[dev]knowledgebase detail page

This commit is contained in:
susie-laptop 2025-03-03 18:22:05 -05:00
parent d9e0b78bee
commit ee5d1bcaa8
19 changed files with 872 additions and 118 deletions

1
.gitignore vendored
View File

@ -22,3 +22,4 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.env*

6
package-lock.json generated
View File

@ -12,6 +12,7 @@
"@reduxjs/toolkit": "^2.6.0",
"axios": "^1.8.1",
"bootstrap": "^5.3.3",
"crypto-js": "^4.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-redux": "^9.2.0",
@ -1846,6 +1847,11 @@
"node": ">= 8"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",

View File

@ -14,6 +14,7 @@
"@reduxjs/toolkit": "^2.6.0",
"axios": "^1.8.1",
"bootstrap": "^5.3.3",
"crypto-js": "^4.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-redux": "^9.2.0",

View File

@ -12,14 +12,15 @@ function App() {
const { user } = useSelector((state) => state.auth);
useEffect(() => {
dispatch(login({id:111, username: 'test'}))
// handleCheckAuth();
handleCheckAuth();
}, [dispatch]);
const handleCheckAuth = async () => {
console.log('app handleCheckAuth');
await dispatch(checkAuthThunk()).unwrap();
if (user) navigate('/');
try {
await dispatch(checkAuthThunk()).unwrap();
if (user) navigate('/');
} catch (error) {}
};
return <AppRouter></AppRouter>;

View File

@ -14,6 +14,7 @@ export const icons = {
p-id='2707'
width='16'
height='16'
fill='currentColor'
>
<path
d='M896 298.666667H128c-25.6 0-42.666667-17.066667-42.666667-42.666667s17.066667-42.666667 42.666667-42.666667h768c25.6 0 42.666667 17.066667 42.666667 42.666667s-17.066667 42.666667-42.666667 42.666667z'
@ -60,4 +61,21 @@ export const icons = {
lock: `<svg xmlns='http://www.w3.org/2000/svg' height='14' width='12.25' viewBox='0 0 448 512' fill='currentColor'>
<path d='M144 144l0 48 160 0 0-48c0-44.2-35.8-80-80-80s-80 35.8-80 80zM80 192l0-48C80 64.5 144.5 0 224 0s144 64.5 144 144l0 48 16 0c35.3 0 64 28.7 64 64l0 192c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 256c0-35.3 28.7-64 64-64l16 0z' />
</svg>`,
'stack-fill': `<svg
t='1741043402869'
class='icon'
viewBox='0 0 1024 1024'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
p-id='12976'
width='20'
height='20'
fill='currentColor'
>
<path
d='M915.456 359.872l-403.456-276.224-403.456 276.224 403.456 276.192 403.456-276.192zM512 697.696l-356.128-234.816-47.328 49.12 403.456 276.192 403.456-276.192-47.968-50.176-355.488 235.872zM512 849.888l-356.128-234.816-47.328 49.12 403.456 276.192 403.456-276.192-47.968-50.176-355.488 235.872z'
p-id='12977'
></path>
</svg>`,
edit: `<svg t="1741043785681" class="icon" viewBox="0 0 1061 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="14021" width="16" height="16" fill='currentColor'><path d="M877.714 475.429v402.286c0 40.396-32.747 73.143-73.143 73.143H146.285c-40.396 0-73.143-32.747-73.143-73.143V219.429c0-40.396 32.747-73.143 73.143-73.143h438.857V73.143H146.285C65.494 73.143-0.001 138.637-0.001 219.429v658.286c0 80.791 65.494 146.286 146.286 146.286h658.286c80.791 0 146.286-65.494 146.286-146.286V475.429h-73.143z" p-id="14022"></path><path d="M397.897 774.217c-5.145 0.812-11.079 1.275-17.121 1.275-27.052 0-51.934-9.295-71.624-24.866-24.26-24.318-23.529-59.427-22.798-117.209 2.851-45.25 21.396-85.691 50.197-116.398L830.903 22.674c40.96-40.96 100.206-20.48 138.24 16.091 10.971 10.971 40.594 40.96 51.566 51.566 36.571 36.571 58.88 96.914 17.189 138.971L543.087 724.113c-30.205 29.593-71.086 48.391-116.341 50.093l-28.848 0.01z m-36.571-75.337c13.39 1.737 28.876 2.729 44.595 2.729 6.955 0 13.864-0.194 20.723-0.577 24.676-1.644 47.559-12.193 64.931-28.534l495.854-494.76c0.004-0.236 0.007-0.514 0.007-0.793 0-14.36-6.517-27.198-16.754-35.717-11.047-10.667-41.401-41.021-52.007-51.992-8.83-10.109-21.744-16.459-36.141-16.459l-0.454 0.002-494.423 494.446a115.687 115.687 0 0 0-28.495 66.486c-0.399 6.509-0.609 13.605-0.609 20.75 0 15.659 1.007 31.082 2.961 46.209z" p-id="14023"></path></svg>`,
};

View File

@ -1,21 +1,26 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import { logoutThunk } from '../store/auth/auth.thunk';
export default function HeaderWithNav() {
const dispatch = useDispatch();
const navigate = useNavigate();
const { user } = useSelector((state) => state.auth);
const handleLogout = () => {
dispatch(logoutThunk());
const handleLogout = async () => {
try {
await dispatch(logoutThunk()).unwrap();
sessionStorage.removeItem('token');
navigate('/login');
} catch (error) {}
};
return (
<header className=' navbar navbar-expand-lg'>
<nav className='navbar navbar-expand-lg border-bottom p-3 mb-3 w-100'>
<div className='container-fluid'>
<Link className='navbar-brand' to='#'>
<Link className='navbar-brand' to='/'>
OOIN 智能知识库
</Link>
<button
@ -32,7 +37,7 @@ export default function HeaderWithNav() {
<div className='collapse navbar-collapse' id='navbarText'>
<ul className='navbar-nav me-auto mb-lg-0'>
<li className='nav-item'>
<Link className='nav-link active' aria-current='page' to='#'>
<Link className='nav-link active' aria-current='page' to='/'>
知识库
</Link>
</li>
@ -50,14 +55,7 @@ export default function HeaderWithNav() {
data-bs-toggle='dropdown'
aria-expanded='false'
>
{/* <img
src='https://github.com/mdo.png'
alt='mdo'
width='32'
height='32'
className='rounded-circle'
/> */}
Hi, { user.username }
Hi, {user.username}
</a>
<ul
className='dropdown-menu text-small shadow'
@ -68,16 +66,16 @@ export default function HeaderWithNav() {
transform: 'translate(0px, 34px)',
}}
>
<li>
{/* <li>
<Link className='dropdown-item' to='#'>
Settings
</Link>
</li>
<li>
</li> */}
{/* <li>
<Link className='dropdown-item' to='#'>
Profile
</Link>
</li>
</li> */}
<li>
<hr className='dropdown-divider' />
</li>

View File

@ -0,0 +1,246 @@
import React, { useState, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import SvgIcon from '../../../components/SvgIcon';
export default function DatasetTab({ knowledgeBase }) {
const [searchQuery, setSearchQuery] = useState('');
const [selectedDocuments, setSelectedDocuments] = useState([]);
const [selectAll, setSelectAll] = useState(false);
const [showBatchDropdown, setShowBatchDropdown] = useState(false);
const dropdownRef = useRef(null);
// Handle click outside dropdown
useEffect(() => {
function handleClickOutside(event) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setShowBatchDropdown(false);
}
}
// Add event listener
document.addEventListener('mousedown', handleClickOutside);
return () => {
// Remove event listener on cleanup
document.removeEventListener('mousedown', handleClickOutside);
};
}, [dropdownRef]);
// Mock data for documents in this knowledge base
const documents = [
{
id: '1001',
name: '测试数据集 001',
description: '产品相关的所有文档和说明',
size: '124kb',
updatedAt: '2023-05-15',
},
{
id: '1002',
name: '产品分析数据',
description: '技术架构和API文档',
size: '89kb',
updatedAt: '2023-05-10',
},
];
// Handle select all checkbox
const handleSelectAll = () => {
if (selectAll) {
setSelectedDocuments([]);
} else {
setSelectedDocuments(documents.map((doc) => doc.id));
}
setSelectAll(!selectAll);
};
// Handle individual document selection
const handleSelectDocument = (docId) => {
if (selectedDocuments.includes(docId)) {
setSelectedDocuments(selectedDocuments.filter((id) => id !== docId));
setSelectAll(false);
} else {
setSelectedDocuments([...selectedDocuments, docId]);
if (selectedDocuments.length + 1 === documents.length) {
setSelectAll(true);
}
}
};
// Handle batch delete
const handleBatchDelete = () => {
if (selectedDocuments.length === 0) return;
// Here you would typically call an API to delete the selected documents
console.log('Deleting documents:', selectedDocuments);
// Reset selection
setSelectedDocuments([]);
setSelectAll(false);
setShowBatchDropdown(false);
};
return (
<>
{/* Breadcrumb navigation */}
<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='/'>
知识库
</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 */}
<div className='d-flex justify-content-between align-items-center mb-3'>
<div className='d-flex align-items-center'>
<div className='dropdown me-2 position-relative' ref={dropdownRef}>
<button
className={`btn ${
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' />
批量操作 {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 me-2' />
删除
</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' />
</span>
</div>
</div>
<button className='btn btn-dark d-flex align-items-center gap-1'>
<SvgIcon className='plus' />
新增文件
</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-2'>
<button className='btn btn-sm btn-outline-primary' title='查看'>
<SvgIcon className='eye' />
</button>
<button className='btn btn-sm btn-outline-success' title='编辑'>
<SvgIcon className='edit' />
</button>
<button className='btn btn-sm btn-outline-danger' title='删除'>
<SvgIcon className='trash' />
</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-sm 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-sm 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>
</>
);
}

View File

@ -0,0 +1,189 @@
import React from 'react';
import { Link } from 'react-router-dom';
import SvgIcon from '../../../components/SvgIcon';
// Custom styled Link component with hover effect
const StyledLink = ({ to, children }) => {
return (
<Link
to={to}
className='text-secondary text-decoration-none'
style={{ transition: 'all 0.2s ease' }}
onMouseOver={(e) => (e.currentTarget.style.color = '#212529')}
onMouseOut={(e) => (e.currentTarget.style.color = '')}
>
{children}
</Link>
);
};
export default function SettingsTab({ knowledgeBase }) {
// Handle form submission
const handleSubmit = (e) => {
e.preventDefault();
// Here you would typically call an API to update the knowledge base settings
console.log('Updating knowledge base settings');
};
// Handle knowledge base deletion
const handleDelete = () => {
// Here you would typically call an API to delete the knowledge base
console.log('Deleting knowledge base:', knowledgeBase.id);
};
return (
<>
{/* Breadcrumb navigation */}
<div className='d-flex align-items-center mb-4 mt-3'>
<nav aria-label='breadcrumb'>
<ol className='breadcrumb mb-0'>
<li className='breadcrumb-item'>
<StyledLink to='/'>知识库</StyledLink>
</li>
<li className='breadcrumb-item'>
<StyledLink to={`/knowledge-base/${knowledgeBase.id}`}>{knowledgeBase.title}</StyledLink>
</li>
<li className='breadcrumb-item active text-secondary' aria-current='page'>
设置
</li>
</ol>
</nav>
</div>
{/* Settings form */}
<div className='card border-0 shadow-sm'>
<div className='card-body'>
<h5 className='card-title mb-4'>知识库设置</h5>
<form onSubmit={handleSubmit}>
<div className='mb-3'>
<label htmlFor='knowledgeTitle' className='form-label'>
知识库名称
</label>
<input
type='text'
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>
<tr>
<td>#1001</td>
<td>测试数据集 001</td>
<td>zhang@abc.com</td>
<td>只读</td>
<td>一个月</td>
<td>
<div className='d-flex gap-2'>
<button
className='btn btn-sm btn-outline-secondary'
title='编辑'
>
<SvgIcon className='edit' />
</button>
<button className='btn btn-sm btn-outline-danger' title='删除'>
<SvgIcon className='trash' />
</button>
</div>
</td>
</tr>
<tr>
<td>#1002</td>
<td>产品分析数据</td>
<td>li@abc.com</td>
<td>完全访问</td>
<td>永久</td>
<td>
<div className='d-flex gap-2'>
<button
className='btn btn-sm btn-outline-secondary'
title='编辑'
>
<SvgIcon className='edit' />
</button>
<button className='btn btn-sm btn-outline-danger' title='删除'>
<SvgIcon className='trash' />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<button type='button' className='btn btn-outline-primary mt-2'>
<SvgIcon className='plus me-1' />
添加用户
</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-primary'>
保存设置
</button>
</div>
</form>
</div>
</div>
</>
);
}

View File

@ -1,4 +1,5 @@
import React from 'react';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import KnowledgeCard from './KnowledgeCard';
import { useDispatch } from 'react-redux';
import { showNotification } from '../../store/notification.slice';
@ -6,9 +7,16 @@ import SvgIcon from '../../components/SvgIcon';
export default function KnowledgeBase() {
const dispatch = useDispatch();
const navigate = useNavigate();
const [showCreateModal, setShowCreateModal] = useState(false);
const [newKnowledgeBase, setNewKnowledgeBase] = useState({
title: '',
description: '',
});
const knowledgeList = [
{
id: '1',
title: '产品开发知识库',
description: '产品开发流程及规范说明文档',
documents: 24,
@ -16,30 +24,168 @@ export default function KnowledgeBase() {
access: 'full',
},
{
id: '2',
title: '市场分析知识库',
description: '2025年Q1市场分析总结',
documents: 12,
date: '2025-02-10',
access: 'read',
},
{ title: '财务知识库', description: '月度财务分析报告', documents: 8, date: '2025-02-01', access: 'none' },
{
id: '3',
title: '财务知识库',
description: '月度财务分析报告',
documents: 8,
date: '2025-02-01',
access: 'none',
},
];
const handleInputChange = (e) => {
const { name, value } = e.target;
setNewKnowledgeBase((prev) => ({
...prev,
[name]: value,
}));
};
const handleCreateKnowledgeBase = () => {
// Here you would typically call an API to create the knowledge base
if (!newKnowledgeBase.title.trim()) {
dispatch(
showNotification({
message: '请输入知识库名称',
type: 'error',
})
);
return;
}
// For now, just show a success notification
dispatch(
showNotification({
message: '知识库创建成功',
type: 'success',
})
);
// 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
setNewKnowledgeBase({ title: '', description: '' });
setShowCreateModal(false);
// Navigate to the newly created knowledge base with datasets tab
navigate(`/knowledge-base/${newId}/datasets`);
};
const handleCardClick = (id) => {
navigate(`/knowledge-base/${id}/datasets`);
};
return (
<div className='container'>
<div className='d-flex justify-content-between align-items-center mb-3'>
<input type='text' className='form-control w-50' placeholder='搜索知识库...' />
<button className='btn btn-dark d-flex align-items-center gap-1'>
<button
className='btn btn-dark d-flex align-items-center gap-1'
onClick={() => setShowCreateModal(true)}
>
<SvgIcon className={'plus'} />
新建知识库
</button>
</div>
<div className='row g-3'>
{knowledgeList.map((item, index) => (
<KnowledgeCard key={index} {...item} />
<div className='row gap-3 m-0'>
{knowledgeList.map((item) => (
<React.Fragment key={item.id}>
<KnowledgeCard {...item} onClick={() => handleCardClick(item.id)} />
</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'>
知识库名称
</label>
<input
type='text'
className='form-control'
id='knowledgeTitle'
name='title'
value={newKnowledgeBase.title}
onChange={handleInputChange}
placeholder='请输入知识库名称'
required
/>
</div>
<div className='mb-3'>
<label htmlFor='knowledgeDescription' className='form-label'>
知识库描述
</label>
<textarea
className='form-control'
id='knowledgeDescription'
name='description'
value={newKnowledgeBase.description}
onChange={handleInputChange}
placeholder='请输入知识库描述'
rows='3'
></textarea>
</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>
);
}

View File

@ -0,0 +1,89 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import SvgIcon from '../../components/SvgIcon';
import DatasetTab from './Detail/DatasetTab';
import SettingsTab from './Detail/SettingsTab';
export default function KnowledgeBaseDetail() {
const { id, tab } = useParams();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState(tab === 'settings' ? 'settings' : 'datasets');
// Update active tab when URL changes
useEffect(() => {
if (tab) {
setActiveTab(tab === 'settings' ? 'settings' : 'datasets');
}
}, [tab]);
// Mock data for the knowledge base details
const knowledgeBase = {
id: id,
title: '知识库 1',
description: '知识库详细信息',
createdAt: '2023-05-01',
updatedAt: '2023-05-15',
documentsCount: 24,
};
// Handle tab change
const handleTabChange = (tab) => {
setActiveTab(tab);
navigate(`/knowledge-base/${id}/${tab}`);
};
return (
<div className='container-fluid px-4'>
<div className='row'>
{/* Sidebar */}
<div className='col-md-3 col-lg-2 border-end'>
<div className='py-4'>
<div className='h4 mb-3 text-center'>{knowledgeBase.title}</div>
<p className='text-center text-muted small mb-4'>{knowledgeBase.description}</p>
<hr />
<nav className='nav flex-column'>
<a
className={`nav-link link-dark link-underline-light d-flex align-items-center ${
activeTab === 'datasets' ? 'active bg-light rounded fw-bold' : ''
}`}
href='#'
onClick={(e) => {
e.preventDefault();
handleTabChange('datasets');
}}
>
<SvgIcon className='database me-2' />
数据集
</a>
<a
className={`nav-link link-dark link-underline-light d-flex align-items-center ${
activeTab === 'settings' ? 'active bg-light rounded fw-bold' : ''
}`}
href='#'
onClick={(e) => {
e.preventDefault();
handleTabChange('settings');
}}
>
<SvgIcon className='gear me-2' />
设置
</a>
</nav>
</div>
</div>
{/* Main content */}
<div className='col-md-9 col-lg-10'>
{/* Render the appropriate tab component */}
{activeTab === 'datasets' ? (
<DatasetTab knowledgeBase={knowledgeBase} />
) : (
<SettingsTab knowledgeBase={knowledgeBase} />
)}
</div>
</div>
</div>
);
}

View File

@ -1,61 +1,59 @@
import React from 'react';
import SvgIcon from '../../components/SvgIcon';
export default function KnowledgeCard({ title, description, documents, date, access }) {
export default function KnowledgeCard({ id, title, description, documents, date, access, onClick }) {
return (
<div className='col-sm-6 col-md-6 col-lg-4 col-xl-3'>
<div className='knowledge-card card shadow border-0'>
<div className='card-body'>
<h5 className='card-title'>{title}</h5>
<div className='hoverdown position-absolute end-0 top-0'>
<button type='button' className='detail-btn btn'>
<SvgIcon className={'more-dot'} />
</button>
<ul className='hoverdown-menu shadow bg-white p-1 rounded'>
<li className='p-1 hoverdown-item px-2'>
删除
<SvgIcon className={'trash'} />
</li>
</ul>
</div>
<p className='card-text text-muted'>{description}</p>
<div className='text-muted d-flex align-items-center gap-1'>
<SvgIcon className={'file'} />
{documents} 文档
<span className='ms-3 d-flex align-items-center gap-1'>
<SvgIcon className={'clock'} />
{date}
<div className='knowledge-card card shadow border-0 p-0 col' onClick={onClick}>
<div className='card-body'>
<h5 className='card-title'>{title}</h5>
<div className='hoverdown position-absolute end-0 top-0'>
<button type='button' className='detail-btn btn'>
<SvgIcon className={'more-dot'} />
</button>
<ul className='hoverdown-menu shadow bg-white p-1 rounded'>
<li className='p-1 hoverdown-item px-2'>
删除
<SvgIcon className={'trash'} />
</li>
</ul>
</div>
<p className='card-text text-muted'>{description}</p>
<div className='text-muted d-flex align-items-center gap-1'>
<SvgIcon className={'file'} />
{documents} 文档
<span className='ms-3 d-flex align-items-center gap-1'>
<SvgIcon className={'clock'} />
{date}
</span>
</div>
<div className='mt-3 d-flex justify-content-between align-items-end'>
{access === 'full' ? (
<span className='badge bg-success-subtle text-success d-flex align-items-center gap-1'>
<SvgIcon className={'circle-yes'} />
完全访问
</span>
</div>
<div className='mt-3 d-flex justify-content-between align-items-end'>
{access === 'full' ? (
<span className='badge bg-success-subtle text-success d-flex align-items-center gap-1'>
<SvgIcon className={'circle-yes'} />
完全访问
</span>
) : access === 'read' ? (
<span className='badge bg-warning-subtle text-warning d-flex align-items-center gap-1'>
<SvgIcon className={'eye'} />
只读访问
</span>
) : (
<span className='badge bg-dark-subtle d-flex align-items-center gap-1'>
<SvgIcon className={'lock'} />
无访问权限
</span>
)}
{access === 'full' || access === 'read' ? (
<button className='btn btn-outline-dark btn-sm d-flex align-items-center gap-1'>
<SvgIcon className={'chat-dot'} />
新聊天
</button>
) : (
<button className='btn btn-outline-dark btn-sm d-flex align-items-center gap-1'>
<SvgIcon className={'key'} />
申请权限
</button>
)}
</div>
) : access === 'read' ? (
<span className='badge bg-warning-subtle text-warning d-flex align-items-center gap-1'>
<SvgIcon className={'eye'} />
只读访问
</span>
) : (
<span className='badge bg-dark-subtle d-flex align-items-center gap-1'>
<SvgIcon className={'lock'} />
无访问权限
</span>
)}
{access === 'full' || access === 'read' ? (
<button className='btn btn-outline-dark btn-sm d-flex align-items-center gap-1'>
<SvgIcon className={'chat-dot'} />
新聊天
</button>
) : (
<button className='btn btn-outline-dark btn-sm d-flex align-items-center gap-1'>
<SvgIcon className={'key'} />
申请权限
</button>
)}
</div>
</div>
</div>

View File

@ -6,8 +6,8 @@ import { checkAuthThunk, loginThunk } from '../../store/auth/auth.thunk';
export default function Login() {
const dispatch = useDispatch();
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [username, setUsername] = useState('member2');
const [password, setPassword] = useState('member123');
const [errors, setErrors] = useState({});
const [submitted, setSubmitted] = useState(false);
@ -18,9 +18,11 @@ export default function Login() {
}, [dispatch]);
const handleCheckAuth = async () => {
console.log('login page handleCheckAuth');
await dispatch(checkAuthThunk()).unwrap();
if (user) navigate('/');
console.log('login page handleCheckAuth');
try {
await dispatch(checkAuthThunk()).unwrap();
if (user) navigate('/');
} catch (error) {}
};
const validateForm = () => {
@ -37,17 +39,20 @@ export default function Login() {
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e) => {
const handleSubmit = async (e) => {
e.preventDefault();
setSubmitted(true);
console.log(validateForm());
if (validateForm()) {
console.log('Form submitted successfully!');
console.log('Username:', username);
console.log('Password:', password);
dispatch(loginThunk({ username, password }));
try {
await dispatch(loginThunk({ username, password })).unwrap();
navigate('/');
} catch (error) {
console.error('Login failed:', error);
}
}
};
return (
@ -60,6 +65,7 @@ export default function Login() {
>
<div className='input-group has-validation'>
<input
value={username}
type='text'
className={`form-control form-control-lg${submitted && errors.username ? ' is-invalid' : ''}`}
id='username'
@ -71,6 +77,7 @@ export default function Login() {
</div>
<div className='input-group has-validation'>
<input
value={password}
type='password'
id='password'
placeholder='Password'

View File

@ -20,8 +20,10 @@ export default function Signup() {
const handleCheckAuth = async () => {
console.log('signup page handleCheckAuth');
await dispatch(checkAuthThunk()).unwrap();
if (user) navigate('/');
try {
await dispatch(checkAuthThunk()).unwrap();
if (user) navigate('/');
} catch (error) {}
};
const validateForm = () => {
@ -45,7 +47,7 @@ export default function Signup() {
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e) => {
const handleSubmit = async (e) => {
e.preventDefault();
setSubmitted(true);
console.log(validateForm());
@ -55,8 +57,12 @@ export default function Signup() {
console.log('Username:', username);
console.log('Email:', email);
console.log('Password:', password);
dispatch(signupThunk({ username, password, email }));
try {
await dispatch(signupThunk({ username, password, email })).unwrap();
navigate('/');
} catch (error) {
console.error('Signup failed:', error);
}
}
};

View File

@ -2,14 +2,15 @@ import React, { Suspense } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import Mainlayout from '../layouts/Mainlayout';
import KnowledgeBase from '../pages/KnowledgeBase/KnowledgeBase';
import KnowledgeBaseDetail from '../pages/KnowledgeBase/KnowledgeBaseDetail';
import Loading from '../components/Loading';
import Login from '../pages/auth/Login';
import Signup from '../pages/auth/Signup';
import Login from '../pages/Auth/Login';
import Signup from '../pages/Auth/Signup';
import ProtectedRoute from './protectedRoute';
import { useSelector } from 'react-redux';
function AppRouter() {
const { id } = useSelector((state) => state.auth);
const { user } = useSelector((state) => state.auth);
return (
<Suspense fallback={<Loading />}>
@ -23,10 +24,26 @@ function AppRouter() {
</Mainlayout>
}
/>
<Route
path='/knowledge-base/:id'
element={
<Mainlayout>
<KnowledgeBaseDetail />
</Mainlayout>
}
/>
<Route
path='/knowledge-base/:id/:tab'
element={
<Mainlayout>
<KnowledgeBaseDetail />
</Mainlayout>
}
/>
</Route>
<Route path='/login' element={<Login />} />
<Route path='/signup' element={<Signup />} />
<Route path='*' element={<Navigate to={!!id ? '/' : '/login'} replace />} />
<Route path='*' element={<Navigate to={!!user ? '/' : '/login'} replace />} />
</Routes>
</Suspense>
);

View File

@ -1,14 +1,22 @@
import axios from 'axios';
import CryptoJS from 'crypto-js';
const secretKey = import.meta.env.VITE_SECRETKEY;
// Create Axios instance with base URL
const api = axios.create({
baseURL: 'api',
baseURL: '/api',
withCredentials: true, // Include cookies if needed
});
// Request Interceptor
api.interceptors.request.use(
(config) => {
const encryptedToken = sessionStorage.getItem('token') || '';
if (encryptedToken) {
const decryptedToken = CryptoJS.AES.decrypt(encryptedToken, secretKey).toString(CryptoJS.enc.Utf8);
config.headers.Authorization = `Token ${decryptedToken}`;
}
return config;
},
(error) => {
@ -26,8 +34,8 @@ api.interceptors.response.use(
// Handle errors in the response
if (error.response) {
// monitor /verify
if (error.response.status === 401 && error.config.url === '/check-token') {
if (window.location.pathname !== '/login' && window.location.pathname !== '/register') {
if (error.response.status === 401 && error.config.url === '/check-token/') {
if (window.location.pathname !== '/login' && window.location.pathname !== '/signup') {
window.location.href = '/login';
}
}
@ -58,7 +66,7 @@ const get = async (url, params = {}) => {
const post = async (url, data, isMultipart = false) => {
const headers = isMultipart
? { 'Content-Type': 'multipart/form-data' } // For file uploads
: { 'Content-Type': 'application/json' }; // For JSON data
: { 'Content-Type': 'application/json' }; // For JSON data
const res = await api.post(url, data, { headers });
return res.data;

View File

@ -20,7 +20,7 @@ const setRejected = (state, action) => {
const authSlice = createSlice({
name: 'auth',
initialState: { loading: false, error: null, user: {id: 123, username: 'test'} },
initialState: { loading: false, error: null, user: null },
reducers: {
login: (state, action) => {
state.user = action.payload;

View File

@ -2,15 +2,22 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
import { get, post } from '../../services/api';
import { showNotification } from '../notification.slice';
import { logout } from './auth.slice';
import CryptoJS from 'crypto-js';
const secretKey = import.meta.env.VITE_SECRETKEY;
export const loginThunk = createAsyncThunk(
'auth/login',
async ({ username, password }, { rejectWithValue, dispatch }) => {
try {
const { message, user } = await post('/login', { username, password });
const { message, user, token } = await post('/login/', { username, password });
if (!user) {
throw new Error(message || 'Something went wrong');
}
// encrypt token
const encryptedToken = CryptoJS.AES.encrypt(token, secretKey).toString();
sessionStorage.setItem('token', encryptedToken);
return user;
} catch (error) {
const errorMessage = error.response?.data?.message || 'Something went wrong';
@ -46,7 +53,7 @@ export const signupThunk = createAsyncThunk('auth/signup', async (config, { reje
export const checkAuthThunk = createAsyncThunk('auth/check', async (_, { rejectWithValue, dispatch }) => {
try {
const { user, message } = await get('/check-token');
const { user, message } = await get('/check-token/');
if (!user) {
dispatch(logout());
throw new Error(message || 'No token found');
@ -62,7 +69,7 @@ export const checkAuthThunk = createAsyncThunk('auth/check', async (_, { rejectW
export const logoutThunk = createAsyncThunk('auth/logout', async (_, { rejectWithValue, dispatch }) => {
try {
// Send the logout request to the server (this assumes your server clears any session-related info)
await post('/logout');
await post('/logout/');
dispatch(logout());
} catch (error) {
const errorMessage = error.response?.data?.message || 'Log out failed';

View File

@ -1,5 +1,8 @@
@import 'bootstrap/scss/bootstrap';
#root {
min-width: 24rem;
}
.dropdown-toggle {
outline: 0;
}
@ -9,6 +12,9 @@
}
.knowledge-card {
min-width: 20rem;
cursor: pointer;
.hoverdown:hover .hoverdown-menu{
display: block;
color: red;

View File

@ -1,13 +1,23 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
build: {
outDir: '../dist'
},
server: {
port: 8080
}
})
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd());
return {
plugins: [react()],
build: {
outDir: '../dist',
},
server: {
port: env.VITE_PORT,
proxy: {
'/api': {
target: env.VITE_API_URL || 'http://124.222.236.141:58000',
changeOrigin: true,
},
},
},
};
});