[dev]split components & add api

This commit is contained in:
susie-laptop 2025-03-07 10:59:53 -05:00
parent 843df8802a
commit 6c1f0f3166
19 changed files with 1487 additions and 739 deletions

View File

@ -101,4 +101,5 @@ export const icons = {
chat: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16" height="16" fill='currentColor'><path d="M123.6 391.3c12.9-9.4 29.6-11.8 44.6-6.4c26.5 9.6 56.2 15.1 87.8 15.1c124.7 0 208-80.5 208-160s-83.3-160-208-160S48 160.5 48 240c0 32 12.4 62.8 35.7 89.2c8.6 9.7 12.8 22.5 11.8 35.5c-1.4 18.1-5.7 34.7-11.3 49.4c17-7.9 31.1-16.7 39.4-22.7zM21.2 431.9c1.8-2.7 3.5-5.4 5.1-8.1c10-16.6 19.5-38.4 21.4-62.9C17.7 326.8 0 285.1 0 240C0 125.1 114.6 32 256 32s256 93.1 256 208s-114.6 208-256 208c-37.1 0-72.3-6.4-104.1-17.9c-11.9 8.7-31.3 20.6-54.3 30.6c-15.1 6.6-32.3 12.6-50.1 16.1c-.8 .2-1.6 .3-2.4 .5c-4.4 .8-8.7 1.5-13.2 1.9c-.2 0-.5 .1-.7 .1c-5.1 .5-10.2 .8-15.3 .8c-6.5 0-12.3-3.9-14.8-9.9c-2.5-6-1.1-12.8 3.4-17.4c4.1-4.2 7.8-8.7 11.3-13.5c1.7-2.3 3.3-4.6 4.8-6.9l.3-.5z"/></svg>`, chat: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16" height="16" fill='currentColor'><path d="M123.6 391.3c12.9-9.4 29.6-11.8 44.6-6.4c26.5 9.6 56.2 15.1 87.8 15.1c124.7 0 208-80.5 208-160s-83.3-160-208-160S48 160.5 48 240c0 32 12.4 62.8 35.7 89.2c8.6 9.7 12.8 22.5 11.8 35.5c-1.4 18.1-5.7 34.7-11.3 49.4c17-7.9 31.1-16.7 39.4-22.7zM21.2 431.9c1.8-2.7 3.5-5.4 5.1-8.1c10-16.6 19.5-38.4 21.4-62.9C17.7 326.8 0 285.1 0 240C0 125.1 114.6 32 256 32s256 93.1 256 208s-114.6 208-256 208c-37.1 0-72.3-6.4-104.1-17.9c-11.9 8.7-31.3 20.6-54.3 30.6c-15.1 6.6-32.3 12.6-50.1 16.1c-.8 .2-1.6 .3-2.4 .5c-4.4 .8-8.7 1.5-13.2 1.9c-.2 0-.5 .1-.7 .1c-5.1 .5-10.2 .8-15.3 .8c-6.5 0-12.3-3.9-14.8-9.9c-2.5-6-1.1-12.8 3.4-17.4c4.1-4.2 7.8-8.7 11.3-13.5c1.7-2.3 3.3-4.6 4.8-6.9l.3-.5z"/></svg>`,
'arrowup-upload': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" width="16" height="16" fill='currentColor'><path d="M246.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-128 128c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 109.3 192 320c0 17.7 14.3 32 32 32s32-14.3 32-32l0-210.7 73.4 73.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-128-128zM64 352c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 64c0 53 43 96 96 96l256 0c53 0 96-43 96-96l0-64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 64c0 17.7-14.3 32-32 32L96 448c-17.7 0-32-14.3-32-32l0-64z"/></svg>`, 'arrowup-upload': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" width="16" height="16" fill='currentColor'><path d="M246.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-128 128c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 109.3 192 320c0 17.7 14.3 32 32 32s32-14.3 32-32l0-210.7 73.4 73.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-128-128zM64 352c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 64c0 53 43 96 96 96l256 0c53 0 96-43 96-96l0-64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 64c0 17.7-14.3 32-32 32L96 448c-17.7 0-32-14.3-32-32l0-64z"/></svg>`,
send: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16" height="16" fill='currentColor'><path d="M498.1 5.6c10.1 7 15.4 19.1 13.5 31.2l-64 416c-1.5 9.7-7.4 18.2-16 23s-18.9 5.4-28 1.6L284 427.7l-68.5 74.1c-8.9 9.7-22.9 12.9-35.2 8.1S160 493.2 160 480V396.4c0-4 1.5-7.8 4.2-10.7L331.8 202.8c5.8-6.3 5.6-16-.4-22s-15.7-6.4-22-.7L106 360.8 17.7 316.6C7.1 311.3 .3 300.7 0 288.9s5.9-22.8 16.1-28.7l448-256c10.7-6.1 23.9-5.5 34 1.4z"/></svg>`, send: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16" height="16" fill='currentColor'><path d="M498.1 5.6c10.1 7 15.4 19.1 13.5 31.2l-64 416c-1.5 9.7-7.4 18.2-16 23s-18.9 5.4-28 1.6L284 427.7l-68.5 74.1c-8.9 9.7-22.9 12.9-35.2 8.1S160 493.2 160 480V396.4c0-4 1.5-7.8 4.2-10.7L331.8 202.8c5.8-6.3 5.6-16-.4-22s-15.7-6.4-22-.7L106 360.8 17.7 316.6C7.1 311.3 .3 300.7 0 288.9s5.9-22.8 16.1-28.7l448-256c10.7-6.1 23.9-5.5 34 1.4z"/></svg>`,
search: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16" height="16" fill='currentColor'><path d="M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM208 352a144 144 0 1 0 0-288 144 144 0 1 0 0 288z"/></svg>`
}; };

View File

@ -1,13 +1,21 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom'; import { useDispatch } from 'react-redux';
import { showNotification } from '../../../store/notification.slice';
import SvgIcon from '../../../components/SvgIcon'; import SvgIcon from '../../../components/SvgIcon';
//
import Breadcrumb from './components/Breadcrumb';
import DocumentList from './components/DocumentList';
import FileUploadModal from './components/FileUploadModal';
export default function DatasetTab({ knowledgeBase }) { export default function DatasetTab({ knowledgeBase }) {
const dispatch = useDispatch();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedDocuments, setSelectedDocuments] = useState([]); const [selectedDocuments, setSelectedDocuments] = useState([]);
const [selectAll, setSelectAll] = useState(false); const [selectAll, setSelectAll] = useState(false);
const [showBatchDropdown, setShowBatchDropdown] = useState(false); const [showBatchDropdown, setShowBatchDropdown] = useState(false);
const [showAddFileModal, setShowAddFileModal] = useState(false); const [showAddFileModal, setShowAddFileModal] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [newFile, setNewFile] = useState({ const [newFile, setNewFile] = useState({
name: '', name: '',
description: '', description: '',
@ -17,23 +25,13 @@ export default function DatasetTab({ knowledgeBase }) {
const dropdownRef = useRef(null); const dropdownRef = useRef(null);
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
// Convert documents to state so we can update it // Use documents from knowledge base or empty array if not available
const [documents, setDocuments] = useState([ const [documents, setDocuments] = useState(knowledgeBase.documents || []);
{
id: '1001', // Update documents when knowledgeBase changes
name: '测试数据集 001', useEffect(() => {
description: '产品相关的所有文档和说明', setDocuments(knowledgeBase.documents || []);
size: '124kb', }, [knowledgeBase]);
updatedAt: '2023-05-15',
},
{
id: '1002',
name: '产品分析数据',
description: '技术架构和API文档',
size: '89kb',
updatedAt: '2023-05-10',
},
]);
// Handle click outside dropdown // Handle click outside dropdown
useEffect(() => { useEffect(() => {
@ -51,6 +49,11 @@ export default function DatasetTab({ knowledgeBase }) {
}; };
}, [dropdownRef]); }, [dropdownRef]);
// Handle search input change
const handleSearchChange = (e) => {
setSearchQuery(e.target.value);
};
// Handle select all checkbox // Handle select all checkbox
const handleSelectAll = () => { const handleSelectAll = () => {
if (selectAll) { if (selectAll) {
@ -84,6 +87,14 @@ export default function DatasetTab({ knowledgeBase }) {
// Update documents state by removing selected documents // Update documents state by removing selected documents
setDocuments((prevDocuments) => prevDocuments.filter((doc) => !selectedDocuments.includes(doc.id))); setDocuments((prevDocuments) => prevDocuments.filter((doc) => !selectedDocuments.includes(doc.id)));
// Show notification
dispatch(
showNotification({
message: '已删除选中的数据集',
type: 'success',
})
);
// Reset selection // Reset selection
setSelectedDocuments([]); setSelectedDocuments([]);
setSelectAll(false); setSelectAll(false);
@ -166,35 +177,48 @@ export default function DatasetTab({ knowledgeBase }) {
return; return;
} }
setIsSubmitting(true);
// Here you would typically call an API to upload the file // Here you would typically call an API to upload the file
console.log('Uploading file:', newFile); console.log('Uploading file:', newFile);
// Generate a new ID for the document // Simulate API call
const newId = (Math.max(...documents.map((doc) => parseInt(doc.id)), 0) + 1).toString(); setTimeout(() => {
// Generate a new ID for the document
const newId = Date.now().toString();
// Format file size // Format file size
const fileSizeKB = newFile.file ? (newFile.file.size / 1024).toFixed(0) + 'kb' : '0kb'; const fileSizeKB = newFile.file ? (newFile.file.size / 1024).toFixed(0) + 'kb' : '0kb';
// Get current date // Get current date
const today = new Date(); const today = new Date();
const formattedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String( const formattedDate = today.toISOString();
today.getDate()
).padStart(2, '0')}`;
// Create new document object // Create new document object
const newDocument = { const newDocument = {
id: newId, id: newId,
name: newFile.name, name: newFile.name,
description: newFile.description || '无描述', description: newFile.description || '无描述',
size: fileSizeKB, size: fileSizeKB,
updatedAt: formattedDate, create_time: formattedDate,
}; update_time: formattedDate,
};
// Add new document to the documents array // Add new document to the documents array
setDocuments((prevDocuments) => [...prevDocuments, newDocument]); setDocuments((prevDocuments) => [...prevDocuments, newDocument]);
// Reset form and close modal // Show notification
handleCloseAddFileModal(); dispatch(
showNotification({
message: '数据集上传成功',
type: 'success',
})
);
setIsSubmitting(false);
// Reset form and close modal
handleCloseAddFileModal();
}, 1000);
}; };
// Open file selector when clicking on the upload area // Open file selector when clicking on the upload area
@ -220,299 +244,103 @@ export default function DatasetTab({ knowledgeBase }) {
// Update documents state by removing the deleted document // Update documents state by removing the deleted document
setDocuments((prevDocuments) => prevDocuments.filter((doc) => doc.id !== docId)); setDocuments((prevDocuments) => prevDocuments.filter((doc) => doc.id !== docId));
// Show notification
dispatch(
showNotification({
message: '数据集已删除',
type: 'success',
})
);
}; };
// Filter documents based on search query
const filteredDocuments = documents.filter(
(doc) =>
doc.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(doc.description && doc.description.toLowerCase().includes(searchQuery.toLowerCase()))
);
return ( return (
<> <>
{/* Breadcrumb navigation */} {/* Breadcrumb navigation */}
<div className='d-flex align-items-center mb-4 mt-3'> <Breadcrumb knowledgeBase={knowledgeBase} activeTab='datasets' />
<nav aria-label='breadcrumb'>
<ol className='breadcrumb mb-0'>
<li className='breadcrumb-item'>
<Link className='text-secondary text-decoration-none' to='/'>
知识库
</Link>
</li>
<li className='breadcrumb-item'>
<Link
className='text-secondary text-decoration-none'
to={`/knowledge-base/${knowledgeBase.id}`}
>
{knowledgeBase.title}
</Link>
</li>
<li className='breadcrumb-item active text-dark' aria-current='page'>
数据集
</li>
</ol>
</nav>
</div>
{/* Action bar */} {/* Toolbar */}
<div className='d-flex justify-content-between align-items-center mb-3'> <div className='d-flex justify-content-between align-items-center mb-3'>
<div className='d-flex align-items-center'> <div className='d-flex gap-2'>
<div className='dropdown me-2 position-relative' ref={dropdownRef}> <button
<button className='btn btn-primary d-flex align-items-center gap-1'
className={`btn ${ onClick={() => setShowAddFileModal(true)}
selectedDocuments.length > 0 ? 'btn-outline-dark' : 'btn-outline-secondary'
} dropdown-toggle d-flex align-items-center gap-1`}
type='button'
onClick={() => setShowBatchDropdown(!showBatchDropdown)}
aria-expanded={showBatchDropdown}
disabled={selectedDocuments.length === 0}
>
<SvgIcon
className='stack-fill'
width='18'
height='18'
color={selectedDocuments.length > 0 ? '#212529' : '#6c757d'}
/>
批量操作 {selectedDocuments.length > 0 ? `(${selectedDocuments.length})` : ''}
</button>
{showBatchDropdown && (
<ul className='dropdown-menu shadow show' style={{ position: 'absolute', zIndex: 1000 }}>
<li>
<button
className='dropdown-item d-flex align-items-center text-danger'
onClick={handleBatchDelete}
>
<SvgIcon className='trash' width='16' height='16' color='#dc3545' />
<span className='ms-2'>删除</span>
</button>
</li>
</ul>
)}
</div>
<div className='position-relative'>
<input
type='text'
className='form-control'
placeholder='搜索文件...'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<span className='position-absolute top-50 end-0 translate-middle-y pe-3'>
<SvgIcon className='search' color='#6c757d' />
</span>
</div>
</div>
<button
className='btn btn-dark d-flex align-items-center gap-1'
onClick={() => setShowAddFileModal(true)}
>
<SvgIcon className='plus' color='#ffffff' />
新增文件
</button>
</div>
{/* Documents table */}
<div className='card border-0 shadow-sm'>
<div className='card-body p-0'>
<table className='table table-hover mb-0'>
<thead className='table-light'>
<tr>
<th scope='col' width='40'>
<div className='form-check'>
<input
className='form-check-input'
type='checkbox'
id='selectAllCheckbox'
checked={selectAll}
onChange={handleSelectAll}
/>
</div>
</th>
<th scope='col'>ID</th>
<th scope='col'>名称</th>
<th scope='col'>描述</th>
<th scope='col'>文档大小</th>
<th scope='col'>更新日期</th>
<th scope='col'>操作</th>
</tr>
</thead>
<tbody>
{documents.map((doc) => (
<tr key={doc.id}>
<td>
<div className='form-check'>
<input
className='form-check-input'
type='checkbox'
id={`checkbox-${doc.id}`}
checked={selectedDocuments.includes(doc.id)}
onChange={() => handleSelectDocument(doc.id)}
/>
</div>
</td>
<td>#{doc.id}</td>
<td>{doc.name}</td>
<td>{doc.description}</td>
<td>{doc.size}</td>
<td>{doc.updatedAt}</td>
<td>
<div className='d-flex gap-1'>
<button className='btn btn-sm text-primary' title='查看'>
<SvgIcon className='eye' width='18' height='18' />
</button>
<button
className='btn btn-sm text-danger'
title='删除'
onClick={() => handleDeleteDocument(doc.id)}
>
<SvgIcon className='trash' width='18' height='18' />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Pagination */}
<div className='d-flex justify-content-between align-items-center mt-3'>
<div>
每页行数:
<select className='form-select form-select d-inline-block ms-2' style={{ width: '70px' }}>
<option value='5'>5</option>
<option value='10'>10</option>
<option value='20'>20</option>
</select>
</div>
<div className='d-flex align-items-center'>
<span className='me-3'>1-5 of 10</span>
<nav aria-label='Page navigation'>
<ul className='pagination pagination mb-0'>
<li className='page-item'>
<button className='page-link' aria-label='Previous'>
<span aria-hidden='true'>&laquo;</span>
</button>
</li>
<li className='page-item'>
<button className='page-link' aria-label='Next'>
<span aria-hidden='true'>&raquo;</span>
</button>
</li>
</ul>
</nav>
</div>
</div>
{/* Add File Modal */}
{showAddFileModal && (
<div
className='modal-backdrop'
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1050,
}}
>
<div
className='modal-content bg-white rounded shadow'
style={{
width: '500px',
maxWidth: '90%',
padding: '20px',
}}
> >
<div className='modal-header d-flex justify-content-between align-items-center mb-3'> <SvgIcon className='plus' />
<h5 className='modal-title m-0'>新增文件</h5> 上传文件
</button>
{selectedDocuments.length > 0 && (
<div className='dropdown' ref={dropdownRef}>
<button <button
className='btn btn-outline-secondary dropdown-toggle'
type='button' type='button'
className='btn-close' onClick={() => setShowBatchDropdown(!showBatchDropdown)}
onClick={handleCloseAddFileModal}
aria-label='Close'
></button>
</div>
<div className='modal-body'>
<div className='mb-3'>
<label htmlFor='fileName' className='form-label'>
文件名称 <span className='text-danger'>*</span>
</label>
<input
type='text'
className='form-control bg-light'
id='fileName'
value={newFile.name}
placeholder='在此输入文件名称'
readOnly
/>
<small className='text-muted'>文件名称将自动填充为上传文件的名称</small>
</div>
<div className='mb-3'>
<label htmlFor='fileDescription' className='form-label'>
文件描述
</label>
<textarea
className='form-control'
id='fileDescription'
value={newFile.description}
onChange={handleDescriptionChange}
placeholder='在此输入文件描述...'
rows='3'
></textarea>
</div>
<div className='mb-3'>
<label className='form-label'>
上传文件 <span className='text-danger'>*</span>
</label>
<div
className={`border rounded p-4 text-center ${
fileErrors.file ? 'border-danger' : 'border-dashed'
}`}
style={{ cursor: 'pointer' }}
onClick={handleUploadAreaClick}
onDrop={handleFileDrop}
onDragOver={handleDragOver}
>
<input
type='file'
ref={fileInputRef}
onChange={handleFileChange}
style={{ display: 'none' }}
/>
{newFile.file ? (
<div>
<p className='mb-1 fw-bold'>{newFile.name}</p>
<p className='text-muted mb-0'>
{(newFile.file.size / 1024).toFixed(2)} KB
</p>
</div>
) : (
<div>
<SvgIcon className='arrowup-upload' width='36' height='36' />
<p className='my-1'>点击或拖拽文件到此处上传</p>
<p className='text-muted mb-0'>支持 PDF, DOCX, TXT, CSV 等格式</p>
</div>
)}
</div>
{fileErrors.file && <div className='text-danger small mt-1'>{fileErrors.file}</div>}
</div>
</div>
<div className='modal-footer d-flex justify-content-end gap-2'>
<button
type='button'
className='btn btn-outline-secondary'
onClick={handleCloseAddFileModal}
> >
取消 批量操作 ({selectedDocuments.length})
</button>
<button type='button' className='btn btn-dark' onClick={handleFileUpload}>
确定
</button> </button>
{showBatchDropdown && (
<ul
className='dropdown-menu show'
style={{
position: 'absolute',
inset: '0px auto auto 0px',
margin: '0px',
transform: 'translate(0px, 40px)',
}}
>
<li>
<button className='dropdown-item text-danger' onClick={handleBatchDelete}>
<SvgIcon className='trash' />
<span className='ms-1'>删除所选</span>
</button>
</li>
</ul>
)}
</div> </div>
</div> )}
</div> </div>
)} <div className='w-25'>
<input
type='text'
className='form-control'
placeholder='搜索数据集...'
value={searchQuery}
onChange={handleSearchChange}
/>
</div>
</div>
{/* Document list */}
<DocumentList
documents={filteredDocuments}
selectedDocuments={selectedDocuments}
selectAll={selectAll}
onSelectAll={handleSelectAll}
onSelectDocument={handleSelectDocument}
onDeleteDocument={handleDeleteDocument}
/>
{/* File upload modal */}
<FileUploadModal
show={showAddFileModal}
newFile={newFile}
fileErrors={fileErrors}
isSubmitting={isSubmitting}
onClose={handleCloseAddFileModal}
onDescriptionChange={handleDescriptionChange}
onFileChange={handleFileChange}
onFileDrop={handleFileDrop}
onDragOver={handleDragOver}
onUploadAreaClick={handleUploadAreaClick}
onUpload={handleFileUpload}
/>
</> </>
); );
} }

View File

@ -1,11 +1,27 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Link } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import SvgIcon from '../../../components/SvgIcon';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { showNotification } from '../../../store/notification.slice'; import { showNotification } from '../../../store/notification.slice';
import { updateKnowledgeBase, deleteKnowledgeBase } from '../../../store/knowledgeBase/knowledgeBase.thunks';
//
import Breadcrumb from './components/Breadcrumb';
import KnowledgeBaseForm from './components/KnowledgeBaseForm';
import DeleteConfirmModal from './components/DeleteConfirmModal';
export default function SettingsTab({ knowledgeBase }) { export default function SettingsTab({ knowledgeBase }) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const navigate = useNavigate();
// State for knowledge base form
const [knowledgeBaseForm, setKnowledgeBaseForm] = useState({
name: knowledgeBase.name,
desc: knowledgeBase.desc,
});
const [formErrors, setFormErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// State for pagination // State for pagination
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const usersPerPage = 10; const usersPerPage = 10;
@ -13,7 +29,6 @@ export default function SettingsTab({ knowledgeBase }) {
// State for edit modal // State for edit modal
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const [editUser, setEditUser] = useState(null); const [editUser, setEditUser] = useState(null);
const [formErrors, setFormErrors] = useState({});
// Mock data for users with permissions - convert to state so we can update it // Mock data for users with permissions - convert to state so we can update it
const [users, setUsers] = useState([ const [users, setUsers] = useState([
@ -109,17 +124,108 @@ export default function SettingsTab({ knowledgeBase }) {
const currentUsers = users.slice(indexOfFirstUser, indexOfLastUser); const currentUsers = users.slice(indexOfFirstUser, indexOfLastUser);
const totalPages = Math.ceil(users.length / usersPerPage); const totalPages = Math.ceil(users.length / usersPerPage);
// Handle knowledge base form input change
const handleInputChange = (e) => {
const { name, value } = e.target;
setKnowledgeBaseForm((prev) => ({
...prev,
[name]: value,
}));
// Clear error if exists
if (formErrors[name]) {
setFormErrors((prev) => ({
...prev,
[name]: '',
}));
}
};
// Validate knowledge base form
const validateForm = () => {
const errors = {};
if (!knowledgeBaseForm.name.trim()) {
errors.name = '请输入知识库名称';
}
if (!knowledgeBaseForm.desc.trim()) {
errors.desc = '请输入知识库描述';
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
// Handle form submission // Handle form submission
const handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault(); e.preventDefault();
// Here you would typically call an API to update the knowledge base settings
console.log('Updating knowledge base settings'); // Validate form
if (!validateForm()) {
return;
}
setIsSubmitting(true);
// Dispatch update knowledge base action
dispatch(
updateKnowledgeBase({
id: knowledgeBase.id,
data: {
name: knowledgeBaseForm.name,
desc: knowledgeBaseForm.desc,
},
})
)
.unwrap()
.then(() => {
dispatch(
showNotification({
message: '知识库更新成功',
type: 'success',
})
);
setIsSubmitting(false);
})
.catch((error) => {
dispatch(
showNotification({
message: `更新失败: ${error.message || '未知错误'}`,
type: 'danger',
})
);
setIsSubmitting(false);
});
}; };
// Handle knowledge base deletion // Handle knowledge base deletion
const handleDelete = () => { const handleDelete = () => {
// Here you would typically call an API to delete the knowledge base setIsSubmitting(true);
console.log('Deleting knowledge base:', knowledgeBase.id);
// Dispatch delete knowledge base action
dispatch(deleteKnowledgeBase(knowledgeBase.id))
.unwrap()
.then(() => {
dispatch(
showNotification({
message: '知识库已删除',
type: 'success',
})
);
// Navigate back to knowledge base list
navigate('/knowledge-base');
})
.catch((error) => {
dispatch(
showNotification({
message: `删除失败: ${error.message || '未知错误'}`,
type: 'danger',
})
);
setIsSubmitting(false);
setShowDeleteConfirm(false);
});
}; };
// Handle edit user permissions // Handle edit user permissions
@ -202,206 +308,27 @@ export default function SettingsTab({ knowledgeBase }) {
return ( return (
<> <>
{/* Breadcrumb navigation */} {/* Breadcrumb navigation */}
<div className='d-flex align-items-center mb-4 mt-3'> <Breadcrumb knowledgeBase={knowledgeBase} activeTab='settings' />
<nav aria-label='breadcrumb'>
<ol className='breadcrumb mb-0'>
<li className='breadcrumb-item'>
<Link className='text-secondary text-decoration-none' to='/'>
知识库
</Link>
</li>
<li className='breadcrumb-item'>
<Link
className='text-secondary text-decoration-none'
to={`/knowledge-base/${knowledgeBase.id}`}
>
{knowledgeBase.title}
</Link>
</li>
<li className='breadcrumb-item active text-dark' aria-current='page'>
设置
</li>
</ol>
</nav>
</div>
{/* Settings form */} {/* Knowledge Base Form */}
<div className='card border-0 shadow-sm'> <KnowledgeBaseForm
<div className='card-body'> formData={knowledgeBaseForm}
<h5 className='card-title mb-4'>知识库设置</h5> formErrors={formErrors}
isSubmitting={isSubmitting}
knowledgeBase={knowledgeBase}
onInputChange={handleInputChange}
onSubmit={handleSubmit}
onDelete={() => setShowDeleteConfirm(true)}
/>
<form onSubmit={handleSubmit}> {/* Delete confirmation modal */}
<div className='mb-3'> <DeleteConfirmModal
<label htmlFor='knowledgeTitle' className='form-label'> show={showDeleteConfirm}
知识库名称 title={knowledgeBase.name}
</label> isSubmitting={isSubmitting}
<input onCancel={() => setShowDeleteConfirm(false)}
type='text' onConfirm={handleDelete}
className='form-control' />
id='knowledgeTitle'
defaultValue={knowledgeBase.title}
/>
</div>
<div className='mb-3'>
<label htmlFor='knowledgeDescription' className='form-label'>
知识库描述
</label>
<textarea
className='form-control'
id='knowledgeDescription'
rows='3'
defaultValue={knowledgeBase.description}
></textarea>
</div>
<div className='mb-3'>
<label className='form-label'>访问权限</label>
<div className='form-check'>
<input
className='form-check-input'
type='radio'
name='accessPermission'
id='accessPublic'
defaultChecked
/>
<label className='form-check-label' htmlFor='accessPublic'>
公开 - 所有人可访问
</label>
</div>
<div className='form-check'>
<input
className='form-check-input'
type='radio'
name='accessPermission'
id='accessPrivate'
/>
<label className='form-check-label' htmlFor='accessPrivate'>
私有 - 仅创建者可访问
</label>
</div>
</div>
<div className='mb-3'>
<label className='form-label'>权限管理</label>
<div className='card'>
<div className='card-body p-0'>
<table className='table mb-0'>
<thead className='table-light'>
<tr>
<th scope='col'>ID</th>
<th scope='col'>用户名</th>
<th scope='col'>邮箱</th>
<th scope='col'>权限类型</th>
<th scope='col'>访问时长</th>
<th scope='col'>操作</th>
</tr>
</thead>
<tbody>
{currentUsers.map((user) => (
<tr key={user.id}>
<td>#{user.id}</td>
<td>{user.username}</td>
<td>{user.email}</td>
<td>
<span
className={
user.permissionType === '完全访问'
? 'text-success'
: 'text-warning'
}
>
{user.permissionType}
</span>
</td>
<td>{user.accessDuration}</td>
<td>
<div className='d-flex gap-2'>
<button
className='btn btn-sm text-primary'
title='编辑'
onClick={() => handleEditUser(user)}
>
<SvgIcon className='edit' width='16' height='16' />
</button>
<button
className='btn btn-sm text-danger'
title='删除'
onClick={() => handleDeleteUser(user.id)}
>
<SvgIcon className='trash' width='16' height='16' />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Pagination */}
{users.length > usersPerPage && (
<div className='d-flex justify-content-end mt-3'>
<nav aria-label='Page navigation'>
<ul className='pagination pagination-sm mb-0'>
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
<button
className='page-link'
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
>
<span aria-hidden='true'>&laquo;</span>
</button>
</li>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNumber) => (
<li
key={pageNumber}
className={`page-item ${
currentPage === pageNumber ? 'active' : ''
}`}
>
<button
className='page-link'
onClick={() => handlePageChange(pageNumber)}
>
{pageNumber}
</button>
</li>
))}
<li className={`page-item ${currentPage === totalPages ? 'disabled' : ''}`}>
<button
className='page-link'
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
<span aria-hidden='true'>&raquo;</span>
</button>
</li>
</ul>
</nav>
</div>
)}
<button type='button' className='btn btn-outline-dark mt-2'>
<SvgIcon className='plus' width='16' height='16' />
<span className='ms-1'>添加用户</span>
</button>
</div>
<div className='d-flex justify-content-end gap-2 mt-4'>
<button type='button' className='btn btn-outline-danger' onClick={handleDelete}>
删除知识库
</button>
<button type='submit' className='btn btn-dark'>
保存设置
</button>
</div>
</form>
</div>
</div>
{/* Edit User Permissions Modal */} {/* Edit User Permissions Modal */}
{showEditModal && ( {showEditModal && (

View File

@ -0,0 +1,34 @@
import React from 'react';
import { Link } from 'react-router-dom';
/**
* 面包屑导航组件
*/
const Breadcrumb = ({ knowledgeBase, activeTab }) => {
return (
<div className='d-flex align-items-center mb-4 mt-3'>
<nav aria-label='breadcrumb'>
<ol className='breadcrumb mb-0'>
<li className='breadcrumb-item'>
<Link className='text-secondary text-decoration-none' to='/knowledge-base'>
知识库
</Link>
</li>
<li className='breadcrumb-item'>
<Link
className='text-secondary text-decoration-none'
to={`/knowledge-base/${knowledgeBase.id}`}
>
{knowledgeBase.name}
</Link>
</li>
<li className='breadcrumb-item active text-dark' aria-current='page'>
{activeTab === 'datasets' ? '数据集' : '设置'}
</li>
</ol>
</nav>
</div>
);
};
export default Breadcrumb;

View File

@ -0,0 +1,70 @@
import React from 'react';
/**
* 删除确认模态框组件
*/
const DeleteConfirmModal = ({ show, title, isSubmitting, onCancel, onConfirm }) => {
if (!show) return null;
return (
<div
className='modal-backdrop'
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1050,
}}
>
<div
className='modal-content bg-white rounded shadow'
style={{
width: '400px',
maxWidth: '90%',
padding: '20px',
}}
>
<div className='modal-header d-flex justify-content-between align-items-center mb-3'>
<h5 className='modal-title m-0'>确认删除</h5>
<button
type='button'
className='btn-close'
onClick={onCancel}
disabled={isSubmitting}
aria-label='Close'
></button>
</div>
<div className='modal-body'>
<p>您确定要删除知识库 "{title}" 此操作不可撤销</p>
</div>
<div className='modal-footer'>
<button type='button' className='btn btn-secondary' onClick={onCancel} disabled={isSubmitting}>
取消
</button>
<button type='button' className='btn btn-danger' onClick={onConfirm} disabled={isSubmitting}>
{isSubmitting ? (
<>
<span
className='spinner-border spinner-border-sm me-2'
role='status'
aria-hidden='true'
></span>
删除中...
</>
) : (
'确认删除'
)}
</button>
</div>
</div>
</div>
);
};
export default DeleteConfirmModal;

View File

@ -0,0 +1,72 @@
import React from 'react';
import SvgIcon from '../../../../components/SvgIcon';
/**
* 文档列表组件
*/
const DocumentList = ({ documents, selectedDocuments, onSelectAll, onSelectDocument, onDeleteDocument, selectAll }) => {
if (documents.length === 0) {
return <div className='alert alert-warning'>暂无数据集请上传数据集</div>;
}
return (
<div className='table-responsive'>
<table className='table table-hover'>
<thead className='table-light'>
<tr>
<th scope='col' width='40'>
<div className='form-check'>
<input
className='form-check-input'
type='checkbox'
checked={selectAll}
onChange={onSelectAll}
/>
</div>
</th>
<th scope='col'>名称</th>
<th scope='col'>描述</th>
<th scope='col'>大小</th>
<th scope='col'>更新时间</th>
<th scope='col' width='100'>
操作
</th>
</tr>
</thead>
<tbody>
{documents.map((doc) => (
<tr key={doc.id}>
<td>
<div className='form-check'>
<input
className='form-check-input'
type='checkbox'
checked={selectedDocuments.includes(doc.id)}
onChange={() => onSelectDocument(doc.id)}
/>
</div>
</td>
<td>{doc.name}</td>
<td>{doc.description}</td>
<td>{doc.size}</td>
<td>{new Date(doc.update_time).toLocaleDateString()}</td>
<td>
<div className='d-flex gap-1'>
<button
className='btn btn-sm text-danger'
title='删除'
onClick={() => onDeleteDocument(doc.id)}
>
<SvgIcon className='trash' width='16' height='16' />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
export default DocumentList;

View File

@ -0,0 +1,118 @@
import React, { useRef } from 'react';
/**
* 文件上传模态框组件
*/
const FileUploadModal = ({
show,
newFile,
fileErrors,
isSubmitting,
onClose,
onDescriptionChange,
onFileChange,
onFileDrop,
onDragOver,
onUploadAreaClick,
onUpload,
}) => {
const fileInputRef = useRef(null);
if (!show) return null;
return (
<div
className='modal-backdrop'
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1050,
}}
>
<div
className='modal-content bg-white rounded shadow'
style={{
width: '500px',
maxWidth: '90%',
padding: '20px',
}}
>
<div className='modal-header d-flex justify-content-between align-items-center mb-3'>
<h5 className='modal-title m-0'>上传文件</h5>
<button type='button' className='btn-close' onClick={onClose} aria-label='Close'></button>
</div>
<div className='modal-body'>
<div
className={`mb-3 p-4 border rounded text-center ${
fileErrors.file ? 'border-danger' : 'border-dashed'
}`}
style={{ cursor: 'pointer' }}
onClick={onUploadAreaClick}
onDrop={onFileDrop}
onDragOver={onDragOver}
>
<input type='file' ref={fileInputRef} className='d-none' onChange={onFileChange} />
{newFile.file ? (
<div>
<p className='mb-1'>已选择文件</p>
<p className='fw-bold mb-0'>{newFile.file.name}</p>
</div>
) : (
<div>
<p className='mb-1'>点击或拖拽文件到此处上传</p>
<p className='text-muted small mb-0'>支持 PDF, DOCX, TXT, CSV 等格式</p>
</div>
)}
{fileErrors.file && <div className='text-danger mt-2'>{fileErrors.file}</div>}
</div>
<div className='mb-3'>
<label htmlFor='fileDescription' className='form-label'>
文件描述
</label>
<textarea
className='form-control'
id='fileDescription'
rows='3'
value={newFile.description}
onChange={onDescriptionChange}
placeholder='请输入文件描述(可选)'
></textarea>
</div>
</div>
<div className='modal-footer'>
<button type='button' className='btn btn-secondary' onClick={onClose}>
取消
</button>
<button
type='button'
className='btn btn-primary'
onClick={onUpload}
disabled={!newFile.file || isSubmitting}
>
{isSubmitting ? (
<>
<span
className='spinner-border spinner-border-sm me-2'
role='status'
aria-hidden='true'
></span>
上传中...
</>
) : (
'上传'
)}
</button>
</div>
</div>
</div>
);
};
export default FileUploadModal;

View File

@ -0,0 +1,116 @@
import React from 'react';
/**
* 知识库表单组件
*/
const KnowledgeBaseForm = ({
formData,
formErrors,
isSubmitting,
knowledgeBase,
onInputChange,
onSubmit,
onDelete,
}) => {
return (
<div className='card border-0 shadow-sm'>
<div className='card-body'>
<h5 className='card-title mb-4'>知识库设置</h5>
<form onSubmit={onSubmit}>
<div className='mb-3'>
<label htmlFor='name' className='form-label'>
知识库名称 <span className='text-danger'>*</span>
</label>
<input
type='text'
className={`form-control ${formErrors.name ? 'is-invalid' : ''}`}
id='name'
name='name'
value={formData.name}
onChange={onInputChange}
/>
{formErrors.name && <div className='invalid-feedback'>{formErrors.name}</div>}
</div>
<div className='mb-3'>
<label htmlFor='desc' className='form-label'>
知识库描述 <span className='text-danger'>*</span>
</label>
<textarea
className={`form-control ${formErrors.desc ? 'is-invalid' : ''}`}
id='desc'
name='desc'
rows='3'
value={formData.desc}
onChange={onInputChange}
></textarea>
{formErrors.desc && <div className='invalid-feedback'>{formErrors.desc}</div>}
</div>
<div className='mb-3'>
<label className='form-label'>知识库类型</label>
<div className='form-check'>
<input
className='form-check-input'
type='radio'
name='knowledgeType'
id='typePrivate'
checked={knowledgeBase.type === 'private'}
readOnly
/>
<label className='form-check-label' htmlFor='typePrivate'>
私有知识库
</label>
</div>
<div className='form-check'>
<input
className='form-check-input'
type='radio'
name='knowledgeType'
id='typePublic'
checked={knowledgeBase.type === 'public'}
readOnly
/>
<label className='form-check-label' htmlFor='typePublic'>
公共知识库
</label>
</div>
</div>
<div className='mb-3'>
<label className='form-label'>创建时间</label>
<p className='form-control-static'>{new Date(knowledgeBase.create_time).toLocaleString()}</p>
</div>
<div className='mb-3'>
<label className='form-label'>最后更新时间</label>
<p className='form-control-static'>{new Date(knowledgeBase.update_time).toLocaleString()}</p>
</div>
<div className='d-flex justify-content-between'>
<button type='submit' className='btn btn-primary' disabled={isSubmitting}>
{isSubmitting ? (
<>
<span
className='spinner-border spinner-border-sm me-2'
role='status'
aria-hidden='true'
></span>
保存中...
</>
) : (
'保存设置'
)}
</button>
<button type='button' className='btn btn-danger' onClick={onDelete} disabled={isSubmitting}>
删除知识库
</button>
</div>
</form>
</div>
</div>
);
};
export default KnowledgeBaseForm;

View File

@ -1,10 +1,21 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import KnowledgeCard from './KnowledgeCard'; import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { showNotification } from '../../store/notification.slice'; import { showNotification } from '../../store/notification.slice';
import {
fetchKnowledgeBases,
searchKnowledgeBases,
createKnowledgeBase,
} from '../../store/knowledgeBase/knowledgeBase.thunks';
import { resetSearchState } from '../../store/knowledgeBase/knowledgeBase.slice';
import SvgIcon from '../../components/SvgIcon'; import SvgIcon from '../../components/SvgIcon';
//
import SearchBar from './components/SearchBar';
import Pagination from './components/Pagination';
import CreateKnowledgeBaseModal from './components/CreateKnowledgeBaseModal';
import KnowledgeBaseList from './components/KnowledgeBaseList';
export default function KnowledgeBase() { export default function KnowledgeBase() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const navigate = useNavigate(); const navigate = useNavigate();
@ -21,36 +32,146 @@ export default function KnowledgeBase() {
reason: '', reason: '',
}); });
const [newKnowledgeBase, setNewKnowledgeBase] = useState({ const [newKnowledgeBase, setNewKnowledgeBase] = useState({
title: '', name: '',
description: '', desc: '',
}); });
const knowledgeList = [ // Search state
{ const [searchKeyword, setSearchKeyword] = useState('');
id: '1', const [isSearching, setIsSearching] = useState(false);
title: '产品开发知识库',
description: '产品开发流程及规范说明文档', // Pagination state
documents: 24, const [pagination, setPagination] = useState({
date: '2025-02-15', page: 1,
access: 'full', page_size: 10,
}, });
{
id: '2', // Get knowledge bases from Redux store
title: '市场分析知识库', const { items: knowledgeBases, total, status, error } = useSelector((state) => state.knowledgeBase.list);
description: '2025年Q1市场分析总结', const {
documents: 12, items: searchResults,
date: '2025-02-10', total: searchTotal,
access: 'read', status: searchStatus,
}, error: searchError,
{ keyword: storeKeyword,
id: '3', } = useSelector((state) => state.knowledgeBase.search);
title: '财务知识库', const { status: operationStatus, error: operationError } = useSelector((state) => state.knowledgeBase.operations);
description: '月度财务分析报告',
documents: 8, // Determine which data to display based on search state
date: '2025-02-01', const displayData = isSearching ? searchResults : knowledgeBases;
access: 'none', const displayTotal = isSearching ? searchTotal : total;
}, const displayStatus = isSearching ? searchStatus : status;
]; const displayError = isSearching ? searchError : error;
// Fetch knowledge bases when component mounts or pagination changes
useEffect(() => {
if (!isSearching) {
dispatch(fetchKnowledgeBases(pagination));
} else if (searchKeyword.trim()) {
dispatch(
searchKnowledgeBases({
keyword: searchKeyword,
page: pagination.page,
page_size: pagination.page_size,
})
);
}
}, [dispatch, pagination.page, pagination.page_size, isSearching, searchKeyword]);
// Handle search input change
const handleSearchInputChange = (e) => {
setSearchKeyword(e.target.value);
};
// Handle search submit
const handleSearch = (e) => {
e.preventDefault();
if (searchKeyword.trim()) {
setIsSearching(true);
setPagination((prev) => ({ ...prev, page: 1 })); // Reset to first page
dispatch(
searchKnowledgeBases({
keyword: searchKeyword,
page: 1,
page_size: pagination.page_size,
})
);
} else {
// If search is empty, reset to normal list view
handleClearSearch();
}
};
// Handle clear search
const handleClearSearch = () => {
setSearchKeyword('');
setIsSearching(false);
setPagination((prev) => ({ ...prev, page: 1 })); // Reset to first page
dispatch(resetSearchState());
};
// Show loading state while fetching data
const isLoading = displayStatus === 'loading';
// Show error notification if fetch fails
useEffect(() => {
if (displayStatus === 'failed' && displayError) {
dispatch(
showNotification({
message: `获取知识库列表失败: ${displayError.message || displayError}`,
type: 'danger',
})
);
}
}, [displayStatus, displayError, dispatch]);
// Show notification for operation status
useEffect(() => {
if (operationStatus === 'succeeded') {
dispatch(
showNotification({
message: '操作成功',
type: 'success',
})
);
// Refresh the list after successful operation
if (isSearching && searchKeyword.trim()) {
dispatch(
searchKnowledgeBases({
keyword: searchKeyword,
page: pagination.page,
page_size: pagination.page_size,
})
);
} else {
dispatch(fetchKnowledgeBases(pagination));
}
} else if (operationStatus === 'failed' && operationError) {
dispatch(
showNotification({
message: `操作失败: ${operationError.message || operationError}`,
type: 'danger',
})
);
}
}, [operationStatus, operationError, dispatch, pagination, isSearching, searchKeyword]);
// Handle pagination change
const handlePageChange = (newPage) => {
setPagination((prev) => ({
...prev,
page: newPage,
}));
};
// Handle page size change
const handlePageSizeChange = (newPageSize) => {
setPagination({
page: 1, // Reset to first page when changing page size
page_size: newPageSize,
});
};
const handleInputChange = (e) => { const handleInputChange = (e) => {
const { name, value } = e.target; const { name, value } = e.target;
@ -87,12 +208,12 @@ export default function KnowledgeBase() {
const validateCreateForm = () => { const validateCreateForm = () => {
const errors = {}; const errors = {};
if (!newKnowledgeBase.title.trim()) { if (!newKnowledgeBase.name.trim()) {
errors.title = '请输入知识库名称'; errors.name = '请输入知识库名称';
} }
if (!newKnowledgeBase.description.trim()) { if (!newKnowledgeBase.desc.trim()) {
errors.description = '请输入知识库描述'; errors.desc = '请输入知识库描述';
} }
setFormErrors(errors); setFormErrors(errors);
@ -120,27 +241,22 @@ export default function KnowledgeBase() {
return; return;
} }
// For now, just show a success notification // Dispatch create knowledge base action
dispatch( dispatch(
showNotification({ createKnowledgeBase({
message: '知识库创建成功', name: newKnowledgeBase.name,
type: 'success', desc: newKnowledgeBase.desc,
type: 'private', // Default type
}) })
); );
// In a real application, you would get the ID from the API response
// For now, we'll generate a mock ID
const newId = Date.now().toString();
// Reset form and close modal // Reset form and close modal
setNewKnowledgeBase({ title: '', description: '' }); setNewKnowledgeBase({ name: '', desc: '' });
setFormErrors({}); setFormErrors({});
setShowCreateModal(false); setShowCreateModal(false);
// Navigate to the newly created knowledge base with datasets tab
navigate(`/knowledge-base/${newId}/datasets`);
}; };
// Handle card click to navigate to knowledge base detail
const handleCardClick = (id) => { const handleCardClick = (id) => {
navigate(`/knowledge-base/${id}/datasets`); navigate(`/knowledge-base/${id}/datasets`);
}; };
@ -179,10 +295,19 @@ export default function KnowledgeBase() {
setShowAccessRequestModal(false); setShowAccessRequestModal(false);
}; };
// Calculate total pages
const totalPages = Math.ceil(displayTotal / pagination.page_size);
return ( return (
<div className='knowledge-base container mt-4'> <div className='knowledge-base container mt-4'>
<div className='d-flex justify-content-between align-items-center mb-3'> <div className='d-flex justify-content-between align-items-center mb-3'>
<input type='text' className='form-control w-50' placeholder='搜索知识库...' /> <SearchBar
searchKeyword={searchKeyword}
isSearching={isSearching}
onSearchChange={handleSearchInputChange}
onSearch={handleSearch}
onClearSearch={handleClearSearch}
/>
<button <button
className='btn btn-dark d-flex align-items-center gap-1' className='btn btn-dark d-flex align-items-center gap-1'
onClick={() => setShowCreateModal(true)} onClick={() => setShowCreateModal(true)}
@ -192,105 +317,52 @@ export default function KnowledgeBase() {
</button> </button>
</div> </div>
<div className='row gap-3 m-0'> {isSearching && (
{knowledgeList.map((item) => ( <div className='alert alert-info'>
<React.Fragment key={item.id}> 搜索结果: "{storeKeyword}" - 找到 {displayTotal} 个知识库
<KnowledgeCard
{...item}
onClick={() => handleCardClick(item.id)}
onRequestAccess={handleRequestAccess}
/>
</React.Fragment>
))}
</div>
{/* 新建知识库弹窗 */}
{showCreateModal && (
<div
className='modal-backdrop'
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1050,
}}
>
<div
className='modal-content bg-white rounded shadow'
style={{
width: '500px',
maxWidth: '90%',
padding: '20px',
}}
>
<div className='modal-header d-flex justify-content-between align-items-center mb-3'>
<h5 className='modal-title m-0'>新建知识库</h5>
<button
type='button'
className='btn-close'
onClick={() => setShowCreateModal(false)}
aria-label='Close'
></button>
</div>
<div className='modal-body'>
<div className='mb-3'>
<label htmlFor='knowledgeTitle' className='form-label'>
知识库名称 <span className='text-danger'>*</span>
</label>
<input
type='text'
className={`form-control ${formErrors.title ? 'is-invalid' : ''}`}
id='knowledgeTitle'
name='title'
value={newKnowledgeBase.title}
onChange={handleInputChange}
placeholder='请输入知识库名称'
required
/>
{formErrors.title && <div className='invalid-feedback'>{formErrors.title}</div>}
</div>
<div className='mb-3'>
<label htmlFor='knowledgeDescription' className='form-label'>
知识库描述 <span className='text-danger'>*</span>
</label>
<textarea
className={`form-control ${formErrors.description ? 'is-invalid' : ''}`}
id='knowledgeDescription'
name='description'
value={newKnowledgeBase.description}
onChange={handleInputChange}
placeholder='请输入知识库描述'
rows='3'
required
></textarea>
{formErrors.description && (
<div className='invalid-feedback'>{formErrors.description}</div>
)}
</div>
</div>
<div className='modal-footer d-flex justify-content-end gap-2'>
<button
type='button'
className='btn btn-outline-secondary'
onClick={() => setShowCreateModal(false)}
>
取消
</button>
<button type='button' className='btn btn-dark' onClick={handleCreateKnowledgeBase}>
创建
</button>
</div>
</div>
</div> </div>
)} )}
{/* 申请访问权限弹窗 */} {isLoading ? (
<div className='d-flex justify-content-center my-5'>
<div className='spinner-border' role='status'>
<span className='visually-hidden'>加载中...</span>
</div>
</div>
) : (
<>
<KnowledgeBaseList
knowledgeBases={displayData}
isSearching={isSearching}
onCardClick={handleCardClick}
onRequestAccess={handleRequestAccess}
/>
{/* Pagination */}
{totalPages > 1 && (
<Pagination
currentPage={pagination.page}
totalPages={totalPages}
pageSize={pagination.page_size}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
/>
)}
</>
)}
{/* 新建知识库弹窗 */}
<CreateKnowledgeBaseModal
show={showCreateModal}
formData={newKnowledgeBase}
formErrors={formErrors}
isSubmitting={operationStatus === 'loading'}
onClose={() => setShowCreateModal(false)}
onChange={handleInputChange}
onSubmit={handleCreateKnowledgeBase}
/>
{/* 申请权限弹窗 */}
{showAccessRequestModal && ( {showAccessRequestModal && (
<div <div
className='modal-backdrop' className='modal-backdrop'
@ -325,54 +397,29 @@ export default function KnowledgeBase() {
></button> ></button>
</div> </div>
<div className='modal-body'> <div className='modal-body'>
<div className='mb-4'>
<label className='form-label d-flex align-items-center gap-1'>
<SvgIcon className='key' />
访问级别 <span className='text-danger'>*</span>
</label>
<div className='d-flex gap-2'>
<div
className={`p-3 rounded border bg-warning-subtle ${
accessRequestData.accessType === '只读访问'
? 'border-warning bg-warning-subtle'
: 'border-warning-subtle opacity-50'
}`}
style={{ flex: 1, cursor: 'pointer' }}
onClick={() =>
setAccessRequestData((prev) => ({ ...prev, accessType: '只读访问' }))
}
>
<div className='text-center text-warning fw-bold mb-1'>只读访问</div>
<div className='text-center text-muted small'>仅查看数据集内容</div>
</div>
<div
className={`p-3 rounded border bg-success-subtle ${
accessRequestData.accessType === '完全访问'
? 'border-success'
: 'border-success-subtle opacity-50'
}`}
style={{ flex: 1, cursor: 'pointer' }}
onClick={() =>
setAccessRequestData((prev) => ({ ...prev, accessType: '完全访问' }))
}
>
<div className='text-center text-success fw-bold mb-1'>完全访问</div>
<div className='text-center text-muted small'>查看编辑和管理数据</div>
</div>
</div>
</div>
<div className='mb-3'> <div className='mb-3'>
<label className='form-label d-flex align-items-center gap-1'> <label className='form-label'>知识库名称</label>
<SvgIcon className='calendar' /> <input type='text' className='form-control' value={accessRequestData.title} readOnly />
访问时长 <span className='text-danger'>*</span> </div>
</label> <div className='mb-3'>
<label className='form-label'>权限类型</label>
<select
className='form-select'
name='accessType'
value={accessRequestData.accessType}
onChange={handleAccessRequestInputChange}
>
<option value='只读访问'>只读访问</option>
<option value='编辑权限'>编辑权限</option>
</select>
</div>
<div className='mb-3'>
<label className='form-label'>访问时长</label>
<select <select
className='form-select' className='form-select'
name='duration' name='duration'
value={accessRequestData.duration} value={accessRequestData.duration}
onChange={handleAccessRequestInputChange} onChange={handleAccessRequestInputChange}
required
> >
<option value='一周'>一周</option> <option value='一周'>一周</option>
<option value='一个月'>一个月</option> <option value='一个月'>一个月</option>
@ -381,54 +428,50 @@ export default function KnowledgeBase() {
<option value='永久'>永久</option> <option value='永久'>永久</option>
</select> </select>
</div> </div>
<div className='mb-3'> <div className='mb-3'>
<label className='form-label d-flex align-items-center gap-1'> <label htmlFor='projectInfo' className='form-label'>
<SvgIcon className='clipboard' />
项目信息 <span className='text-danger'>*</span> 项目信息 <span className='text-danger'>*</span>
</label> </label>
<input <input
type='text' type='text'
className={`form-control ${accessRequestErrors.projectInfo ? 'is-invalid' : ''}`} className={`form-control ${accessRequestErrors.projectInfo ? 'is-invalid' : ''}`}
id='projectInfo'
name='projectInfo' name='projectInfo'
value={accessRequestData.projectInfo} value={accessRequestData.projectInfo}
onChange={handleAccessRequestInputChange} onChange={handleAccessRequestInputChange}
placeholder='请输入项目信息' placeholder='请输入项目信息'
required
/> />
{accessRequestErrors.projectInfo && ( {accessRequestErrors.projectInfo && (
<div className='invalid-feedback'>{accessRequestErrors.projectInfo}</div> <div className='invalid-feedback'>{accessRequestErrors.projectInfo}</div>
)} )}
</div> </div>
<div className='mb-3'> <div className='mb-3'>
<label className='form-label d-flex align-items-center gap-1'> <label htmlFor='reason' className='form-label'>
<SvgIcon className='chat' />
申请原因 <span className='text-danger'>*</span> 申请原因 <span className='text-danger'>*</span>
</label> </label>
<textarea <textarea
className={`form-control ${accessRequestErrors.reason ? 'is-invalid' : ''}`} className={`form-control ${accessRequestErrors.reason ? 'is-invalid' : ''}`}
id='reason'
name='reason' name='reason'
rows='3'
value={accessRequestData.reason} value={accessRequestData.reason}
onChange={handleAccessRequestInputChange} onChange={handleAccessRequestInputChange}
placeholder='请输入申请原因' placeholder='请输入申请原因'
rows='4'
required
></textarea> ></textarea>
{accessRequestErrors.reason && ( {accessRequestErrors.reason && (
<div className='invalid-feedback'>{accessRequestErrors.reason}</div> <div className='invalid-feedback'>{accessRequestErrors.reason}</div>
)} )}
</div> </div>
</div> </div>
<div className='modal-footer d-flex justify-content-end gap-2'> <div className='modal-footer'>
<button <button
type='button' type='button'
className='btn btn-outline-secondary' className='btn btn-secondary'
onClick={() => setShowAccessRequestModal(false)} onClick={() => setShowAccessRequestModal(false)}
> >
取消 取消
</button> </button>
<button type='button' className='btn btn-dark' onClick={handleSubmitAccessRequest}> <button type='button' className='btn btn-primary' onClick={handleSubmitAccessRequest}>
提交申请 提交申请
</button> </button>
</div> </div>

View File

@ -1,5 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
import { showNotification } from '../../store/notification.slice';
import SvgIcon from '../../components/SvgIcon'; import SvgIcon from '../../components/SvgIcon';
import DatasetTab from './Detail/DatasetTab'; import DatasetTab from './Detail/DatasetTab';
import SettingsTab from './Detail/SettingsTab'; import SettingsTab from './Detail/SettingsTab';
@ -7,8 +9,13 @@ import SettingsTab from './Detail/SettingsTab';
export default function KnowledgeBaseDetail() { export default function KnowledgeBaseDetail() {
const { id, tab } = useParams(); const { id, tab } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useDispatch();
const [activeTab, setActiveTab] = useState(tab === 'settings' ? 'settings' : 'datasets'); const [activeTab, setActiveTab] = useState(tab === 'settings' ? 'settings' : 'datasets');
// Get knowledge base details from Redux store
const { items: knowledgeBases } = useSelector((state) => state.knowledgeBase.list);
const knowledgeBase = knowledgeBases.find((kb) => kb.id === id);
// Update active tab when URL changes // Update active tab when URL changes
useEffect(() => { useEffect(() => {
if (tab) { if (tab) {
@ -16,15 +23,18 @@ export default function KnowledgeBaseDetail() {
} }
}, [tab]); }, [tab]);
// Mock data for the knowledge base details // If knowledge base not found in Redux store, show notification and redirect
const knowledgeBase = { useEffect(() => {
id: id, if (!knowledgeBase && knowledgeBases.length > 0) {
title: '知识库 1', dispatch(
description: '知识库详细信息', showNotification({
createdAt: '2023-05-01', message: '未找到知识库,请返回知识库列表',
updatedAt: '2023-05-15', type: 'warning',
documentsCount: 24, })
}; );
navigate('/knowledge-base');
}
}, [knowledgeBase, knowledgeBases, dispatch, navigate]);
// Handle tab change // Handle tab change
const handleTabChange = (tab) => { const handleTabChange = (tab) => {
@ -32,14 +42,25 @@ export default function KnowledgeBaseDetail() {
navigate(`/knowledge-base/${id}/${tab}`); navigate(`/knowledge-base/${id}/${tab}`);
}; };
// Show loading state if knowledge base not loaded yet
if (!knowledgeBase) {
return (
<div className='container-fluid px-4 py-5 text-center'>
<div className='spinner-border' role='status'>
<span className='visually-hidden'>加载中...</span>
</div>
</div>
);
}
return ( return (
<div className='container-fluid px-4'> <div className='container-fluid px-4'>
<div className='row'> <div className='row'>
{/* Sidebar */} {/* Sidebar */}
<div className='col-md-3 col-lg-2 border-end'> <div className='col-md-3 col-lg-2 border-end'>
<div className='py-4'> <div className='py-4'>
<div className='h4 mb-3 text-center'>{knowledgeBase.title}</div> <div className='h4 mb-3 text-center'>{knowledgeBase.name}</div>
<p className='text-center text-muted small mb-4'>{knowledgeBase.description}</p> <p className='text-center text-muted small mb-4'>{knowledgeBase.desc}</p>
<hr /> <hr />

View File

@ -0,0 +1,92 @@
import React from 'react';
import SvgIcon from '../../../components/SvgIcon';
/**
* 创建知识库模态框组件
*/
const CreateKnowledgeBaseModal = ({ show, formData, formErrors, isSubmitting, onClose, onChange, onSubmit }) => {
if (!show) return null;
return (
<div
className='modal-backdrop'
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1050,
}}
>
<div
className='modal-content bg-white rounded shadow'
style={{
width: '500px',
maxWidth: '90%',
padding: '20px',
}}
>
<div className='modal-header d-flex justify-content-between align-items-center mb-3'>
<h5 className='modal-title m-0'>新建知识库</h5>
<button type='button' className='btn-close' onClick={onClose} aria-label='Close'></button>
</div>
<div className='modal-body'>
<div className='mb-3'>
<label htmlFor='name' className='form-label'>
知识库名称 <span className='text-danger'>*</span>
</label>
<input
type='text'
className={`form-control ${formErrors.name ? 'is-invalid' : ''}`}
id='name'
name='name'
value={formData.name}
onChange={onChange}
/>
{formErrors.name && <div className='invalid-feedback'>{formErrors.name}</div>}
</div>
<div className='mb-3'>
<label htmlFor='desc' className='form-label'>
知识库描述 <span className='text-danger'>*</span>
</label>
<textarea
className={`form-control ${formErrors.desc ? 'is-invalid' : ''}`}
id='desc'
name='desc'
rows='3'
value={formData.desc}
onChange={onChange}
></textarea>
{formErrors.desc && <div className='invalid-feedback'>{formErrors.desc}</div>}
</div>
</div>
<div className='modal-footer'>
<button type='button' className='btn btn-secondary' onClick={onClose}>
取消
</button>
<button type='button' className='btn btn-primary' onClick={onSubmit} disabled={isSubmitting}>
{isSubmitting ? (
<>
<span
className='spinner-border spinner-border-sm me-2'
role='status'
aria-hidden='true'
></span>
处理中...
</>
) : (
'创建'
)}
</button>
</div>
</div>
</div>
);
};
export default CreateKnowledgeBaseModal;

View File

@ -0,0 +1,36 @@
import React from 'react';
import KnowledgeCard from '../KnowledgeCard';
/**
* 知识库列表组件
*/
const KnowledgeBaseList = ({ knowledgeBases, isSearching, onCardClick, onRequestAccess }) => {
if (knowledgeBases.length === 0) {
return (
<div className='alert alert-warning'>
{isSearching ? '没有找到匹配的知识库' : '暂无知识库,请创建新的知识库'}
</div>
);
}
return (
<div className='row gap-3 m-0'>
{knowledgeBases.map((item) => (
<React.Fragment key={item.id}>
<KnowledgeCard
id={item.id}
title={item.name}
description={item.desc}
documents={item.document_count || 0}
date={new Date(item.create_time).toLocaleDateString()}
access={item.permissions.can_edit ? 'full' : item.permissions.can_read ? 'read' : 'none'}
onClick={() => onCardClick(item.id)}
onRequestAccess={onRequestAccess}
/>
</React.Fragment>
))}
</div>
);
};
export default KnowledgeBaseList;

View File

@ -0,0 +1,53 @@
import React from 'react';
/**
* 分页组件
*/
const Pagination = ({ currentPage, totalPages, pageSize, onPageChange, onPageSizeChange }) => {
return (
<div className='d-flex justify-content-between align-items-center mt-4'>
<div>
<select
className='form-select'
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
>
<option value='10'>10/</option>
<option value='20'>20/</option>
<option value='50'>50/</option>
</select>
</div>
<nav aria-label='知识库分页'>
<ul className='pagination mb-0'>
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
<button
className='page-link'
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
>
上一页
</button>
</li>
{[...Array(totalPages).keys()].map((i) => (
<li key={i} className={`page-item ${currentPage === i + 1 ? 'active' : ''}`}>
<button className='page-link' onClick={() => onPageChange(i + 1)}>
{i + 1}
</button>
</li>
))}
<li className={`page-item ${currentPage === totalPages ? 'disabled' : ''}`}>
<button
className='page-link'
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
下一页
</button>
</li>
</ul>
</nav>
</div>
);
};
export default Pagination;

View File

@ -0,0 +1,29 @@
import React from 'react';
import SvgIcon from '../../../components/SvgIcon';
/**
* 知识库搜索栏组件
*/
const SearchBar = ({ searchKeyword, isSearching, onSearchChange, onSearch, onClearSearch }) => {
return (
<form className='d-flex w-50' onSubmit={onSearch}>
<input
type='text'
className='form-control'
placeholder='搜索知识库...'
value={searchKeyword}
onChange={onSearchChange}
/>
{/* <button type='submit' className='btn btn-outline-dark ms-2'>
<SvgIcon className={'search'} />
</button> */}
{isSearching && (
<button type='button' className='btn btn-outline-dark ms-2' onClick={onClearSearch}>
清除
</button>
)}
</form>
);
};
export default SearchBar;

View File

@ -34,7 +34,7 @@ api.interceptors.response.use(
// Handle errors in the response // Handle errors in the response
if (error.response) { if (error.response) {
// monitor /verify // monitor /verify
if (error.response.status === 401 && error.config.url === '/check-token/') { if (error.response.status === 401 && error.config.url === '/auth/verify-token/') {
if (window.location.pathname !== '/login' && window.location.pathname !== '/signup') { if (window.location.pathname !== '/login' && window.location.pathname !== '/signup') {
window.location.href = '/login'; window.location.href = '/login';
} }

View File

@ -10,15 +10,16 @@ export const loginThunk = createAsyncThunk(
'auth/login', 'auth/login',
async ({ username, password }, { rejectWithValue, dispatch }) => { async ({ username, password }, { rejectWithValue, dispatch }) => {
try { try {
const { message, user, token } = await post('/login/', { username, password }); const { message, data } = await post('/auth/login/', { username, password });
if (!user) { if (!data) {
throw new Error(message || 'Something went wrong'); throw new Error(message || 'Something went wrong');
} }
const { token } = data;
// encrypt token // encrypt token
const encryptedToken = CryptoJS.AES.encrypt(token, secretKey).toString(); const encryptedToken = CryptoJS.AES.encrypt(token, secretKey).toString();
sessionStorage.setItem('token', encryptedToken); sessionStorage.setItem('token', encryptedToken);
return user; return data;
} catch (error) { } catch (error) {
const errorMessage = error.response?.data?.message || 'Something went wrong'; const errorMessage = error.response?.data?.message || 'Something went wrong';
dispatch( dispatch(
@ -51,9 +52,9 @@ export const signupThunk = createAsyncThunk('auth/signup', async (config, { reje
} }
}); });
export const checkAuthThunk = createAsyncThunk('auth/check', async (_, { rejectWithValue, dispatch }) => { export const checkAuthThunk = createAsyncThunk('auth/verify', async (_, { rejectWithValue, dispatch }) => {
try { try {
const { user, message } = await get('/check-token/'); const { user, message } = await get('/auth/verify-token/');
if (!user) { if (!user) {
dispatch(logout()); dispatch(logout());
throw new Error(message || 'No token found'); throw new Error(message || 'No token found');
@ -69,7 +70,7 @@ export const checkAuthThunk = createAsyncThunk('auth/check', async (_, { rejectW
export const logoutThunk = createAsyncThunk('auth/logout', async (_, { rejectWithValue, dispatch }) => { export const logoutThunk = createAsyncThunk('auth/logout', async (_, { rejectWithValue, dispatch }) => {
try { try {
// Send the logout request to the server (this assumes your server clears any session-related info) // Send the logout request to the server (this assumes your server clears any session-related info)
await post('/logout/'); await post('/auth/logout/');
dispatch(logout()); dispatch(logout());
} catch (error) { } catch (error) {
const errorMessage = error.response?.data?.message || 'Log out failed'; const errorMessage = error.response?.data?.message || 'Log out failed';

View File

@ -0,0 +1,198 @@
import { createSlice } from '@reduxjs/toolkit';
import {
fetchKnowledgeBases,
searchKnowledgeBases,
createKnowledgeBase,
getKnowledgeBaseById,
updateKnowledgeBase,
deleteKnowledgeBase,
} from './knowledgeBase.thunks';
const initialState = {
// List state
list: {
items: [],
total: 0,
page: 1,
page_size: 10,
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
},
// Search state
search: {
items: [],
total: 0,
page: 1,
page_size: 10,
keyword: '',
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
},
// Current knowledge base details
current: {
data: null,
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
},
// Create/update/delete operations status
operations: {
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
operationType: null, // 'create' | 'update' | 'delete'
},
};
const knowledgeBaseSlice = createSlice({
name: 'knowledgeBase',
initialState,
reducers: {
resetOperationStatus: (state) => {
state.operations = {
status: 'idle',
error: null,
operationType: null,
};
},
resetCurrentKnowledgeBase: (state) => {
state.current = {
data: null,
status: 'idle',
error: null,
};
},
resetSearchState: (state) => {
state.search = {
items: [],
total: 0,
page: 1,
page_size: 10,
keyword: '',
status: 'idle',
error: null,
};
},
},
extraReducers: (builder) => {
builder
// Fetch knowledge bases
.addCase(fetchKnowledgeBases.pending, (state) => {
state.list.status = 'loading';
})
.addCase(fetchKnowledgeBases.fulfilled, (state, action) => {
state.list.status = 'succeeded';
state.list.items = action.payload.items;
state.list.total = action.payload.total;
state.list.page = action.payload.page;
state.list.page_size = action.payload.page_size;
state.list.error = null;
})
.addCase(fetchKnowledgeBases.rejected, (state, action) => {
state.list.status = 'failed';
state.list.error = action.payload;
})
// Search knowledge bases
.addCase(searchKnowledgeBases.pending, (state, action) => {
state.search.status = 'loading';
// Store the keyword for reference
if (action.meta.arg.keyword) {
state.search.keyword = action.meta.arg.keyword;
}
})
.addCase(searchKnowledgeBases.fulfilled, (state, action) => {
state.search.status = 'succeeded';
state.search.items = action.payload.items;
state.search.total = action.payload.total;
state.search.page = action.payload.page;
state.search.page_size = action.payload.page_size;
state.search.error = null;
})
.addCase(searchKnowledgeBases.rejected, (state, action) => {
state.search.status = 'failed';
state.search.error = action.payload;
})
// Get knowledge base by ID
.addCase(getKnowledgeBaseById.pending, (state) => {
state.current.status = 'loading';
})
.addCase(getKnowledgeBaseById.fulfilled, (state, action) => {
state.current.status = 'succeeded';
state.current.data = action.payload;
state.current.error = null;
})
.addCase(getKnowledgeBaseById.rejected, (state, action) => {
state.current.status = 'failed';
state.current.error = action.payload;
})
// Create knowledge base
.addCase(createKnowledgeBase.pending, (state) => {
state.operations.status = 'loading';
state.operations.operationType = 'create';
})
.addCase(createKnowledgeBase.fulfilled, (state, action) => {
state.operations.status = 'succeeded';
// Don't add to list here - better to refetch the list to ensure consistency
state.operations.error = null;
})
.addCase(createKnowledgeBase.rejected, (state, action) => {
state.operations.status = 'failed';
state.operations.error = action.payload;
})
// Update knowledge base
.addCase(updateKnowledgeBase.pending, (state) => {
state.operations.status = 'loading';
state.operations.operationType = 'update';
})
.addCase(updateKnowledgeBase.fulfilled, (state, action) => {
state.operations.status = 'succeeded';
// Update in list if present
const index = state.list.items.findIndex((item) => item.id === action.payload.id);
if (index !== -1) {
state.list.items[index] = action.payload;
}
// Update in search results if present
const searchIndex = state.search.items.findIndex((item) => item.id === action.payload.id);
if (searchIndex !== -1) {
state.search.items[searchIndex] = action.payload;
}
// Update current if it's the same knowledge base
if (state.current.data && state.current.data.id === action.payload.id) {
state.current.data = action.payload;
}
state.operations.error = null;
})
.addCase(updateKnowledgeBase.rejected, (state, action) => {
state.operations.status = 'failed';
state.operations.error = action.payload;
})
// Delete knowledge base
.addCase(deleteKnowledgeBase.pending, (state) => {
state.operations.status = 'loading';
state.operations.operationType = 'delete';
})
.addCase(deleteKnowledgeBase.fulfilled, (state, action) => {
state.operations.status = 'succeeded';
// Remove from list if present
state.list.items = state.list.items.filter((item) => item.id !== action.payload);
// Remove from search results if present
state.search.items = state.search.items.filter((item) => item.id !== action.payload);
// Reset current if it's the same knowledge base
if (state.current.data && state.current.data.id === action.payload) {
state.current.data = null;
}
state.operations.error = null;
})
.addCase(deleteKnowledgeBase.rejected, (state, action) => {
state.operations.status = 'failed';
state.operations.error = action.payload;
});
},
});
export const { resetOperationStatus, resetCurrentKnowledgeBase, resetSearchState } = knowledgeBaseSlice.actions;
const knowledgeBaseReducer = knowledgeBaseSlice.reducer;
export default knowledgeBaseReducer;

View File

@ -0,0 +1,107 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { get, post, put, del } from '../../services/api';
/**
* Fetch knowledge bases with pagination
* @param {Object} params - Pagination parameters
* @param {number} params.page - Page number (default: 1)
* @param {number} params.page_size - Page size (default: 10)
*/
export const fetchKnowledgeBases = createAsyncThunk(
'knowledgeBase/fetchKnowledgeBases',
async ({ page = 1, page_size = 10 } = {}, { rejectWithValue }) => {
try {
const response = await get('/knowledge-bases/', { page, page_size });
return response.data;
} catch (error) {
return rejectWithValue(error.response?.data || 'Failed to fetch knowledge bases');
}
}
);
/**
* Search knowledge bases
* @param {Object} params - Search parameters
* @param {string} params.keyword - Search keyword
* @param {number} params.page - Page number (default: 1)
* @param {number} params.page_size - Page size (default: 10)
*/
export const searchKnowledgeBases = createAsyncThunk(
'knowledgeBase/searchKnowledgeBases',
async ({ keyword, page = 1, page_size = 10 }, { rejectWithValue }) => {
try {
const response = await get('/knowledge-bases/search/', {
keyword,
page,
page_size,
});
return response.data;
} catch (error) {
return rejectWithValue(error.response?.data || 'Failed to search knowledge bases');
}
}
);
/**
* Create a new knowledge base
*/
export const createKnowledgeBase = createAsyncThunk(
'knowledgeBase/createKnowledgeBase',
async (knowledgeBaseData, { rejectWithValue }) => {
try {
const response = await post('/knowledge-bases/', knowledgeBaseData);
return response.data;
} catch (error) {
return rejectWithValue(error.response?.data || 'Failed to create knowledge base');
}
}
);
/**
* Get knowledge base details by ID
*/
export const getKnowledgeBaseById = createAsyncThunk(
'knowledgeBase/getKnowledgeBaseById',
async (id, { rejectWithValue }) => {
try {
const response = await get(`/knowledge-bases/${id}/`);
return response.data;
} catch (error) {
return rejectWithValue(error.response?.data || 'Failed to get knowledge base details');
}
}
);
/**
* Update knowledge base
* @param {Object} params - Update parameters
* @param {string} params.id - Knowledge base ID
* @param {Object} params.data - Update data (name, desc)
*/
export const updateKnowledgeBase = createAsyncThunk(
'knowledgeBase/updateKnowledgeBase',
async ({ id, data }, { rejectWithValue }) => {
try {
const response = await put(`/knowledge-bases/${id}/`, data);
return response.data;
} catch (error) {
return rejectWithValue(error.response?.data || 'Failed to update knowledge base');
}
}
);
/**
* Delete knowledge base
* @param {string} id - Knowledge base ID
*/
export const deleteKnowledgeBase = createAsyncThunk(
'knowledgeBase/deleteKnowledgeBase',
async (id, { rejectWithValue }) => {
try {
await del(`/knowledge-bases/${id}/`);
return id;
} catch (error) {
return rejectWithValue(error.response?.data || 'Failed to delete knowledge base');
}
}
);

View File

@ -3,10 +3,12 @@ import { persistReducer, persistStore } from 'redux-persist';
import sessionStorage from 'redux-persist/lib/storage/session'; import sessionStorage from 'redux-persist/lib/storage/session';
import notificationReducer from './notification.slice.js'; import notificationReducer from './notification.slice.js';
import authReducer from './auth/auth.slice.js'; import authReducer from './auth/auth.slice.js';
import knowledgeBaseReducer from './knowledgeBase/knowledgeBase.slice.js';
const rootRducer = combineReducers({ const rootRducer = combineReducers({
auth: authReducer, auth: authReducer,
notification: notificationReducer, notification: notificationReducer,
knowledgeBase: knowledgeBaseReducer,
}); });
const persistConfig = { const persistConfig = {