Initial commit

This commit is contained in:
susie-laptop 2025-04-15 21:51:27 -04:00
commit f4c1d03dc8
79 changed files with 19880 additions and 0 deletions

4
.env Normal file
View File

@ -0,0 +1,4 @@
VITE_PORT = 8080
VITE_PROD = false
VITE_API_URL = "http://121.4.99.91:8008"
VITE_SECRETKEY = "ooin-knowledge-base-key"

4
.env.development Normal file
View File

@ -0,0 +1,4 @@
VITE_PORT = 8080
VITE_PROD = false
VITE_API_URL = "http://121.4.99.91:8008"
VITE_SECRETKEY = "ooin-knowledge-base-key"

4
.env.production Normal file
View File

@ -0,0 +1,4 @@
VITE_PORT = 8080
VITE_PROD = true
VITE_API_URL = "http://121.4.99.91:8008"
VITE_SECRETKEY = "ooin-knowledge-base-key"

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

38
eslint.config.js Normal file
View File

@ -0,0 +1,38 @@
import js from '@eslint/js'
import globals from 'globals'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default [
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
settings: { react: { version: '18.3' } },
plugins: {
react,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
...reactHooks.configs.recommended.rules,
'react/jsx-no-target-blank': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Influencer Knowledge Base</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

6396
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "knowledgebase-influencer",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@popperjs/core": "^2.11.8",
"@reduxjs/toolkit": "^2.6.0",
"axios": "^1.8.1",
"bootstrap": "^5.3.3",
"crypto-js": "^4.2.0",
"lodash": "^4.17.21",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.2.0",
"react-syntax-highlighter": "^15.6.1",
"redux-persist": "^6.0.0",
"remark-gfm": "^4.0.1",
"uuid": "^11.1.0"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.21.0",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"sass": "^1.62.1",
"vite": "^6.2.0"
}
}

61
src/App.jsx Normal file
View File

@ -0,0 +1,61 @@
import { useDispatch, useSelector } from 'react-redux';
import AppRouter from './router/router';
import { checkAuthThunk } from './store/auth/auth.thunk';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { login } from './store/auth/auth.slice';
import { initWebSocket, closeWebSocket } from './services/websocket';
import { setWebSocketConnected } from './store/notificationCenter/notificationCenter.slice';
function App() {
const navigate = useNavigate();
const dispatch = useDispatch();
const { user } = useSelector((state) => state.auth);
const { isConnected } = useSelector((state) => state.notificationCenter);
//
useEffect(() => {
handleCheckAuth();
}, [dispatch]);
// WebSocket
useEffect(() => {
console.log(user, isConnected);
// WebSocket
if (user && !isConnected) {
initWebSocket()
.then(() => {
dispatch(setWebSocketConnected(true));
console.log('WebSocket connection initialized');
})
.catch((error) => {
console.error('Failed to initialize WebSocket connection:', error);
});
}
// WebSocket
return () => {
if (isConnected) {
closeWebSocket();
dispatch(setWebSocketConnected(false));
}
};
}, [user, isConnected, dispatch]);
const handleCheckAuth = async () => {
console.log('app handleCheckAuth');
try {
await dispatch(checkAuthThunk()).unwrap();
if (!user) navigate('/login');
} catch (error) {
console.log('error', error);
navigate('/login');
}
};
return <AppRouter></AppRouter>;
}
export default App;

1
src/assets/react.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" className="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,232 @@
import React, { useState } from 'react';
import SvgIcon from './SvgIcon';
/**
* 申请权限弹窗组件
* @param {Object} props
* @param {boolean} props.show - 是否显示弹窗
* @param {string} props.knowledgeBaseId - 知识库ID
* @param {string} props.knowledgeBaseTitle - 知识库标题
* @param {Function} props.onClose - 关闭弹窗的回调函数
* @param {Function} props.onSubmit - 提交申请的回调函数接收 requestData 参数
* @param {boolean} props.isSubmitting - 是否正在提交
*/
export default function AccessRequestModal({
show,
knowledgeBaseId,
knowledgeBaseTitle,
onClose,
onSubmit,
isSubmitting = false,
}) {
const [accessRequestData, setAccessRequestData] = useState({
permissions: {
can_read: true,
can_edit: false,
can_delete: false,
},
duration: '30', // 30
reason: '',
});
const [accessRequestErrors, setAccessRequestErrors] = useState({});
const handleAccessRequestInputChange = (e) => {
const { name, value } = e.target;
if (name === 'duration') {
setAccessRequestData((prev) => ({
...prev,
[name]: value,
}));
} else if (name === 'reason') {
setAccessRequestData((prev) => ({
...prev,
[name]: value,
}));
}
// Clear error when user types
if (accessRequestErrors[name]) {
setAccessRequestErrors((prev) => ({
...prev,
[name]: '',
}));
}
};
const handlePermissionChange = (permissionType) => {
setAccessRequestData((prev) => ({
...prev,
permissions: {
can_read: true, // true
can_edit: permissionType === '编辑权限',
can_delete: false, //
},
}));
};
const validateAccessRequestForm = () => {
const errors = {};
if (!accessRequestData.reason.trim()) {
errors.reason = '请输入申请原因';
}
setAccessRequestErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSubmitAccessRequest = () => {
// Validate form
if (!validateAccessRequestForm()) {
return;
}
//
const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() + parseInt(accessRequestData.duration));
const expiresAt = expirationDate.toISOString();
//
onSubmit({
knowledge_base: knowledgeBaseId,
permissions: accessRequestData.permissions,
reason: accessRequestData.reason,
expires_at: expiresAt,
});
//
setAccessRequestData({
permissions: {
can_read: true,
can_edit: false,
can_delete: false,
},
duration: '30',
reason: '',
});
setAccessRequestErrors({});
};
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'
disabled={isSubmitting}
></button>
</div>
<div className='modal-body'>
<div className='mb-3'>
<label className='form-label'>知识库名称</label>
<input type='text' className='form-control' value={knowledgeBaseTitle} readOnly />
</div>
<div className='mb-3'>
<label className='form-label d-flex align-items-center gap-1'>
<SvgIcon className='key' />
访问级别 <span className='text-danger'>*</span>
</label>
<select
className='form-select'
value={accessRequestData.permissions.can_edit ? '编辑权限' : '只读访问'}
onChange={(e) => handlePermissionChange(e.target.value)}
>
<option value='只读访问'>只读访问</option>
<option value='编辑权限'>编辑权限</option>
</select>
</div>
<div className='mb-3'>
<label className='form-label d-flex align-items-center gap-1'>
<SvgIcon className='calendar' />
访问时长 <span className='text-danger'>*</span>
</label>
<select
className='form-select'
name='duration'
value={accessRequestData.duration}
onChange={handleAccessRequestInputChange}
>
<option value='7'>7</option>
<option value='15'>15</option>
<option value='30'>30</option>
<option value='60'>60</option>
<option value='90'>90</option>
<option value='180'>180</option>
<option value='365'>365</option>
</select>
</div>
<div className='mb-3'>
<label className='form-label d-flex align-items-center gap-1'>
<SvgIcon className='chat' />
申请原因 <span className='text-danger'>*</span>
</label>
<textarea
className={`form-control ${accessRequestErrors.reason ? 'is-invalid' : ''}`}
id='reason'
name='reason'
rows='3'
value={accessRequestData.reason}
onChange={handleAccessRequestInputChange}
placeholder='请输入申请原因'
></textarea>
{accessRequestErrors.reason && (
<div className='invalid-feedback'>{accessRequestErrors.reason}</div>
)}
</div>
</div>
<div className='modal-footer gap-2'>
<button type='button' className='btn btn-secondary' onClick={onClose} disabled={isSubmitting}>
取消
</button>
<button
type='button'
className='btn btn-dark'
onClick={handleSubmitAccessRequest}
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<span
className='spinner-border spinner-border-sm me-2'
role='status'
aria-hidden='true'
></span>
提交中...
</>
) : (
'提交申请'
)}
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,83 @@
import React, { useState, useEffect } from 'react';
import { switchToMockApi, switchToRealApi, checkServerStatus } from '../services/api';
export default function ApiModeSwitch() {
const [isMockMode, setIsMockMode] = useState(false);
const [isChecking, setIsChecking] = useState(false);
const [showNotification, setShowNotification] = useState(false);
const [notification, setNotification] = useState({ message: '', type: 'info' });
//
useEffect(() => {
const checkStatus = async () => {
setIsChecking(true);
const isServerUp = await checkServerStatus();
setIsMockMode(!isServerUp);
setIsChecking(false);
};
checkStatus();
}, []);
// API
const handleToggleMode = async () => {
setIsChecking(true);
if (isMockMode) {
// API
const isServerUp = await switchToRealApi();
if (isServerUp) {
setIsMockMode(false);
showNotificationMessage('已切换到真实API模式', 'success');
} else {
showNotificationMessage('服务器连接失败,继续使用模拟数据', 'warning');
}
} else {
// API
switchToMockApi();
setIsMockMode(true);
showNotificationMessage('已切换到模拟API模式', 'info');
}
setIsChecking(false);
};
//
const showNotificationMessage = (message, type) => {
setNotification({ message, type });
setShowNotification(true);
// 3
setTimeout(() => {
setShowNotification(false);
}, 3000);
};
return (
<div className='api-mode-switch'>
<div className='d-flex align-items-center'>
<div className='form-check form-switch me-2'>
<input
className='form-check-input'
type='checkbox'
id='apiModeToggle'
checked={isMockMode}
onChange={handleToggleMode}
disabled={isChecking}
/>
<label className='form-check-label' htmlFor='apiModeToggle'>
{isChecking ? '检查中...' : isMockMode ? '模拟API模式' : '真实API模式'}
</label>
</div>
{isMockMode && <span className='badge bg-warning text-dark'>使用本地模拟数据</span>}
{!isMockMode && <span className='badge bg-success'>已连接到后端服务器</span>}
</div>
{showNotification && (
<div className={`alert alert-${notification.type} mt-2 py-2 px-3`} style={{ fontSize: '0.85rem' }}>
{notification.message}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,41 @@
import React, { useState } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { atomDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import SvgIcon from './SvgIcon';
/**
* CodeBlock component renders a syntax highlighted code block with a copy button
*/
const CodeBlock = ({ language, value }) => {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(value).then(() => {
setCopied(true);
// Reset the copied state after 2 seconds
setTimeout(() => setCopied(false), 2000);
});
};
return (
<div className='code-block-container'>
<div className='code-block-header'>
{language && <span className='code-language-badge'>{language}</span>}
<button onClick={handleCopy} className='copy-button' title={copied ? 'Copied!' : 'Copy code'}>
{copied ? (
<span className='copied-indicator'> Copied!</span>
) : (
<span className='copy-icon'>
<SvgIcon className='copy' width='16' height='16' />
</span>
)}
</button>
</div>
<SyntaxHighlighter style={atomDark} language={language || 'text'} PreTag='div' wrapLongLines={true}>
{value}
</SyntaxHighlighter>
</div>
);
};
export default CodeBlock;

View File

@ -0,0 +1,287 @@
import React, { useState, useEffect } from 'react';
import SvgIcon from './SvgIcon';
//
const departmentGroups = {
达人部门: ['达人'],
商务部门: ['商务'],
样本中心: ['样本'],
产品部门: ['产品'],
AI自媒体: ['AI自媒体'],
HR: ['HR'],
技术部门: ['技术'],
};
//
const departments = Object.keys(departmentGroups);
/**
* 创建知识库模态框组件
* @param {Object} props
* @param {boolean} props.show - 是否显示弹窗
* @param {Object} props.formData - 表单数据
* @param {Object} props.formErrors - 表单错误信息
* @param {boolean} props.isSubmitting - 是否正在提交
* @param {Function} props.onClose - 关闭弹窗的回调函数
* @param {Function} props.onChange - 表单输入变化的回调函数
* @param {Function} props.onSubmit - 提交表单的回调函数
* @param {Object} props.currentUser - 当前用户信息
*/
const CreateKnowledgeBaseModal = ({
show,
formData,
formErrors,
isSubmitting,
onClose,
onChange,
onSubmit,
currentUser,
}) => {
//
const isAdmin = currentUser?.role === 'admin';
const isLeader = currentUser?.role === 'leader';
//
const userDepartment = currentUser?.department || '';
//
const [availableGroups, setAvailableGroups] = useState([]);
//
useEffect(() => {
if (formData.department && departmentGroups[formData.department]) {
setAvailableGroups(departmentGroups[formData.department]);
} else {
setAvailableGroups([]);
}
}, [formData.department]);
// Hooks
if (!show) return null;
//
const getAvailableTypes = () => {
if (isAdmin) {
return [
{ value: 'admin', label: '公共知识库' },
{ value: 'leader', label: '组长级知识库' },
{ value: 'member', label: '组内知识库' },
{ value: 'private', label: '私有知识库' },
{ value: 'secret', label: '私密知识库' },
];
} else if (isLeader) {
return [
{ value: 'admin', label: '公共知识库' },
{ value: 'member', label: '组内知识库' },
{ value: 'private', label: '私有知识库' },
];
} else {
return [
{ value: 'admin', label: '公共知识库' },
{ value: 'private', label: '私有知识库' },
];
}
};
const availableTypes = getAvailableTypes();
//
const needDepartmentAndGroup = formData.type === 'member' || formData.type === 'leader';
const needSelectGroup = needDepartmentAndGroup;
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'
disabled={isSubmitting}
></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 className='mb-3'>
<label className='form-label'>
知识库类型 <span className='text-danger'>*</span>
</label>
<div className='d-flex flex-wrap gap-3'>
{availableTypes.map((type, index) => (
<div className='form-check' key={index}>
<input
className='form-check-input'
type='radio'
name='type'
id={`type${type.value}`}
value={type.value}
checked={formData.type === type.value}
onChange={onChange}
/>
<label className='form-check-label' htmlFor={`type${type.value}`}>
{type.label}
</label>
</div>
))}
</div>
{!isAdmin && !isLeader && (
<small className='text-muted d-block mt-1'>
您可以创建公共知识库所有人可访问或私有知识库仅自己可访问
</small>
)}
{formErrors.type && <div className='text-danger small mt-1'>{formErrors.type}</div>}
</div>
{/* 仅当不是私有知识库且需要部门和组别时才显示部门选项 */}
{needDepartmentAndGroup && (
<div className='mb-3'>
<label htmlFor='department' className='form-label'>
部门 {isAdmin && needSelectGroup && <span className='text-danger'>*</span>}
</label>
{isAdmin ? (
//
<select
className={`form-select ${formErrors.department ? 'is-invalid' : ''}`}
id='department'
name='department'
value={formData.department || ''}
onChange={onChange}
disabled={isSubmitting}
>
<option value=''>请选择部门</option>
{departments.map((dept, index) => (
<option key={index} value={dept}>
{dept}
</option>
))}
</select>
) : (
//
<input
type='text'
className='form-control bg-light'
id='department'
name='department'
value={formData.department || ''}
readOnly
/>
)}
{formErrors.department && (
<div className='text-danger small mt-1'>{formErrors.department}</div>
)}
</div>
)}
{/* 仅当不是私有知识库且需要部门和组别时才显示组别选项 */}
{needDepartmentAndGroup && (
<div className='mb-3'>
<label htmlFor='group' className='form-label'>
组别 {needSelectGroup && <span className='text-danger'>*</span>}
</label>
{isAdmin || (isLeader && needSelectGroup) ? (
//
<select
className={`form-select ${formErrors.group ? 'is-invalid' : ''}`}
id='group'
name='group'
value={formData.group || ''}
onChange={onChange}
disabled={isSubmitting || (isAdmin && !formData.department)}
>
<option value=''>{formData.department ? '请选择组别' : '请先选择部门'}</option>
{availableGroups.map((group, index) => (
<option key={index} value={group}>
{group}
</option>
))}
</select>
) : (
//
<input
type='text'
className='form-control bg-light'
id='group'
name='group'
value={formData.group || ''}
readOnly
/>
)}
{formErrors.group && <div className='text-danger small mt-1'>{formErrors.group}</div>}
</div>
)}
</div>
<div className='modal-footer gap-2'>
<button type='button' className='btn btn-secondary' onClick={onClose} disabled={isSubmitting}>
取消
</button>
<button type='button' className='btn btn-dark' 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,47 @@
import React, { Component } from 'react';
/**
* Error Boundary component to catch errors in child components
* and display a fallback UI instead of crashing the whole app
*/
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
// Update state so the next render will show the fallback UI
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// You can log the error to an error reporting service
console.error('Error caught by ErrorBoundary:', error);
console.error('Component stack:', errorInfo.componentStack);
}
render() {
const { children, fallback } = this.props;
if (this.state.hasError) {
// You can render any custom fallback UI
if (typeof fallback === 'function') {
return fallback(this.state.error);
}
return (
fallback || (
<div className='p-3 border rounded bg-light'>
<p className='text-danger mb-1'>Error rendering content</p>
<small className='text-muted'>The content couldn't be displayed properly.</small>
</div>
)
);
}
return children;
}
}
export default ErrorBoundary;

View File

@ -0,0 +1,11 @@
import React from 'react';
const Loading = () => {
return (
<div className='spinner-border' role='status'>
<span className='visually-hidden'>Loading...</span>
</div>
);
};
export default Loading;

View File

@ -0,0 +1,310 @@
import React, { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
clearNotifications,
markAllNotificationsAsRead,
markNotificationAsRead,
setWebSocketConnected,
} from '../store/notificationCenter/notificationCenter.slice';
import RequestDetailSlideOver from '../pages/Permissions/components/RequestDetailSlideOver';
import { approvePermissionThunk, rejectPermissionThunk } from '../store/permissions/permissions.thunks';
import { showNotification } from '../store/notification.slice';
import { initWebSocket, acknowledgeNotification, closeWebSocket } from '../services/websocket';
export default function NotificationCenter({ show, onClose }) {
const [showAll, setShowAll] = useState(false);
const dispatch = useDispatch();
const { notifications, unreadCount, isConnected } = useSelector((state) => state.notificationCenter);
const [selectedRequest, setSelectedRequest] = useState(null);
const [showSlideOver, setShowSlideOver] = useState(false);
const [showResponseInput, setShowResponseInput] = useState(false);
const [currentRequestId, setCurrentRequestId] = useState(null);
const [isApproving, setIsApproving] = useState(false);
const [responseMessage, setResponseMessage] = useState('');
const { isAuthenticated } = useSelector((state) => state.auth);
const displayedNotifications = showAll ? notifications : notifications.slice(0, 5);
// WebSocket
useEffect(() => {
// WebSocket
if (isAuthenticated && !isConnected) {
initWebSocket()
.then(() => {
dispatch(setWebSocketConnected(true));
console.log('Successfully connected to notification WebSocket');
})
.catch((error) => {
console.error('Failed to connect to notification WebSocket:', error);
dispatch(setWebSocketConnected(false));
//
dispatch(
showNotification({
message: '通知服务连接失败,部分功能可能不可用',
type: 'warning',
})
);
});
}
// WebSocket
return () => {
if (isConnected) {
closeWebSocket();
dispatch(setWebSocketConnected(false));
}
};
}, [isAuthenticated, isConnected, dispatch]);
const handleClearAll = () => {
dispatch(clearNotifications());
};
const handleMarkAllAsRead = () => {
dispatch(markAllNotificationsAsRead());
};
const handleMarkAsRead = (notificationId) => {
dispatch(markNotificationAsRead(notificationId));
//
acknowledgeNotification(notificationId);
};
const handleViewDetail = (notification) => {
//
if (!notification.isRead) {
handleMarkAsRead(notification.id);
}
if (notification.type === 'permission') {
setSelectedRequest(notification);
setShowSlideOver(true);
}
};
const handleCloseSlideOver = () => {
setShowSlideOver(false);
setTimeout(() => {
setSelectedRequest(null);
}, 300);
};
const handleOpenResponseInput = (requestId, approving) => {
setCurrentRequestId(requestId);
setIsApproving(approving);
setShowResponseInput(true);
};
const handleCloseResponseInput = () => {
setShowResponseInput(false);
setCurrentRequestId(null);
setResponseMessage('');
};
const handleProcessRequest = () => {
if (!currentRequestId) return;
const params = {
id: currentRequestId,
responseMessage,
};
if (isApproving) {
dispatch(approvePermissionThunk(params));
} else {
dispatch(rejectPermissionThunk(params));
}
};
if (!show) return null;
return (
<>
<div
className='notification-center card shadow-lg'
style={{
position: 'fixed',
top: '60px',
right: '20px',
width: '400px',
zIndex: 1050,
backgroundColor: 'white',
maxHeight: '80vh',
display: 'flex',
flexDirection: 'column',
}}
>
<div className='card-header bg-white border-0 d-flex justify-content-between align-items-center py-3'>
<div className='d-flex align-items-center'>
<h6 className='mb-0 me-2'>通知中心</h6>
{unreadCount > 0 && <span className='badge bg-danger rounded-pill'>{unreadCount}</span>}
{isConnected ? (
<span className='ms-2 badge bg-success rounded-pill'>已连接</span>
) : (
<span className='ms-2 badge bg-secondary rounded-pill'>未连接</span>
)}
</div>
<div className='d-flex gap-3 align-items-center'>
<button
className='btn btn-link text-decoration-none p-0 text-dark'
onClick={handleMarkAllAsRead}
disabled={unreadCount === 0}
>
全部标为已读
</button>
<button
className='btn btn-link text-decoration-none p-0 text-dark'
onClick={handleClearAll}
disabled={notifications.length === 0}
>
清除所有
</button>
<button type='button' className='btn-close' onClick={onClose}></button>
</div>
</div>
<div className='card-body p-0' style={{ overflowY: 'auto' }}>
{displayedNotifications.length === 0 ? (
<div className='text-center py-4 text-muted'>
<i className='bi bi-bell fs-3 d-block mb-2'></i>
<p>暂无通知</p>
</div>
) : (
displayedNotifications.map((notification) => (
<div
key={notification.id}
className={`notification-item p-3 border-bottom hover-bg-light ${
!notification.isRead ? 'bg-light' : ''
}`}
>
<div className='d-flex gap-3'>
<div className='notification-icon'>
<i
className={`bi ${notification.icon} ${
!notification.isRead ? 'text-primary' : 'text-secondary'
} fs-5`}
></i>
</div>
<div className='flex-grow-1'>
<div className='d-flex justify-content-between align-items-start'>
<h6 className={`mb-1 ${!notification.isRead ? 'fw-bold' : ''}`}>
{notification.title}
</h6>
<small className='text-muted'>{notification.time}</small>
</div>
<p className='mb-1 text-secondary'>{notification.content}</p>
<div className='d-flex gap-2'>
{notification.hasDetail && (
<button
className='btn btn-sm btn-dark'
onClick={() => handleViewDetail(notification)}
>
查看详情
</button>
)}
{!notification.isRead && (
<button
className='btn btn-sm btn-outline-secondary'
onClick={() => handleMarkAsRead(notification.id)}
>
标为已读
</button>
)}
</div>
</div>
</div>
</div>
))
)}
</div>
{notifications.length > 5 && (
<div className='card-footer bg-white border-0 text-center p-3 mt-auto'>
<button
className='btn btn-link text-decoration-none text-dark'
onClick={() => setShowAll(!showAll)}
>
{showAll ? '收起' : `查看全部通知 (${notifications.length})`}
</button>
</div>
)}
</div>
{/* 使用滑动面板组件 */}
<RequestDetailSlideOver
show={showSlideOver}
onClose={handleCloseSlideOver}
request={selectedRequest}
onApprove={(id) => handleOpenResponseInput(id, true)}
onReject={(id) => handleOpenResponseInput(id, false)}
processingId={currentRequestId}
approveRejectStatus={showResponseInput ? 'loading' : 'idle'}
isApproving={isApproving}
/>
{/* 回复输入弹窗 */}
{showResponseInput && (
<div className='modal fade show' style={{ display: 'block' }} tabIndex='-1'>
<div className='modal-dialog'>
<div className='modal-content'>
<div className='modal-header'>
<h5 className='modal-title'>{isApproving ? '批准' : '拒绝'}申请</h5>
<button
type='button'
className='btn-close'
onClick={handleCloseResponseInput}
disabled={showResponseInput}
></button>
</div>
<div className='modal-body'>
<div className='mb-3'>
<label htmlFor='responseMessage' className='form-label'>
审批意见
</label>
<textarea
className='form-control'
id='responseMessage'
rows='3'
value={responseMessage}
onChange={(e) => setResponseMessage(e.target.value)}
placeholder={isApproving ? '请输入批准意见(可选)' : '请输入拒绝理由(可选)'}
></textarea>
</div>
</div>
<div className='modal-footer'>
<button
type='button'
className='btn btn-secondary'
onClick={handleCloseResponseInput}
disabled={showResponseInput}
>
取消
</button>
<button
type='button'
className={`btn ${isApproving ? 'btn-success' : 'btn-danger'}`}
onClick={handleProcessRequest}
disabled={showResponseInput}
>
{showResponseInput ? (
<>
<span
className='spinner-border spinner-border-sm me-1'
role='status'
aria-hidden='true'
></span>
处理中...
</>
) : isApproving ? (
'确认批准'
) : (
'确认拒绝'
)}
</button>
</div>
</div>
</div>
<div className='modal-backdrop fade show'></div>
</div>
)}
</>
);
}

View File

@ -0,0 +1,22 @@
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { hideNotification } from '../store/notification.slice.js';
import Snackbar from './Snackbar.jsx';
const NotificationSnackbar = () => {
const notification = useSelector((state) => state.notification);
const dispatch = useDispatch();
if (!notification) return null; //
return (
<Snackbar
type={notification.type}
message={notification.message}
duration={notification.duration}
onClose={() => dispatch(hideNotification())}
/>
);
};
export default NotificationSnackbar;

View File

@ -0,0 +1,59 @@
import React from 'react';
/**
* 分页组件
* @param {Object} props
* @param {number} props.currentPage - 当前页码
* @param {number} props.totalPages - 总页数
* @param {number} props.pageSize - 每页显示的条目数
* @param {Function} props.onPageChange - 页码变化的回调函数
* @param {Function} props.onPageSizeChange - 每页条目数变化的回调函数
*/
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 dark-pagination'>
<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,50 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import ErrorBoundary from './ErrorBoundary';
import CodeBlock from './CodeBlock';
/**
* SafeMarkdown component that wraps ReactMarkdown with error handling
* Displays raw content as fallback if markdown parsing fails
*/
const SafeMarkdown = ({ content, className = 'markdown-content' }) => {
// Fallback UI that shows raw content when ReactMarkdown fails
const renderFallback = (error) => {
console.error('Markdown rendering error:', error);
return (
<div className={`${className} markdown-fallback`}>
<p className='text-danger mb-2'>
<small>Error rendering markdown. Showing raw content:</small>
</p>
<div className='p-2 border rounded'>{content}</div>
</div>
);
};
return (
<ErrorBoundary fallback={renderFallback}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// Apply the className to the root element
root: ({ node, ...props }) => <div className={className} {...props} />,
code({ node, inline, className: codeClassName, children, ...props }) {
const match = /language-(\w+)/.exec(codeClassName || '');
return !inline && match ? (
<CodeBlock language={match[1]} value={String(children).replace(/\n$/, '')} />
) : (
<code className={codeClassName} {...props}>
{children}
</code>
);
},
}}
>
{content}
</ReactMarkdown>
</ErrorBoundary>
);
};
export default SafeMarkdown;

View File

@ -0,0 +1,215 @@
import React, { useRef, useState, useEffect } from 'react';
import SvgIcon from './SvgIcon';
/**
* 搜索栏组件
* @param {Object} props
* @param {string} props.searchKeyword - 搜索关键词
* @param {boolean} props.isSearching - 是否正在搜索
* @param {Function} props.onSearchChange - 搜索关键词变化的回调函数
* @param {Function} props.onSearch - 提交搜索的回调函数
* @param {Function} props.onClearSearch - 清除搜索的回调函数
* @param {string} props.placeholder - 搜索框占位文本
* @param {string} props.className - 额外的 CSS 类名
* @param {Array} props.searchResults - 搜索结果
* @param {boolean} props.isSearchLoading - 搜索是否正在加载
* @param {Function} props.onResultClick - 点击搜索结果的回调
* @param {Function} props.onRequestAccess - 申请权限的回调
* @param {string} props.cornerStyle - 设置圆角风格可选值: 'rounded'(圆角) 'square'(方角)
*/
const SearchBar = ({
searchKeyword,
isSearching,
onSearchChange,
onSearch,
onClearSearch,
placeholder = '搜索知识库...',
className = 'w-50',
searchResults = [],
isSearchLoading = false,
onResultClick,
onRequestAccess,
cornerStyle = 'rounded', //
}) => {
const [showDropdown, setShowDropdown] = useState(false);
const searchRef = useRef(null);
const inputRef = useRef(null);
//
const getBorderRadiusClass = () => {
return cornerStyle === 'rounded' ? 'rounded-pill' : 'rounded-0';
};
//
useEffect(() => {
const handleClickOutside = (event) => {
if (searchRef.current && !searchRef.current.contains(event.target)) {
setShowDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
//
useEffect(() => {
if (isSearching) {
setShowDropdown(true);
}
}, [isSearching]);
//
const handleInputChange = (e) => {
onSearchChange(e);
};
//
const handleSubmit = (e) => {
e.preventDefault();
onSearch(e);
// (searchResults)
};
return (
<div className={`position-relative ${className}`} ref={searchRef}>
<form className='d-flex' onSubmit={handleSubmit}>
<div className={`input-group search-input-group ${getBorderRadiusClass()}`}>
<input
ref={inputRef}
type='text'
className={`form-control search-input border-end-0 ${getBorderRadiusClass()}`}
placeholder={placeholder}
value={searchKeyword}
onChange={handleInputChange}
/>
{searchKeyword.trim() && (
<button
type='button'
className={`btn btn-outline-secondary border-start-0 ${
cornerStyle === 'rounded' ? '' : 'rounded-0'
}`}
onClick={() => {
onClearSearch();
setShowDropdown(false);
inputRef.current?.focus();
}}
>
<SvgIcon className='close' />
</button>
)}
<button
type='submit'
className={`btn btn-outline-secondary search-button ${
cornerStyle === 'rounded' ? 'rounded-end-pill' : 'rounded-0'
}`}
>
<SvgIcon className='search' />
</button>
</div>
</form>
{/* 搜索结果下拉框 - 在用户搜索后显示,无论是否有结果 */}
{showDropdown && (
<div
className={`position-absolute bg-white shadow-sm mt-1 w-100 search-results-dropdown z-1 ${
cornerStyle === 'rounded' ? 'rounded-3' : ''
}`}
>
<div className='p-2 overflow-auto' style={{ maxHeight: '350px', zIndex: '1050' }}>
{isSearchLoading ? (
<div className='text-center p-3'>
<div className='spinner-border spinner-border-sm text-secondary' role='status'>
<span className='visually-hidden'>加载中...</span>
</div>
<span className='ms-2'>搜索中...</span>
</div>
) : searchResults?.length > 0 ? (
<>
<div className='fw-bold text-secondary px-2 mb-2'>
搜索结果 ({searchResults.length})
</div>
{searchResults.map((item) => (
<div
key={item.id}
className={`search-result-item p-2 mb-1 hover-bg-light ${
cornerStyle === 'rounded' ? 'rounded-2' : ''
}`}
style={{
cursor: item.permissions?.can_read ? 'pointer' : 'default',
}}
>
<div className='d-flex justify-content-between align-items-center'>
<div
className='flex-grow-1'
onClick={() => {
if (item.permissions?.can_read) {
onResultClick(item.id, item.permissions);
setShowDropdown(false);
}
}}
>
<div className='d-flex align-items-center'>
<SvgIcon className='knowledge-base' />
<span className='ms-2 fw-medium'>
{item.highlighted_name ? (
<span
dangerouslySetInnerHTML={{
__html: item.highlighted_name,
}}
/>
) : (
item.name
)}
</span>
</div>
<div className='small text-secondary d-flex align-items-center mt-1'>
<span
className='badge me-2'
style={{
backgroundColor:
item.type === 'private' ? '#e9ecef' : '#cff4fc',
color: item.type === 'private' ? '#495057' : '#055160',
}}
>
{item.type === 'private' ? '私有' : item.type}
</span>
{item.department && <span className='me-2'>{item.department}</span>}
{!item.permissions?.can_read && (
<span className='text-danger'>
<SvgIcon className='lock' />
<span className='ms-1'>无权限</span>
</span>
)}
</div>
</div>
{!item.permissions?.can_read && (
<button
className={`btn btn-sm btn-outline-primary ms-2 ${
cornerStyle === 'rounded' ? '' : 'rounded-0'
}`}
onClick={() => {
onRequestAccess(item.id, item.name);
setShowDropdown(false);
}}
>
申请权限
</button>
)}
</div>
</div>
))}
</>
) : (
<div className='text-center text-secondary p-3'>未找到匹配的知识库</div>
)}
</div>
</div>
)}
</div>
);
};
export default SearchBar;

View File

@ -0,0 +1,41 @@
import React, { useEffect } from 'react';
import SvgIcon from './SvgIcon';
const Snackbar = ({ type = 'primary', message, duration = 3000, onClose }) => {
if (!message) return null;
useEffect(() => {
if (message) {
const timer = setTimeout(() => {
if (onClose) onClose();
}, duration);
return () => clearTimeout(timer);
}
}, [message, duration, onClose]);
const icons = {
success: 'check-circle-fill',
primary: 'info-fill',
warning: 'exclamation-triangle-fill',
danger: 'exclamation-triangle-fill',
};
//
const handleClose = (e) => {
e.preventDefault();
if (onClose) onClose();
};
return (
<div
className={`snackbar alert alert-${type} d-flex align-items-center justify-content-between position-fixed top-10 start-50 translate-middle w-50 gap-2`}
role='alert'
>
<SvgIcon className={icons[type]} />
<div className='flex-fill'>{message}</div>
<button type='button' className='btn-close flex-end' onClick={handleClose} aria-label='Close'></button>
</div>
);
};
export default Snackbar;

View File

@ -0,0 +1,46 @@
import React from 'react';
import { icons } from '../icons/icons';
export default function SvgIcon({ className, width, height, color, style }) {
// Create a new SVG string with custom attributes if provided
const customizeSvg = (svgString) => {
if (!svgString) return '';
// If no customization needed, return the original SVG
if (!width && !height && !color) return svgString;
// Parse the SVG to modify attributes
let modifiedSvg = svgString;
// Replace width if provided
if (width) {
modifiedSvg = modifiedSvg.replace(/width=['"]([^'"]*)['"]/g, `width="${width}"`);
}
// Replace height if provided
if (height) {
modifiedSvg = modifiedSvg.replace(/height=['"]([^'"]*)['"]/g, `height="${height}"`);
}
// Replace fill color if provided
if (color) {
modifiedSvg = modifiedSvg.replace(/fill=['"]currentColor['"]/g, `fill="${color}"`);
}
return modifiedSvg;
};
return (
<span
className={className}
style={{
display: 'inline-block',
lineHeight: 0,
...style,
}}
dangerouslySetInnerHTML={{
__html: customizeSvg(icons[className] || ''),
}}
/>
);
}

View File

@ -0,0 +1,268 @@
import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import '../styles/style.scss';
import { updateProfileThunk } from '../store/auth/auth.thunk';
//
const departmentGroups = {
达人部门: ['达人'],
商务部门: ['商务'],
样本中心: ['样本'],
产品部门: ['产品'],
AI自媒体: ['AI自媒体'],
HR: ['HR'],
技术部门: ['技术'],
};
function UserSettingsModal({ show, onClose }) {
const { user, loading } = useSelector((state) => state.auth);
const [lastPasswordChange] = useState('30天前'); // This would come from backend in real app
const [formData, setFormData] = useState({});
//
const [availableGroups, setAvailableGroups] = useState([]);
const [submitted, setSubmitted] = useState(false);
const [errors, setErrors] = useState({});
const dispatch = useDispatch();
useEffect(() => {
if (user) {
setFormData({
name: user.name,
email: user.email,
department: user.department,
group: user.group,
});
}
}, [user]);
//
useEffect(() => {
if (formData.department && departmentGroups[formData.department]) {
setAvailableGroups(departmentGroups[formData.department]);
} else {
setAvailableGroups([]);
}
}, [formData.department]);
if (!show) return null;
const handleInputChange = (e) => {
const { name, value } = e.target;
if (name === 'department') {
setFormData({
...formData,
[name]: value,
['group']: '',
});
} else {
setFormData({
...formData,
[name]: value,
});
}
//
if (errors[name]) {
setErrors({
...errors,
[name]: '',
});
}
};
const handleSubmit = async (e) => {
e.preventDefault();
setSubmitted(true);
if (validateForm()) {
console.log('Form submitted successfully!');
console.log('Update data:', formData);
try {
await dispatch(updateProfileThunk(formData)).unwrap();
} catch (error) {
console.error('Signup failed:', error);
}
}
};
const validateForm = () => {
const newErrors = {};
if (!formData.email) {
newErrors.email = 'Email is required';
} else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(formData.email)) {
newErrors.email = 'Invalid email address';
}
if (!formData.name) {
newErrors.name = 'Name is required';
}
if (!formData.department) {
newErrors.department = '请选择部门';
}
if (!formData.group) {
newErrors.group = '请选择组别';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
return (
<div className='modal show d-block' style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className='modal-dialog modal-dialog-centered'>
<form className='modal-content' onSubmit={handleSubmit}>
<div className='modal-header border-0'>
<h5 className='modal-title'>个人设置</h5>
<button type='button' className='btn-close' onClick={onClose}></button>
</div>
<div className='modal-body'>
<div className='mb-4'>
<h6 className='text-secondary mb-3'>个人信息</h6>
<div className='mb-3'>
<label className='form-label text-secondary'>用户名</label>
<input type='text' className='form-control' value={user?.username || ''} readOnly />
</div>
<div className='mb-3'>
<label className='form-label text-secondary'>姓名</label>
<input
type='text'
className='form-control'
value={formData?.name || ''}
onChange={handleInputChange}
disabled={loading}
/>
{submitted && errors.name && <div className='invalid-feedback'>{errors.name}</div>}
</div>
<div className='mb-3'>
<label className='form-label text-secondary'>邮箱</label>
<input
type='email'
className='form-control'
value={formData?.email || 'admin@ooin.com'}
onChange={handleInputChange}
disabled={loading}
/>
{submitted && errors.email && <div className='invalid-feedback'>{errors.email}</div>}
</div>
<div className='mb-3'>
<select
className={`form-select form-select-lg${
submitted && errors.department ? ' is-invalid' : ''
}`}
id='department'
name='department'
value={formData.department}
onChange={handleInputChange}
disabled={loading}
required
>
<option value='' disabled>
选择部门
</option>
{Object.keys(departmentGroups).map((dept, index) => (
<option key={index} value={dept}>
{dept}
</option>
))}
</select>
{submitted && errors.department && (
<div className='invalid-feedback'>{errors.department}</div>
)}
</div>
<div className='mb-3'>
<select
className={`form-select form-select-lg${
submitted && errors.group ? ' is-invalid' : ''
}`}
id='group'
name='group'
value={formData.group}
onChange={handleInputChange}
disabled={loading || !formData.department}
required
>
<option value='' disabled>
{formData.department ? '选择组别' : '请先选择部门'}
</option>
{availableGroups.map((group, index) => (
<option key={index} value={group}>
{group}
</option>
))}
</select>
{submitted && errors.group && <div className='invalid-feedback'>{errors.group}</div>}
</div>
</div>
<div className='d-none mb-4'>
<h6 className='text-secondary mb-3'>安全设置</h6>
<div className='d-flex justify-content-between align-items-center p-3 bg-light rounded'>
<div>
<div className='d-flex align-items-center gap-2'>
<i className='bi bi-key'></i>
<span>修改密码</span>
</div>
<small className='text-secondary'>上次修改{lastPasswordChange}</small>
</div>
<button className='btn btn-outline-dark btn-sm'>修改</button>
</div>
<div className='d-flex justify-content-between align-items-center p-3 bg-light rounded mt-3'>
<div>
<div className='d-flex align-items-center gap-2'>
<i className='bi bi-shield-check'></i>
<span>双重认证</span>
</div>
<small className='text-secondary'>增强账户安全性</small>
</div>
<button className='btn btn-outline-dark btn-sm'>设置</button>
</div>
</div>
<div className='d-none'>
<h6 className='text-secondary mb-3'>通知设置</h6>
<div className='form-check form-switch mb-3 dark-switch'>
<input
className='form-check-input'
type='checkbox'
id='notificationSwitch1'
defaultChecked
/>
<label className='form-check-label' htmlFor='notificationSwitch1'>
访问请求通知
</label>
<div className='text-secondary small'>新的数据集访问申请通知</div>
</div>
<div className='form-check form-switch dark-switch'>
<input
className='form-check-input'
type='checkbox'
id='notificationSwitch2'
defaultChecked
/>
<label className='form-check-label' htmlFor='notificationSwitch2'>
安全提醒
</label>
<div className='text-secondary small'>异常登录和权限变更提醒</div>
</div>
</div>
</div>
<div className='modal-footer border-0'>
<button type='button' disabled={loading} className='btn btn-outline-dark' onClick={onClose}>
取消
</button>
<button type='submit' className='btn btn-dark' disabled={loading}>
保存更改
</button>
</div>
</form>
</div>
</div>
);
}
export default UserSettingsModal;

125
src/icons/icons.js Normal file
View File

@ -0,0 +1,125 @@
export const icons = {
plus: `<svg xmlns='http://www.w3.org/2000/svg' height='16' width='16' viewBox='0 0 448 512' fill='currentColor'>
<path d='M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 144L48 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l144 0 0 144c0 17.7 14.3 32 32 32s32-14.3 32-32l0-144 144 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-144 0 0-144z' />
</svg>`,
'more-dot': `<svg xmlns='http://www.w3.org/2000/svg' height='20' width='5' viewBox='0 0 128 512'>
<path d='M64 360a56 56 0 1 0 0 112 56 56 0 1 0 0-112zm0-160a56 56 0 1 0 0 112 56 56 0 1 0 0-112zM120 96A56 56 0 1 0 8 96a56 56 0 1 0 112 0z' />
</svg>`,
trash: `<svg
t='1740778468968'
className='icon'
viewBox='0 0 1024 1024'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
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'
p-id='2708'
></path>
<path
d='M725.333333 981.333333H298.666667c-72.533333 0-128-55.466667-128-128V256c0-25.6 17.066667-42.666667 42.666666-42.666667s42.666667 17.066667 42.666667 42.666667v597.333333c0 25.6 17.066667 42.666667 42.666667 42.666667h426.666666c25.6 0 42.666667-17.066667 42.666667-42.666667V256c0-25.6 17.066667-42.666667 42.666667-42.666667s42.666667 17.066667 42.666666 42.666667v597.333333c0 72.533333-55.466667 128-128 128zM682.666667 298.666667c-25.6 0-42.666667-17.066667-42.666667-42.666667V170.666667c0-25.6-17.066667-42.666667-42.666667-42.666667h-170.666666c-25.6 0-42.666667 17.066667-42.666667 42.666667v85.333333c0 25.6-17.066667 42.666667-42.666667 42.666667s-42.666667-17.066667-42.666666-42.666667V170.666667c0-72.533333 55.466667-128 128-128h170.666666c72.533333 0 128 55.466667 128 128v85.333333c0 25.6-17.066667 42.666667-42.666666 42.666667z'
p-id='2709'
></path>
</svg>`,
'check-circle-fill': `<svg className='bi flex-shrink-0 me-2' role='img' width='16' height='16' fill='currentColor'>
<path d='M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z' />
</svg>`,
'info-fill': `<svg className='bi flex-shrink-0 me-2' role='img' width='16' height='16' fill='currentColor'>
<path d='M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z' />
</svg>`,
'exclamation-triangle-fill': `<svg className='bi flex-shrink-0 me-2' role='img' width='16' height='16' fill='currentColor'>
<path d='M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z' />
</svg>`,
file: `<svg xmlns='http://www.w3.org/2000/svg' height='16' width='16' viewBox='0 0 384 512'>
<path
fill='#737373'
d='M320 464c8.8 0 16-7.2 16-16l0-288-80 0c-17.7 0-32-14.3-32-32l0-80L64 48c-8.8 0-16 7.2-16 16l0 384c0 8.8 7.2 16 16 16l256 0zM0 64C0 28.7 28.7 0 64 0L229.5 0c17 0 33.3 6.7 45.3 18.7l90.5 90.5c12 12 18.7 28.3 18.7 45.3L384 448c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 64z'
/>
</svg>`,
clock: `<svg xmlns='http://www.w3.org/2000/svg' height='16' width='16' viewBox='0 0 512 512'>
<path
fill='#737373'
d='M464 256A208 208 0 1 1 48 256a208 208 0 1 1 416 0zM0 256a256 256 0 1 0 512 0A256 256 0 1 0 0 256zM232 120l0 136c0 8 4 15.5 10.7 20l96 64c11 7.4 25.9 4.4 33.3-6.7s4.4-25.9-6.7-33.3L280 243.2 280 120c0-13.3-10.7-24-24-24s-24 10.7-24 24z'
/>
</svg>`,
'circle-yes': `<svg xmlns='http://www.w3.org/2000/svg' height='14' width='14' viewBox='0 0 512 512' fill='currentColor'>
<path d='M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z' />
</svg>`,
eye: `<svg xmlns='http://www.w3.org/2000/svg' height='14' width='15.75' viewBox='0 0 576 512' fill='currentColor'>
<path d='M288 80c-65.2 0-118.8 29.6-159.9 67.7C89.6 183.5 63 226 49.4 256c13.6 30 40.2 72.5 78.6 108.3C169.2 402.4 222.8 432 288 432s118.8-29.6 159.9-67.7C486.4 328.5 513 286 526.6 256c-13.6-30-40.2-72.5-78.6-108.3C406.8 109.6 353.2 80 288 80zM95.4 112.6C142.5 68.8 207.2 32 288 32s145.5 36.8 192.6 80.6c46.8 43.5 78.1 95.4 93 131.1c3.3 7.9 3.3 16.7 0 24.6c-14.9 35.7-46.2 87.7-93 131.1C433.5 443.2 368.8 480 288 480s-145.5-36.8-192.6-80.6C48.6 356 17.3 304 2.5 268.3c-3.3-7.9-3.3-16.7 0-24.6C17.3 208 48.6 156 95.4 112.6zM288 336c44.2 0 80-35.8 80-80s-35.8-80-80-80c-.7 0-1.3 0-2 0c1.3 5.1 2 10.5 2 16c0 35.3-28.7 64-64 64c-5.5 0-10.9-.7-16-2c0 .7 0 1.3 0 2c0 44.2 35.8 80 80 80zm0-208a128 128 0 1 1 0 256 128 128 0 1 1 0-256z' />
</svg>`,
'chat-dot': `<svg xmlns='http://www.w3.org/2000/svg' height='14' width='14' viewBox='0 0 512 512' fill='currentColor'>
<path d='M168.2 384.9c-15-5.4-31.7-3.1-44.6 6.4c-8.2 6-22.3 14.8-39.4 22.7c5.6-14.7 9.9-31.3 11.3-49.4c1-12.9-3.3-25.7-11.8-35.5C60.4 302.8 48 272 48 240c0-79.5 83.3-160 208-160s208 80.5 208 160s-83.3 160-208 160c-31.6 0-61.3-5.5-87.8-15.1zM26.3 423.8c-1.6 2.7-3.3 5.4-5.1 8.1l-.3 .5c-1.6 2.3-3.2 4.6-4.8 6.9c-3.5 4.7-7.3 9.3-11.3 13.5c-4.6 4.6-5.9 11.4-3.4 17.4c2.5 6 8.3 9.9 14.8 9.9c5.1 0 10.2-.3 15.3-.8l.7-.1c4.4-.5 8.8-1.1 13.2-1.9c.8-.1 1.6-.3 2.4-.5c17.8-3.5 34.9-9.5 50.1-16.1c22.9-10 42.4-21.9 54.3-30.6c31.8 11.5 67 17.9 104.1 17.9c141.4 0 256-93.1 256-208S397.4 32 256 32S0 125.1 0 240c0 45.1 17.7 86.8 47.7 120.9c-1.9 24.5-11.4 46.3-21.4 62.9zM144 272a32 32 0 1 0 0-64 32 32 0 1 0 0 64zm144-32a32 32 0 1 0 -64 0 32 32 0 1 0 64 0zm80 32a32 32 0 1 0 0-64 32 32 0 1 0 0 64z' />
</svg>`,
key: `<svg xmlns='http://www.w3.org/2000/svg' height='14' width='14' viewBox='0 0 512 512' fill='currentColor'>
<path d='M336 352c97.2 0 176-78.8 176-176S433.2 0 336 0S160 78.8 160 176c0 18.7 2.9 36.8 8.3 53.7L7 391c-4.5 4.5-7 10.6-7 17l0 80c0 13.3 10.7 24 24 24l80 0c13.3 0 24-10.7 24-24l0-40 40 0c13.3 0 24-10.7 24-24l0-40 40 0c6.4 0 12.5-2.5 17-7l33.3-33.3c16.9 5.4 35 8.3 53.7 8.3zM376 96a40 40 0 1 1 0 80 40 40 0 1 1 0-80z' />
</svg>`,
lock: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
</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>`,
list: `<svg
t='1741046309233'
class='icon'
viewBox='0 0 1024 1024'
version='1.1'
xmlns='http://www.w3.org/2000/svg'
p-id='21340'
width='16'
height='16'
fill='currentColor'
>
<path
d='M896 256l-288 0c-17.696 0-32-14.336-32-32s14.304-32 32-32l288 0c17.696 0 32 14.336 32 32S913.696 256 896 256zM896 416l-288 0c-17.696 0-32-14.336-32-32s14.304-32 32-32l288 0c17.696 0 32 14.336 32 32S913.696 416 896 416zM896 672l-288 0c-17.696 0-32-14.304-32-32s14.304-32 32-32l288 0c17.696 0 32 14.304 32 32S913.696 672 896 672zM896 832l-288 0c-17.696 0-32-14.304-32-32s14.304-32 32-32l288 0c17.696 0 32 14.304 32 32S913.696 832 896 832zM384 480 192 480c-52.928 0-96-43.072-96-96L96 192c0-52.928 43.072-96 96-96l192 0c52.928 0 96 43.072 96 96l0 192C480 436.928 436.928 480 384 480zM192 160C174.368 160 160 174.368 160 192l0 192c0 17.632 14.368 32 32 32l192 0c17.632 0 32-14.368 32-32L416 192c0-17.632-14.368-32-32-32L192 160zM384 928 192 928c-52.928 0-96-43.072-96-96l0-192c0-52.928 43.072-96 96-96l192 0c52.928 0 96 43.072 96 96l0 192C480 884.928 436.928 928 384 928zM192 608c-17.632 0-32 14.336-32 32l0 192c0 17.664 14.368 32 32 32l192 0c17.632 0 32-14.336 32-32l0-192c0-17.664-14.368-32-32-32L192 608z'
p-id='21341'
></path>
</svg>`,
'setting-fill': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16" height="16" fill='currentColor'><path d="M495.9 166.6c3.2 8.7 .5 18.4-6.4 24.6l-43.3 39.4c1.1 8.3 1.7 16.8 1.7 25.4s-.6 17.1-1.7 25.4l43.3 39.4c6.9 6.2 9.6 15.9 6.4 24.6c-4.4 11.9-9.7 23.3-15.8 34.3l-4.7 8.1c-6.6 11-14 21.4-22.1 31.2c-5.9 7.2-15.7 9.6-24.5 6.8l-55.7-17.7c-13.4 10.3-28.2 18.9-44 25.4l-12.5 57.1c-2 9.1-9 16.3-18.2 17.8c-13.8 2.3-28 3.5-42.5 3.5s-28.7-1.2-42.5-3.5c-9.2-1.5-16.2-8.7-18.2-17.8l-12.5-57.1c-15.8-6.5-30.6-15.1-44-25.4L83.1 425.9c-8.8 2.8-18.6 .3-24.5-6.8c-8.1-9.8-15.5-20.2-22.1-31.2l-4.7-8.1c-6.1-11-11.4-22.4-15.8-34.3c-3.2-8.7-.5-18.4 6.4-24.6l43.3-39.4C64.6 273.1 64 264.6 64 256s.6-17.1 1.7-25.4L22.4 191.2c-6.9-6.2-9.6-15.9-6.4-24.6c4.4-11.9 9.7-23.3 15.8-34.3l4.7-8.1c6.6-11 14-21.4 22.1-31.2c5.9-7.2 15.7-9.6 24.5-6.8l55.7 17.7c13.4-10.3 28.2-18.9 44-25.4l12.5-57.1c2-9.1 9-16.3 18.2-17.8C227.3 1.2 241.5 0 256 0s28.7 1.2 42.5 3.5c9.2 1.5 16.2 8.7 18.2 17.8l12.5 57.1c15.8 6.5 30.6 15.1 44 25.4l55.7-17.7c8.8-2.8 18.6-.3 24.5 6.8c8.1 9.8 15.5 20.2 22.1 31.2l4.7 8.1c6.1 11 11.4 22.4 15.8 34.3zM256 336a80 80 0 1 0 0-160 80 80 0 1 0 0 160z"/></svg>`,
dataset: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16" height="16" fill='currentColor'><path d="M0 96C0 60.7 28.7 32 64 32l384 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm64 0l0 64 64 0 0-64L64 96zm384 0L192 96l0 64 256 0 0-64zM64 224l0 64 64 0 0-64-64 0zm384 0l-256 0 0 64 256 0 0-64zM64 352l0 64 64 0 0-64-64 0zm384 0l-256 0 0 64 256 0 0-64z"/></svg>`,
calendar: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" width="16" height="16" fill='currentColor'><path d="M152 24c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 40L64 64C28.7 64 0 92.7 0 128l0 16 0 48L0 448c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-256 0-48 0-16c0-35.3-28.7-64-64-64l-40 0 0-40c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 40L152 64l0-40zM48 192l352 0 0 256c0 8.8-7.2 16-16 16L64 464c-8.8 0-16-7.2-16-16l0-256z"/></svg>`,
clipboard: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" width="16" height="16" fill='currentColor'><path d="M280 64l40 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 128C0 92.7 28.7 64 64 64l40 0 9.6 0C121 27.5 153.3 0 192 0s71 27.5 78.4 64l9.6 0zM64 112c-8.8 0-16 7.2-16 16l0 320c0 8.8 7.2 16 16 16l256 0c8.8 0 16-7.2 16-16l0-320c0-8.8-7.2-16-16-16l-16 0 0 24c0 13.3-10.7 24-24 24l-88 0-88 0c-13.3 0-24-10.7-24-24l0-24-16 0zm128-8a24 24 0 1 0 0-48 24 24 0 1 0 0 48z"/></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>`,
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>`,
bell: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" width="16" height="16" fill='currentColor'><path d="M224 0c-17.7 0-32 14.3-32 32l0 19.2C119 66 64 130.6 64 208l0 18.8c0 47-17.3 92.4-48.5 127.6l-7.4 8.3c-8.4 9.4-10.4 22.9-5.3 34.4S19.4 416 32 416l384 0c12.6 0 24-7.4 29.2-18.9s3.1-25-5.3-34.4l-7.4-8.3C401.3 319.2 384 273.9 384 226.8l0-18.8c0-77.4-55-142-128-156.8L256 32c0-17.7-14.3-32-32-32zm45.3 493.3c12-12 18.7-28.3 18.7-45.3l-64 0-64 0c0 17 6.7 33.3 18.7 45.3s28.3 18.7 45.3 18.7s33.3-6.7 45.3-18.7z"/></svg>`,
'magnifying-glass': `<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>`,
close: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
</svg>`,
'knowledge-base': `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M5 10.5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5zm0-2a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5zm0-2a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5zm0-2a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5z"/>
<path d="M3 0h10a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-1h1v1a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v1H1V2a2 2 0 0 1 2-2z"/>
<path d="M1 5v-.5a.5.5 0 0 1 1 0V5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1H1zm0 3v-.5a.5.5 0 0 1 1 0V8h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1H1zm0 3v-.5a.5.5 0 0 1 1 0v.5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1H1z"/>
</svg>`,
'knowledge-base-large': `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 16 16">
<path d="M5 10.5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-2a.5.5 0 0 1-.5-.5zm0-2a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5zm0-2a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5zm0-2a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5z"/>
<path d="M3 0h10a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2v-1h1v1a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v1H1V2a2 2 0 0 1 2-2z"/>
<path d="M1 5v-.5a.5.5 0 0 1 1 0V5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1H1zm0 3v-.5a.5.5 0 0 1 1 0V8h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1H1zm0 3v-.5a.5.5 0 0 1 1 0v.5h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1H1z"/>
</svg>`,
plus: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>
</svg>`,
building: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" width="16" height="16" fill="currentColor"><path d="M48 0C21.5 0 0 21.5 0 48L0 464c0 26.5 21.5 48 48 48l96 0 0-80c0-26.5 21.5-48 48-48s48 21.5 48 48l0 80 96 0c26.5 0 48-21.5 48-48l0-416c0-26.5-21.5-48-48-48L48 0zM64 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm112-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zm80 16c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM80 96l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zm80 16c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM272 96l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16z"/></svg>`,
group:`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" width="16" height="16" fill="currentColor"><path d="M96 128a128 128 0 1 1 256 0A128 128 0 1 1 96 128zM0 482.3C0 383.8 79.8 304 178.3 304l91.4 0C368.2 304 448 383.8 448 482.3c0 16.4-13.3 29.7-29.7 29.7L29.7 512C13.3 512 0 498.7 0 482.3zM609.3 512l-137.8 0c5.4-9.4 8.6-20.3 8.6-32l0-8c0-60.7-27.1-115.2-69.8-151.8c2.4-.1 4.7-.2 7.1-.2l61.4 0C567.8 320 640 392.2 640 481.3c0 17-13.8 30.7-30.7 30.7zM432 256c-31 0-59-12.6-79.3-32.9C372.4 196.5 384 163.6 384 128c0-26.8-6.6-52.1-18.3-74.3C384.3 40.1 407.2 32 432 32c61.9 0 112 50.1 112 112s-50.1 112-112 112z"/></svg>`
};

View File

@ -0,0 +1,168 @@
import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { logoutThunk } from '../store/auth/auth.thunk';
import UserSettingsModal from '../components/UserSettingsModal';
import NotificationCenter from '../components/NotificationCenter';
import SvgIcon from '../components/SvgIcon';
export default function HeaderWithNav() {
const dispatch = useDispatch();
const navigate = useNavigate();
const location = useLocation();
const { user } = useSelector((state) => state.auth);
const [showSettings, setShowSettings] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const { notifications, unreadCount, isConnected } = useSelector((state) => state.notificationCenter);
const handleLogout = async () => {
try {
await dispatch(logoutThunk()).unwrap();
sessionStorage.removeItem('token');
navigate('/login');
} catch (error) {}
};
// Check if the current path starts with the given path
const isActive = (path) => {
return location.pathname.startsWith(path);
};
console.log('user', user);
// leader admin
const hasManagePermission = user && (user.role === 'leader' || user.role === 'admin');
return (
<header>
<nav className='navbar navbar-expand-lg bg-white shadow-sm'>
<div className='container-fluid'>
<Link className='navbar-brand' to='/'>
OOIN 达人智能知识库
</Link>
<button
className='navbar-toggler'
type='button'
data-bs-toggle='collapse'
data-bs-target='#navbarText'
aria-controls='navbarText'
aria-expanded='false'
aria-label='Toggle navigation'
>
<span className='navbar-toggler-icon'></span>
</button>
<div className='collapse navbar-collapse' id='navbarText'>
<ul className='navbar-nav me-auto mb-lg-0'>
<li className='nav-item'>
<Link
className={`nav-link ${
isActive('/') && !isActive('/chat') && !isActive('/permissions') ? 'active' : ''
}`}
aria-current='page'
to='/'
>
知识库
</Link>
</li>
<li className='nav-item'>
<Link className={`nav-link ${isActive('/chat') ? 'active' : ''}`} to='/chat'>
Chat
</Link>
</li>
<li className='nav-item'>
<Link
className={`nav-link ${isActive('/permissions') ? 'active' : ''}`}
to='/permissions'
>
权限管理
</Link>
</li>
</ul>
{!!user ? (
<div className='d-flex align-items-center gap-3'>
<div className='position-relative'>
<button
className='btn btn-link text-dark p-0'
onClick={() => setShowNotifications(!showNotifications)}
title={isConnected ? '通知服务已连接' : '通知服务未连接'}
>
<SvgIcon className={'bell'} />
{unreadCount > 0 && (
<span className='position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger'>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
{!isConnected && (
<span className='position-absolute bottom-0 end-0'>
<span
className='badge bg-secondary'
style={{ fontSize: '0.6rem', transform: 'translate(25%, 25%)' }}
>
<i className='bi bi-x-circle-fill'></i>
</span>
</span>
)}
</button>
</div>
<div className='flex-shrink-0 dropdown'>
<a
href='#'
className='d-block link-dark text-decoration-none dropdown-toggle'
data-bs-toggle='dropdown'
aria-expanded='false'
>
Hi, {user.username}
</a>
<ul
className='dropdown-menu text-small shadow'
style={{
position: 'absolute',
inset: '0px 0px auto auto',
margin: '0px',
transform: 'translate(0px, 34px)',
}}
>
<li>
<Link
className='dropdown-item'
to='#'
onClick={() => setShowSettings(true)}
>
个人设置
</Link>
</li>
<li className='d-none'>
<hr className='dropdown-divider' />
</li>
<li>
<Link className='dropdown-item' to='#' onClick={handleLogout}>
退出登录
</Link>
</li>
</ul>
</div>
</div>
) : (
<>
<hr className='d-lg-none' />
<ul className='navbar-nav mb-2 mb-lg-0'>
<li className='nav-item'>
<Link className='nav-link text-dark' to='/login'>
Log in
</Link>
</li>
<li className='nav-item'>
<Link className='nav-link text-dark' to='/signup'>
Sign up
</Link>
</li>
</ul>
</>
)}
</div>
</div>
</nav>
<UserSettingsModal show={showSettings} onClose={() => setShowSettings(false)} />
<NotificationCenter show={showNotifications} onClose={() => setShowNotifications(false)} />
</header>
);
}

View File

@ -0,0 +1,13 @@
import React from 'react';
import HeaderWithNav from './HeaderWithNav';
import '../styles/style.scss';
import NotificationSnackbar from '../components/NotificationSnackbar';
export default function Mainlayout({ children }) {
return (
<>
<HeaderWithNav />
{children}
</>
);
}

22
src/main.jsx Normal file
View File

@ -0,0 +1,22 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './styles/base.scss';
import App from './App.jsx';
import { BrowserRouter } from 'react-router-dom';
import 'bootstrap';
import { Provider } from 'react-redux';
import store, { persistor } from './store/store.js';
import { PersistGate } from 'redux-persist/integration/react';
import Loading from './components/Loading.jsx';
createRoot(document.getElementById('root')).render(
// <StrictMode>
<PersistGate loading={<Loading />} persistor={persistor}>
<BrowserRouter>
<Provider store={store}>
<App />
</Provider>
</BrowserRouter>
</PersistGate>
// </StrictMode>
);

179
src/pages/Chat/Chat.jsx Normal file
View File

@ -0,0 +1,179 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { fetchChats, deleteChat, createChatRecord, createConversation } from '../../store/chat/chat.thunks';
import { showNotification } from '../../store/notification.slice';
import ChatSidebar from './ChatSidebar';
import NewChat from './NewChat';
import ChatWindow from './ChatWindow';
export default function Chat() {
const { knowledgeBaseId, chatId } = useParams();
const navigate = useNavigate();
const dispatch = useDispatch();
// Redux store
const {
items: chatHistory,
status,
error,
} = useSelector((state) => state.chat.history || { items: [], status: 'idle', error: null });
const operationStatus = useSelector((state) => state.chat.createSession?.status);
const operationError = useSelector((state) => state.chat.createSession?.error);
//
useEffect(() => {
dispatch(fetchChats({ page: 1, page_size: 20 }));
}, [dispatch]);
//
useEffect(() => {
if (operationStatus === 'succeeded') {
dispatch(
showNotification({
message: '操作成功',
type: 'success',
})
);
} else if (operationStatus === 'failed' && operationError) {
dispatch(
showNotification({
message: `操作失败: ${operationError}`,
type: 'danger',
})
);
}
}, [operationStatus, operationError, dispatch]);
// If we have a knowledgeBaseId but no chatId, check if we have an existing chat or create a new one
useEffect(() => {
// knowledgeBaseId chatId
if (knowledgeBaseId && !chatId && status === 'succeeded' && !status.includes('loading')) {
console.log('Chat.jsx: 检查是否需要创建聊天...');
// ID ()
const knowledgeBaseIds = knowledgeBaseId.split(',').map((id) => id.trim());
console.log('Chat.jsx: 处理知识库ID列表:', knowledgeBaseIds);
//
const existingChat = chatHistory.find((chat) => {
// datasets
if (!chat.datasets || !Array.isArray(chat.datasets)) {
return false;
}
// ID
const chatDatasetIds = chat.datasets.map((ds) => ds.id);
//
//
return (
knowledgeBaseIds.length === chatDatasetIds.length &&
knowledgeBaseIds.every((id) => chatDatasetIds.includes(id))
);
});
console.log('Chat.jsx: existingChat', existingChat);
if (existingChat) {
console.log(
`Chat.jsx: 找到现有聊天记录,导航到 /chat/${knowledgeBaseId}/${existingChat.conversation_id}`
);
//
navigate(`/chat/${knowledgeBaseId}/${existingChat.conversation_id}`);
} else {
console.log('Chat.jsx: 创建新聊天...');
// - 使API
dispatch(
createConversation({
dataset_id_list: knowledgeBaseIds,
})
)
.unwrap()
.then((response) => {
// 使conversation_id
if (response && response.conversation_id) {
console.log(
`Chat.jsx: 创建成功,导航到 /chat/${knowledgeBaseId}/${response.conversation_id}`
);
navigate(`/chat/${knowledgeBaseId}/${response.conversation_id}`);
} else {
//
console.error('Chat.jsx: 创建失败未能获取会话ID');
dispatch(
showNotification({
message: '创建聊天失败未能获取会话ID',
type: 'danger',
})
);
}
})
.catch((error) => {
console.error('Chat.jsx: 创建失败', error);
dispatch(
showNotification({
message: `创建聊天失败: ${error}`,
type: 'danger',
})
);
});
}
}
}, [knowledgeBaseId, chatId, chatHistory, status, navigate, dispatch]);
const handleDeleteChat = (id) => {
// Redux action
dispatch(deleteChat(id))
.unwrap()
.then(() => {
//
dispatch(
showNotification({
message: '聊天记录已删除',
type: 'success',
})
);
// If the deleted chat is the current one, navigate to the chat list
if (chatId === id) {
navigate('/chat');
}
})
.catch((error) => {
//
dispatch(
showNotification({
message: `删除失败: ${error}`,
type: 'danger',
})
);
});
};
return (
<div className='chat-container container-fluid h-100'>
<div className='row h-100'>
{/* Sidebar */}
<div
className='col-md-3 col-lg-2 p-0 border-end'
style={{ height: 'calc(100vh - 73px)', overflowY: 'auto' }}
>
<ChatSidebar
chatHistory={chatHistory}
onDeleteChat={handleDeleteChat}
isLoading={status === 'loading'}
hasError={status === 'failed'}
/>
</div>
{/* Main Content */}
<div
className='chat-main col-md-9 col-lg-10 p-0'
style={{ height: 'calc(100vh - 73px)', overflowY: 'auto' }}
>
{!chatId ? <NewChat /> : <ChatWindow chatId={chatId} knowledgeBaseId={knowledgeBaseId} />}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,134 @@
import React, { useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import SvgIcon from '../../components/SvgIcon';
export default function ChatSidebar({ chatHistory = [], onDeleteChat, isLoading = false, hasError = false }) {
const navigate = useNavigate();
const { chatId, knowledgeBaseId } = useParams();
const [activeDropdown, setActiveDropdown] = useState(null);
const handleNewChat = () => {
navigate('/chat');
};
const handleToggleDropdown = (e, id) => {
e.preventDefault();
e.stopPropagation();
setActiveDropdown(activeDropdown === id ? null : id);
};
const handleDeleteChat = (e, id) => {
e.preventDefault();
e.stopPropagation();
if (onDeleteChat) {
onDeleteChat(id);
}
setActiveDropdown(null);
};
//
const renderLoading = () => (
<div className='p-3 text-center'>
<div className='spinner-border spinner-border-sm text-secondary' role='status'>
<span className='visually-hidden'>加载中...</span>
</div>
<div className='mt-2 text-muted small'>加载聊天记录...</div>
</div>
);
//
const renderError = () => (
<div className='p-3 text-center'>
<div className='text-danger mb-2'>
<SvgIcon className='error' width='24' height='24' />
</div>
<div className='text-muted small'>加载聊天记录失败请重试</div>
</div>
);
//
const renderEmpty = () => (
<div className='p-3 text-center'>
<div className='text-muted small'>暂无聊天记录</div>
</div>
);
return (
<div className='chat-sidebar d-flex flex-column h-100'>
<div className='p-3 pb-0'>
<h5 className='mb-0'>聊天记录</h5>
</div>
<div className='p-3'>
<button
className='btn btn-dark w-100 d-flex align-items-center justify-content-center gap-2'
onClick={handleNewChat}
>
<SvgIcon className='plus' color='#ffffff' />
<span>新建聊天</span>
</button>
</div>
<div className='overflow-auto flex-grow-1'>
{isLoading ? (
renderLoading()
) : hasError ? (
renderError()
) : chatHistory.length === 0 ? (
renderEmpty()
) : (
<ul className='list-group list-group-flush'>
{chatHistory.map((chat) => (
<li
key={chat.conversation_id}
className={`list-group-item border-0 position-relative ${
chatId === chat.conversation_id ? 'bg-light' : ''
}`}
>
<Link
to={`/chat/${chat.datasets?.map((ds) => ds.id).join(',') || knowledgeBaseId}/${
chat.conversation_id
}`}
className={`text-decoration-none d-flex align-items-center gap-2 py-2 text-dark ${
chatId === chat.conversation_id ? 'fw-bold' : ''
}`}
>
<div className='d-flex flex-column'>
<div className='text-truncate fw-medium'>
{chat.datasets?.map((ds) => ds.name).join(', ') || '未命名知识库'}
</div>
</div>
</Link>
<div
className='dropdown-area position-absolute end-0 top-0 bottom-0'
style={{ width: '40px' }}
>
<button
className='btn btn-sm position-absolute end-0 top-50 translate-middle-y me-2'
onClick={(e) => handleToggleDropdown(e, chat.conversation_id)}
>
<SvgIcon className='more-dot' width='5' height='16' />
</button>
{activeDropdown === chat.conversation_id && (
<div
className='position-absolute end-0 top-100 bg-white shadow rounded p-1 z-3'
style={{ zIndex: 1000, minWidth: '80px' }}
>
<button
className='btn btn-sm text-danger d-flex align-items-center gap-2 w-100'
onClick={(e) => handleDeleteChat(e, chat.conversation_id)}
>
<SvgIcon className='trash' />
<span>删除</span>
</button>
</div>
)}
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,403 @@
import React, { useState, useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchMessages } from '../../store/chat/chat.messages.thunks';
import { resetMessages, resetSendMessageStatus, addMessage } from '../../store/chat/chat.slice';
import { showNotification } from '../../store/notification.slice';
import { createChatRecord, fetchAvailableDatasets, fetchConversationDetail } from '../../store/chat/chat.thunks';
import { fetchKnowledgeBases } from '../../store/knowledgeBase/knowledgeBase.thunks';
import SvgIcon from '../../components/SvgIcon';
import SafeMarkdown from '../../components/SafeMarkdown';
import { get } from '../../services/api';
export default function ChatWindow({ chatId, knowledgeBaseId }) {
const dispatch = useDispatch();
const [inputMessage, setInputMessage] = useState('');
const [loading, setLoading] = useState(false);
const messagesEndRef = useRef(null);
const hasLoadedDetailRef = useRef({}); // ref
// Redux store
const messages = useSelector((state) => state.chat.messages.items);
const messageStatus = useSelector((state) => state.chat.messages.status);
const messageError = useSelector((state) => state.chat.messages.error);
const { status: sendStatus, error: sendError } = useSelector((state) => state.chat.sendMessage);
// 使Redux
const knowledgeBases = useSelector((state) => state.knowledgeBase.knowledgeBases || []);
const knowledgeBase = knowledgeBases.find((kb) => kb.id === knowledgeBaseId);
const isLoadingKnowledgeBases = useSelector((state) => state.knowledgeBase.loading);
//
const availableDatasets = useSelector((state) => state.chat.availableDatasets.items || []);
const availableDatasetsLoading = useSelector((state) => state.chat.availableDatasets.status === 'loading');
//
const conversation = useSelector((state) => state.chat.currentChat.data);
const conversationStatus = useSelector((state) => state.chat.currentChat.status);
const conversationError = useSelector((state) => state.chat.currentChat.error);
//
const createSessionStatus = useSelector((state) => state.chat.createSession?.status);
const createSessionId = useSelector((state) => state.chat.createSession?.sessionId);
// ID
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState([]);
// conversationknowledgeBaseIdselectedKnowledgeBaseIds
useEffect(() => {
// 使conversation
if (conversation && conversation.datasets && conversation.datasets.length > 0) {
const datasetIds = conversation.datasets.map((ds) => ds.id);
console.log('从会话中获取知识库列表:', datasetIds);
setSelectedKnowledgeBaseIds(datasetIds);
}
// 使URLID
else if (knowledgeBaseId) {
// IDID
const ids = knowledgeBaseId.split(',').map((id) => id.trim());
console.log('从URL参数中获取知识库列表:', ids);
setSelectedKnowledgeBaseIds(ids);
}
}, [conversation, knowledgeBaseId]);
//
useEffect(() => {
if (!chatId) return;
// chatId
if (hasLoadedDetailRef.current[chatId]) {
console.log('跳过已加载过的会话详情:', chatId);
return;
}
//
const isNewlyCreatedChat = createSessionStatus === 'succeeded' && createSessionId === chatId;
//
if (isNewlyCreatedChat && conversation && conversation.conversation_id === chatId) {
console.log('跳过新创建会话的详情获取:', chatId);
hasLoadedDetailRef.current[chatId] = true;
return;
}
console.log('获取会话详情:', chatId);
setLoading(true);
dispatch(fetchConversationDetail(chatId))
.unwrap()
.then((response) => {
console.log('获取会话详情成功:', response);
//
hasLoadedDetailRef.current[chatId] = true;
})
.catch((error) => {
console.error('获取会话详情失败:', error);
dispatch(
showNotification({
message: `获取聊天详情失败: ${error || '未知错误'}`,
type: 'danger',
})
);
})
.finally(() => {
setLoading(false);
});
//
return () => {
dispatch(resetMessages());
// hasLoadedDetailRef
// hasLoadedDetailRef.current = {}; // ref
};
}, [chatId, dispatch, createSessionStatus, createSessionId]);
// ref
useEffect(() => {
return () => {
hasLoadedDetailRef.current = {};
};
}, []);
//
useEffect(() => {
//
if (chatId && messages.length === 0 && !loading && messageStatus !== 'loading') {
const selectedKb = knowledgeBase ||
availableDatasets.find((ds) => ds.id === knowledgeBaseId) || { name: '知识库' };
dispatch(
addMessage({
id: 'welcome-' + Date.now(),
role: 'assistant',
content: `欢迎使用${selectedKb.name}。您可以向我提问任何相关问题。`,
created_at: new Date().toISOString(),
})
);
}
}, [chatId, messages.length, loading, messageStatus, knowledgeBase, knowledgeBaseId, availableDatasets, dispatch]);
//
useEffect(() => {
if (sendStatus === 'failed' && sendError) {
dispatch(
showNotification({
message: `发送失败: ${sendError}`,
type: 'danger',
})
);
dispatch(resetSendMessageStatus());
}
}, [sendStatus, sendError, dispatch]);
//
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
//
useEffect(() => {
// conversation使
if (conversation && conversation.datasets && conversation.datasets.length > 0) {
return;
}
// knowledgeBaseId
if (knowledgeBaseId && knowledgeBases.length === 0 && !availableDatasets.length) {
dispatch(fetchAvailableDatasets());
}
}, [dispatch, knowledgeBaseId, knowledgeBases, conversation, availableDatasets]);
const handleSendMessage = (e) => {
e.preventDefault();
if (!inputMessage.trim() || sendStatus === 'loading') return;
console.log('准备发送消息:', inputMessage);
console.log('当前会话ID:', chatId);
// ID
let dataset_id_list = [];
// 使
if (selectedKnowledgeBaseIds.length > 0) {
// 使
dataset_id_list = selectedKnowledgeBaseIds.map((id) => id.replace(/-/g, ''));
console.log('使用组件状态中的知识库列表:', dataset_id_list);
} else if (conversation && conversation.datasets && conversation.datasets.length > 0) {
// 使
dataset_id_list = conversation.datasets.map((ds) => ds.id.replace(/-/g, ''));
console.log('使用会话中的知识库列表:', dataset_id_list);
} else if (knowledgeBaseId) {
// 使
// IDID
const ids = knowledgeBaseId.split(',').map((id) => id.trim().replace(/-/g, ''));
dataset_id_list = ids;
console.log('使用URL参数中的知识库:', dataset_id_list);
} else if (availableDatasets.length > 0) {
// 使
dataset_id_list = [availableDatasets[0].id.replace(/-/g, '')];
console.log('使用可用知识库列表中的第一个:', dataset_id_list);
}
if (dataset_id_list.length === 0) {
dispatch(
showNotification({
message: '发送失败:未选择知识库',
type: 'danger',
})
);
return;
}
console.log('发送消息参数:', {
dataset_id_list,
question: inputMessage,
conversation_id: chatId,
});
//
dispatch(
createChatRecord({
dataset_id_list: dataset_id_list,
question: inputMessage,
conversation_id: chatId,
})
)
.unwrap()
.then((response) => {
//
console.log('消息发送成功:', response);
})
.catch((error) => {
//
console.error('消息发送失败:', error);
dispatch(
showNotification({
message: `发送失败: ${error}`,
type: 'danger',
})
);
});
//
setInputMessage('');
};
//
const renderLoading = () => (
<div className='p-5 text-center'>
<div className='spinner-border text-secondary' role='status'>
<span className='visually-hidden'>加载中...</span>
</div>
<div className='mt-3 text-muted'>加载聊天记录...</div>
</div>
);
//
const renderError = () => (
<div className='alert alert-danger'>
<p className='mb-0'>
<strong>加载消息失败</strong>
</p>
<p className='mb-0 small'>{messageError}</p>
<button className='btn btn-outline-secondary mt-3' onClick={() => dispatch(fetchMessages(chatId))}>
重试
</button>
</div>
);
//
const renderEmpty = () => {
if (loading) return null;
return (
<div className='text-center my-5'>
<p className='text-muted'>暂无消息开始发送第一条消息吧</p>
</div>
);
};
return (
<div className='chat-window d-flex flex-column h-100'>
{/* Chat header */}
<div className='p-3 border-bottom'>
{conversation && conversation.datasets ? (
<>
<h5 className='mb-0'>{conversation.datasets.map((dataset) => dataset.name).join(', ')}</h5>
{conversation.datasets.length > 0 && conversation.datasets[0].type && (
<small className='text-muted'>类型: {conversation.datasets[0].type}</small>
)}
</>
) : knowledgeBase ? (
<>
<h5 className='mb-0'>{knowledgeBase.name}</h5>
<small className='text-muted'>{knowledgeBase.description}</small>
</>
) : (
<h5 className='mb-0'>{loading || availableDatasetsLoading ? '加载中...' : '聊天'}</h5>
)}
</div>
{/* Chat messages */}
<div className='flex-grow-1 p-3 overflow-auto'>
<div className='container'>
{messageStatus === 'loading'
? renderLoading()
: messageStatus === 'failed'
? renderError()
: messages.length === 0
? renderEmpty()
: messages.map((message) => (
<div
key={message.id}
className={`d-flex ${
message.role === 'user' ? 'align-items-end' : 'align-items-start'
} mb-3 flex-column`}
>
<div
className={`chat-message p-3 rounded-3 ${
message.role === 'user' ? 'bg-dark text-white' : 'bg-light'
}`}
style={{
maxWidth: '75%',
position: 'relative',
}}
>
<div className='message-content'>
{message.role === 'user' ? (
message.content
) : (
<SafeMarkdown content={message.content} />
)}
{message.is_streaming && (
<span className='streaming-indicator'>
<span className='dot dot1'></span>
<span className='dot dot2'></span>
<span className='dot dot3'></span>
</span>
)}
</div>
</div>
<div className='message-time small text-muted mt-1'>
{message.created_at &&
(() => {
const messageDate = new Date(message.created_at);
const today = new Date();
//
const isToday =
messageDate.getDate() === today.getDate() &&
messageDate.getMonth() === today.getMonth() &&
messageDate.getFullYear() === today.getFullYear();
//
if (isToday) {
return messageDate.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
} else {
return messageDate.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
})()}
{message.is_streaming && ' · 正在生成...'}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
</div>
{/* Chat input */}
<div className='p-3 border-top'>
<form onSubmit={handleSendMessage} className='d-flex gap-2'>
<input
type='text'
className='form-control'
placeholder='输入你的问题...'
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
disabled={sendStatus === 'loading'}
/>
<button
type='submit'
className='btn btn-dark d-flex align-items-center justify-content-center gap-2'
disabled={sendStatus === 'loading' || !inputMessage.trim()}
>
<SvgIcon className='send' color='#ffffff' />
<span className='ms-1' style={{ minWidth: 'fit-content' }}>
发送
</span>
</button>
</form>
</div>
</div>
);
}

256
src/pages/Chat/NewChat.jsx Normal file
View File

@ -0,0 +1,256 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { showNotification } from '../../store/notification.slice';
import { fetchAvailableDatasets, fetchChats, createConversation } from '../../store/chat/chat.thunks';
import SvgIcon from '../../components/SvgIcon';
export default function NewChat() {
const navigate = useNavigate();
const dispatch = useDispatch();
const [selectedDatasetIds, setSelectedDatasetIds] = useState([]);
const [isNavigating, setIsNavigating] = useState(false);
// Redux store
const datasets = useSelector((state) => state.chat.availableDatasets.items || []);
const isLoading = useSelector((state) => state.chat.availableDatasets.status === 'loading');
const error = useSelector((state) => state.chat.availableDatasets.error);
//
const chatHistory = useSelector((state) => state.chat.history.items || []);
const chatHistoryLoading = useSelector((state) => state.chat.history.status === 'loading');
const chatCreationStatus = useSelector((state) => state.chat.sendMessage?.status);
//
useEffect(() => {
dispatch(fetchAvailableDatasets());
dispatch(fetchChats({ page: 1, page_size: 50 }));
}, [dispatch]);
//
useEffect(() => {
if (error) {
dispatch(
showNotification({
message: `获取可用知识库列表失败: ${error}`,
type: 'danger',
})
);
}
}, [error, dispatch]);
//
const handleToggleKnowledgeBase = (dataset) => {
if (isNavigating) return; //
setSelectedDatasetIds((prev) => {
//
const isSelected = prev.includes(dataset.id);
if (isSelected) {
//
return prev.filter((id) => id !== dataset.id);
} else {
//
return [...prev, dataset.id];
}
});
};
//
const handleStartChat = async () => {
if (selectedDatasetIds.length === 0) {
dispatch(
showNotification({
message: '请至少选择一个知识库',
type: 'warning',
})
);
return;
}
if (isNavigating) return; //
try {
setIsNavigating(true);
//
console.log('选中的知识库ID:', selectedDatasetIds);
//
//
const existingChat = chatHistory.find((chat) => {
//
if (chat.datasets && Array.isArray(chat.datasets)) {
const chatDatasetIds = chat.datasets.map((ds) => ds.id);
return (
chatDatasetIds.length === selectedDatasetIds.length &&
selectedDatasetIds.every((id) => chatDatasetIds.includes(id))
);
}
//
if (chat.dataset_id_list && Array.isArray(chat.dataset_id_list)) {
const formattedSelectedIds = selectedDatasetIds.map((id) => id.replace(/-/g, ''));
return (
chat.dataset_id_list.length === formattedSelectedIds.length &&
formattedSelectedIds.every((id) => chat.dataset_id_list.includes(id))
);
}
return false;
});
if (existingChat) {
//
// 使IDURL
const knowledgeBaseIdsParam = selectedDatasetIds.join(',');
console.log(
`找到现有聊天记录,直接导航到 /chat/${knowledgeBaseIdsParam}/${existingChat.conversation_id}`
);
navigate(`/chat/${knowledgeBaseIdsParam}/${existingChat.conversation_id}`);
} else {
//
console.log(`未找到现有聊天记录创建新会话选中的知识库ID: ${selectedDatasetIds.join(', ')}`);
try {
// createConversation
const response = await dispatch(
createConversation({
dataset_id_list: selectedDatasetIds,
})
).unwrap();
console.log('创建会话响应:', response);
if (response && response.conversation_id) {
// 使IDURL
const knowledgeBaseIdsParam = selectedDatasetIds.join(',');
console.log(`创建会话成功,导航到 /chat/${knowledgeBaseIdsParam}/${response.conversation_id}`);
navigate(`/chat/${knowledgeBaseIdsParam}/${response.conversation_id}`);
} else {
throw new Error('未能获取会话ID' + JSON.stringify(response));
}
} catch (apiError) {
// API
console.error('API调用失败:', apiError);
throw new Error(`API调用失败: ${apiError.message || '未知错误'}`);
}
}
} catch (error) {
console.error('导航或创建聊天失败:', error);
//
if (error.stack) {
console.error('错误堆栈:', error.stack);
}
//
dispatch(
showNotification({
message: `创建聊天失败: ${error.message || '请重试'}`,
type: 'danger',
})
);
} finally {
setIsNavigating(false);
}
};
//
if (isLoading || chatHistoryLoading) {
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 (
<div className='container-fluid px-4 py-5'>
{/* 导航中的遮罩层 */}
{isNavigating && (
<div
className='position-fixed top-0 start-0 w-100 h-100 d-flex justify-content-center align-items-center'
style={{
backgroundColor: 'rgba(255, 255, 255, 0.7)',
zIndex: 1050,
}}
>
<div className='text-center'>
<div className='spinner-border mb-2' role='status'>
<span className='visually-hidden'>加载中...</span>
</div>
<div>正在加载聊天界面...</div>
</div>
</div>
)}
<div className='d-flex justify-content-between align-items-center mb-4'>
<h4 className='m-0'>选择知识库开始聊天</h4>
<button
className='btn btn-dark'
onClick={handleStartChat}
disabled={selectedDatasetIds.length === 0 || isNavigating}
>
{isNavigating ? (
<>
<span
className='spinner-border spinner-border-sm me-2'
role='status'
aria-hidden='true'
></span>
加载中...
</>
) : (
<>开始聊天 {selectedDatasetIds.length > 0 && `(${selectedDatasetIds.length})`}</>
)}
</button>
</div>
{selectedDatasetIds.length > 0 && (
<div className='alert alert-dark mb-4'>已选择 {selectedDatasetIds.length} 个知识库</div>
)}
<div className='row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4 justify-content-center'>
{datasets.length > 0 ? (
datasets.map((dataset) => {
const isSelected = selectedDatasetIds.includes(dataset.id);
return (
<div key={dataset.id} className='col'>
<div
className={`card h-100 shadow-sm ${!isNavigating ? 'cursor-pointer' : ''} ${
isSelected ? 'border-gray border-2' : 'border-0'
}`}
onClick={() => handleToggleKnowledgeBase(dataset)}
style={{ opacity: isNavigating && !isSelected ? 0.6 : 1 }}
>
<div className='card-body'>
<h5 className='card-title d-flex justify-content-between align-items-center'>
{dataset.name}
{isSelected && <SvgIcon className='check-circle text-primary' />}
</h5>
<p className='card-text text-muted'>
{dataset.desc || dataset.description || ''}
</p>
<div className='text-muted small d-flex align-items-center gap-2'>
<span className='d-flex align-items-center gap-1'>
{dataset.department || ''}
</span>
</div>
</div>
</div>
</div>
);
})
) : (
<div className='col-12'>
<div className='alert alert-warning'>暂无可访问的知识库请先申请知识库访问权限</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,386 @@
import React, { useState, useEffect, useRef } from 'react';
import { useDispatch } from 'react-redux';
import { showNotification } from '../../../store/notification.slice';
import SvgIcon from '../../../components/SvgIcon';
//
import Breadcrumb from './components/Breadcrumb';
import DocumentList from './components/DocumentList';
import FileUploadModal from './components/FileUploadModal';
import { getKnowledgeBaseDocuments } from '../../../store/knowledgeBase/knowledgeBase.thunks';
export default function DatasetTab({ knowledgeBase }) {
const dispatch = useDispatch();
const [searchQuery, setSearchQuery] = useState('');
const [selectedDocuments, setSelectedDocuments] = useState([]);
const [selectAll, setSelectAll] = useState(false);
const [showBatchDropdown, setShowBatchDropdown] = useState(false);
const [showAddFileModal, setShowAddFileModal] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [newFile, setNewFile] = useState({
name: '',
description: '',
file: null,
});
const [fileErrors, setFileErrors] = useState({});
const dropdownRef = useRef(null);
const fileInputRef = useRef(null);
// Use documents from knowledge base or empty array if not available
const [documents, setDocuments] = useState(knowledgeBase.documents || []);
// Update documents when knowledgeBase changes
useEffect(() => {
setDocuments(knowledgeBase.documents || []);
}, [knowledgeBase]);
//
useEffect(() => {
if (knowledgeBase?.id) {
dispatch(getKnowledgeBaseDocuments({ knowledge_base_id: knowledgeBase.id }));
}
}, [dispatch, knowledgeBase?.id]);
// 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]);
// Handle search input change
const handleSearchChange = (e) => {
setSearchQuery(e.target.value);
};
// 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);
// Update documents state by removing selected documents
setDocuments((prevDocuments) => prevDocuments.filter((doc) => !selectedDocuments.includes(doc.id)));
// Show notification
dispatch(
showNotification({
message: '已删除选中的数据集',
type: 'success',
})
);
// Reset selection
setSelectedDocuments([]);
setSelectAll(false);
setShowBatchDropdown(false);
};
// Handle file input change
const handleFileChange = (e) => {
const selectedFile = e.target.files[0];
if (selectedFile) {
setNewFile({
...newFile,
name: selectedFile.name,
file: selectedFile,
});
// Clear file error if exists
if (fileErrors.file) {
setFileErrors((prev) => ({
...prev,
file: '',
}));
}
}
};
// Handle description input change
const handleDescriptionChange = (e) => {
setNewFile({
...newFile,
description: e.target.value,
});
};
// Handle file drop
const handleFileDrop = (e) => {
e.preventDefault();
e.stopPropagation();
const droppedFile = e.dataTransfer.files[0];
if (droppedFile) {
setNewFile({
...newFile,
name: droppedFile.name,
file: droppedFile,
});
// Clear file error if exists
if (fileErrors.file) {
setFileErrors((prev) => ({
...prev,
file: '',
}));
}
}
};
// Prevent default behavior for drag events
const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
};
// Validate file form
const validateFileForm = () => {
const errors = {};
if (!newFile.file) {
errors.file = '请上传文件';
}
setFileErrors(errors);
return Object.keys(errors).length === 0;
};
// Handle file upload
const handleFileUpload = () => {
// Validate form
if (!validateFileForm()) {
return;
}
setIsSubmitting(true);
// Here you would typically call an API to upload the file
console.log('Uploading file:', newFile);
// Simulate API call
setTimeout(() => {
// Generate a new ID for the document
const newId = Date.now().toString();
// Format file size
const fileSizeKB = newFile.file ? (newFile.file.size / 1024).toFixed(0) + 'kb' : '0kb';
// Get current date
const today = new Date();
const formattedDate = today.toISOString();
// Create new document object
const newDocument = {
id: newId,
name: newFile.name,
description: newFile.description || '无描述',
size: fileSizeKB,
create_time: formattedDate,
update_time: formattedDate,
};
// Add new document to the documents array
setDocuments((prevDocuments) => [...prevDocuments, newDocument]);
// Show notification
dispatch(
showNotification({
message: '数据集上传成功',
type: 'success',
})
);
setIsSubmitting(false);
// Reset form and close modal
handleCloseAddFileModal();
}, 1000);
};
// Open file selector when clicking on the upload area
const handleUploadAreaClick = () => {
fileInputRef.current.click();
};
// Handle close modal
const handleCloseAddFileModal = () => {
setNewFile({
name: '',
description: '',
file: null,
});
setFileErrors({});
setShowAddFileModal(false);
};
// Handle delete document
const handleDeleteDocument = (docId) => {
// Here you would typically call an API to delete the document
console.log('Deleting document:', docId);
// Update documents state by removing the deleted document
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 (
<>
{/* Breadcrumb navigation */}
<Breadcrumb knowledgeBase={knowledgeBase} activeTab='datasets' />
{/* Toolbar */}
<div className='d-flex justify-content-between align-items-center mb-3'>
<div className='d-flex gap-2' title={knowledgeBase.permissions?.can_edit ? '上传文件' : '无权限'}>
<button
disabled={!knowledgeBase.permissions?.can_edit}
className='btn btn-dark d-flex align-items-center gap-1'
onClick={() => setShowAddFileModal(true)}
>
<SvgIcon className='plus' />
上传文件
</button>
{selectedDocuments.length > 0 && (
<div className='dropdown' ref={dropdownRef}>
<button
className='btn btn-outline-secondary dropdown-toggle'
type='button'
onClick={() => setShowBatchDropdown(!showBatchDropdown)}
>
批量操作 ({selectedDocuments.length})
</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 className='w-25 d-none'>
<input
type='text'
className='form-control'
placeholder='搜索数据集...'
value={searchQuery}
onChange={handleSearchChange}
/>
</div>
</div>
{/* Document list */}
<DocumentList
documents={filteredDocuments}
knowledgeBaseId={knowledgeBase.id}
selectedDocuments={selectedDocuments}
selectAll={selectAll}
onSelectAll={handleSelectAll}
onSelectDocument={handleSelectDocument}
onDeleteDocument={handleDeleteDocument}
/>
{/* 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> */}
{/* File upload modal */}
<FileUploadModal
show={showAddFileModal}
knowledgeBaseId={knowledgeBase.id}
newFile={newFile}
fileErrors={fileErrors}
isSubmitting={isSubmitting}
onClose={handleCloseAddFileModal}
onDescriptionChange={handleDescriptionChange}
onFileChange={handleFileChange}
onFileDrop={handleFileDrop}
onDragOver={handleDragOver}
onUploadAreaClick={handleUploadAreaClick}
onUpload={handleFileUpload}
/>
</>
);
}

View File

@ -0,0 +1,119 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
import { showNotification } from '../../../store/notification.slice';
import { getKnowledgeBaseById } from '../../../store/knowledgeBase/knowledgeBase.thunks';
import SvgIcon from '../../../components/SvgIcon';
import DatasetTab from './DatasetTab';
import SettingsTab from './SettingsTab';
export default function KnowledgeBaseDetail() {
const { id, tab } = useParams();
const navigate = useNavigate();
const dispatch = useDispatch();
const [activeTab, setActiveTab] = useState(tab === 'settings' ? 'settings' : 'datasets');
// Get knowledge base details from Redux store - 使
const knowledgeBase = useSelector((state) => state.knowledgeBase.currentKnowledgeBase);
const loading = useSelector((state) => state.knowledgeBase.loading);
const error = useSelector((state) => state.knowledgeBase.error);
// Fetch knowledge base details when component mounts or ID changes
useEffect(() => {
if (id) {
dispatch(getKnowledgeBaseById(id));
}
}, [dispatch, id]);
// Update active tab when URL changes
useEffect(() => {
if (tab) {
setActiveTab(tab === 'settings' ? 'settings' : 'datasets');
}
}, [tab]);
// If knowledge base not found, show notification and redirect
useEffect(() => {
if (!loading && error) {
dispatch(
showNotification({
message: `获取知识库失败: ${error.message || '未找到知识库'}`,
type: 'warning',
})
);
navigate('/knowledge-base');
}
}, [loading, error, dispatch, navigate]);
// Handle tab change
const handleTabChange = (tab) => {
setActiveTab(tab);
navigate(`/knowledge-base/${id}/${tab}`);
};
// Show loading state if knowledge base not loaded yet
if (loading || !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 (
<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.name}</div>
<p className='text-center text-muted small mb-4'>{knowledgeBase.desc || ''}</p>
<hr />
<nav className='nav flex-column'>
<a
className={`nav-link link-dark link-underline-light d-flex align-items-center gap-2 ${
activeTab === 'datasets' ? 'active bg-light rounded fw-bold' : ''
}`}
href='#'
onClick={(e) => {
e.preventDefault();
handleTabChange('datasets');
}}
>
<SvgIcon className='dataset' />
数据集
</a>
<a
className={`nav-link link-dark link-underline-light d-flex align-items-center gap-1 ${
activeTab === 'settings' ? 'active bg-light rounded fw-bold' : ''
}`}
href='#'
onClick={(e) => {
e.preventDefault();
handleTabChange('settings');
}}
>
<SvgIcon className='setting-fill' />
设置
</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

@ -0,0 +1,351 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { showNotification } from '../../../store/notification.slice';
import {
updateKnowledgeBase,
deleteKnowledgeBase,
changeKnowledgeBaseType,
} from '../../../store/knowledgeBase/knowledgeBase.thunks';
//
import Breadcrumb from './components/Breadcrumb';
import KnowledgeBaseForm from './components/KnowledgeBaseForm';
import DeleteConfirmModal from './components/DeleteConfirmModal';
//
const departmentGroups = {
达人部门: ['达人'],
商务部门: ['商务'],
样本中心: ['样本'],
产品部门: ['产品'],
AI自媒体: ['AI自媒体'],
HR: ['HR'],
技术部门: ['技术'],
};
//
const departments = Object.keys(departmentGroups);
export default function SettingsTab({ knowledgeBase }) {
const dispatch = useDispatch();
const navigate = useNavigate();
const currentUser = useSelector((state) => state.auth.user);
const isAdmin = currentUser?.role === 'admin';
// State for knowledge base form
const [knowledgeBaseForm, setKnowledgeBaseForm] = useState({
permissions: knowledgeBase.permissions,
name: knowledgeBase.name,
desc: knowledgeBase.desc || knowledgeBase.description || '',
type: knowledgeBase.type || 'private', //
original_type: knowledgeBase.type || 'private', //
department: knowledgeBase.department || '',
group: knowledgeBase.group || '',
original_department: knowledgeBase.department || '',
original_group: knowledgeBase.group || '',
});
const [formErrors, setFormErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [availableGroups, setAvailableGroups] = useState([]);
//
useEffect(() => {
if (knowledgeBaseForm.department && departmentGroups[knowledgeBaseForm.department]) {
setAvailableGroups(departmentGroups[knowledgeBaseForm.department]);
//
if (!departmentGroups[knowledgeBaseForm.department].includes(knowledgeBaseForm.group)) {
setKnowledgeBaseForm((prev) => ({
...prev,
group: '',
}));
}
} else {
setAvailableGroups([]);
setKnowledgeBaseForm((prev) => ({
...prev,
group: '',
}));
}
}, [knowledgeBaseForm.department]);
//
useEffect(() => {
if (knowledgeBase.department && departmentGroups[knowledgeBase.department]) {
setAvailableGroups(departmentGroups[knowledgeBase.department]);
}
}, [knowledgeBase]);
// Handle knowledge base form input change
const handleInputChange = (e) => {
const { name, value } = e.target;
//
if (name === 'type') {
const role = currentUser?.role;
let allowed = false;
//
if (role === 'admin') {
//
allowed = ['admin', 'leader', 'member', 'private', 'secret'].includes(value);
} else if (role === 'leader') {
// member private
allowed = ['admin', 'member', 'private'].includes(value);
} else {
// private
allowed = ['admin', 'private'].includes(value);
}
if (!allowed) {
dispatch(
showNotification({
message: '您没有权限设置此类型的知识库',
type: 'warning',
})
);
return;
}
}
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 = '请输入知识库描述';
}
if (!knowledgeBaseForm.type) {
errors.type = '请选择知识库类型';
}
//
if (
(knowledgeBaseForm.type === 'leader' || knowledgeBaseForm.type === 'member') &&
!knowledgeBaseForm.department
) {
errors.department = '请选择部门';
}
if (knowledgeBaseForm.type === 'member' && !knowledgeBaseForm.group) {
errors.group = '请选择组别';
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
//
const hasDepartmentOrGroupChanged = () => {
return (
knowledgeBaseForm.department !== knowledgeBaseForm.original_department ||
knowledgeBaseForm.group !== knowledgeBaseForm.original_group
);
};
//
const handleTypeChange = (newType) => {
if (!currentUser) {
dispatch(
showNotification({
message: '用户信息不完整,无法更改类型',
type: 'warning',
})
);
return;
}
// adminprivate
if (newType !== 'admin' && newType !== 'private' && currentUser.role === 'member') {
dispatch(
showNotification({
message: '您只能将知识库修改为公共(admin)或私有(private)类型',
type: 'warning',
})
);
return;
}
if (isAdmin && !validateForm()) {
return;
}
console.log(newType, currentUser.role);
setIsSubmitting(true);
//
const isPrivate = newType === 'private';
const department = isPrivate ? '' : isAdmin ? knowledgeBaseForm.department : currentUser.department || '';
const group = isPrivate ? '' : isAdmin ? knowledgeBaseForm.group : currentUser.group || '';
console.log(newType, currentUser.role);
dispatch(
changeKnowledgeBaseType({
id: knowledgeBase.id,
type: newType,
department,
group,
})
)
.unwrap()
.then((updatedKB) => {
//
setKnowledgeBaseForm((prev) => ({
...prev,
type: updatedKB.type,
original_type: updatedKB.type,
department: updatedKB.department,
original_department: updatedKB.department,
group: updatedKB.group,
original_group: updatedKB.group,
}));
dispatch(
showNotification({
message: `知识库类型已更新为 ${updatedKB.type}`,
type: 'success',
})
);
setIsSubmitting(false);
})
.catch((error) => {
dispatch(
showNotification({
message: `类型更新失败: ${error || '未知错误'}`,
type: 'danger',
})
);
setIsSubmitting(false);
});
};
// Handle form submission
const handleSubmit = (e) => {
e.preventDefault();
// Validate form
if (!validateForm()) {
return;
}
setIsSubmitting(true);
// 使API
if (knowledgeBaseForm.type !== knowledgeBaseForm.original_type || (isAdmin && hasDepartmentOrGroupChanged())) {
handleTypeChange(knowledgeBaseForm.type);
return;
}
// Dispatch update knowledge base action ()
dispatch(
updateKnowledgeBase({
id: knowledgeBase.id,
data: {
name: knowledgeBaseForm.name,
desc: knowledgeBaseForm.desc,
description: knowledgeBaseForm.desc, // Add description field for compatibility
},
})
)
.unwrap()
.then(() => {
dispatch(
showNotification({
message: '知识库更新成功',
type: 'success',
})
);
setIsSubmitting(false);
})
.catch((error) => {
dispatch(
showNotification({
message: `更新失败: ${error || '未知错误'}`,
type: 'danger',
})
);
setIsSubmitting(false);
});
};
// Handle knowledge base deletion
const handleDelete = () => {
setIsSubmitting(true);
// Dispatch delete knowledge base action
dispatch(deleteKnowledgeBase(knowledgeBase.id))
.unwrap()
.then(() => {
dispatch(
showNotification({
message: '知识库已删除',
type: 'success',
})
);
// Navigate back to knowledge base list
// Redux store reducer
navigate('/knowledge-base');
})
.catch((error) => {
dispatch(
showNotification({
message: `删除失败: ${error || '未知错误'}`,
type: 'danger',
})
);
setIsSubmitting(false);
setShowDeleteConfirm(false);
});
};
return (
<>
{/* Breadcrumb navigation */}
<Breadcrumb knowledgeBase={knowledgeBase} activeTab='settings' />
{/* Knowledge Base Form */}
<KnowledgeBaseForm
formData={knowledgeBaseForm}
formErrors={formErrors}
isSubmitting={isSubmitting}
onInputChange={handleInputChange}
onSubmit={handleSubmit}
onDelete={() => setShowDeleteConfirm(true)}
onTypeChange={handleTypeChange}
isAdmin={isAdmin}
departments={departments}
availableGroups={availableGroups}
/>
{/* User Permissions Manager */}
{/* <UserPermissionsManager knowledgeBase={knowledgeBase} /> */}
{/* Delete confirmation modal */}
<DeleteConfirmModal
show={showDeleteConfirm}
title={knowledgeBase.name}
isSubmitting={isSubmitting}
onCancel={() => setShowDeleteConfirm(false)}
onConfirm={handleDelete}
/>
</>
);
}

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='/'>
知识库
</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 gap-2'>
<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,222 @@
import React, { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { formatDate } from '../../../../utils/dateUtils';
import { deleteKnowledgeBaseDocument } from '../../../../store/knowledgeBase/knowledgeBase.thunks';
import DocumentPreviewModal from './DocumentPreviewModal';
/**
* 知识库文档列表组件
*/
const DocumentList = ({ knowledgeBaseId }) => {
const dispatch = useDispatch();
const { items, loading, error } = useSelector((state) => state.knowledgeBase.documents);
const currentKnowledgeBase = useSelector((state) => state.knowledgeBase.currentKnowledgeBase);
const [previewModalVisible, setPreviewModalVisible] = useState(false);
const [selectedDocumentId, setSelectedDocumentId] = useState(null);
//
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [displayedItems, setDisplayedItems] = useState([]);
const [totalPages, setTotalPages] = useState(1);
//
const canEdit = currentKnowledgeBase?.permissions?.can_edit || false;
//
useEffect(() => {
if (!items || items.length === 0) {
setDisplayedItems([]);
setTotalPages(1);
return;
}
//
const total = Math.ceil(items.length / pageSize);
setTotalPages(total);
//
let page = currentPage;
if (page > total) {
page = total;
setCurrentPage(page);
}
//
const startIndex = (page - 1) * pageSize;
const endIndex = Math.min(startIndex + pageSize, items.length);
setDisplayedItems(items.slice(startIndex, endIndex));
console.log(`前端分页: 总项目 ${items.length}, 当前页 ${page}/${total}, 显示 ${startIndex + 1}-${endIndex}`);
}, [items, currentPage, pageSize]);
//
const handlePageChange = (page) => {
setCurrentPage(page);
};
const handlePrevPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};
const handleNextPage = () => {
if (currentPage < totalPages) {
setCurrentPage(currentPage + 1);
}
};
//
useEffect(() => {
console.log('DocumentList 渲染 - 知识库ID:', knowledgeBaseId);
console.log('文档列表状态:', { items, loading, error });
console.log('文档列表项数:', items ? items.length : 0);
}, [knowledgeBaseId, items, loading, error]);
const handleDeleteDocument = (documentId) => {
if (window.confirm('确定要删除此文档吗?')) {
dispatch(
deleteKnowledgeBaseDocument({
knowledge_base_id: knowledgeBaseId,
document_id: documentId,
})
);
}
};
const handlePreviewDocument = (documentId) => {
setSelectedDocumentId(documentId);
setPreviewModalVisible(true);
};
const handleClosePreviewModal = () => {
setPreviewModalVisible(false);
setSelectedDocumentId(null);
};
if (loading) {
console.log('DocumentList - 加载中...');
return (
<div className='text-center py-4'>
<div className='spinner-border text-primary' role='status'>
<span className='visually-hidden'>加载中...</span>
</div>
</div>
);
}
if (!items || items.length === 0) {
console.log('DocumentList - 暂无文档');
return (
<div className='text-center py-4 text-muted'>
<p>暂无文档请上传文档</p>
</div>
);
}
console.log('DocumentList - 渲染文档列表', displayedItems);
return (
<>
<div className='table-responsive'>
<table className='table table-hover'>
<thead className='table-light'>
<tr>
<th scope='col'>文档名称</th>
<th scope='col'>创建时间</th>
<th scope='col'>更新时间</th>
<th scope='col'>操作</th>
</tr>
</thead>
<tbody>
{displayedItems.map((doc) => (
<tr key={doc.id || doc.document_id}>
<td>
<div
className='text-truncate'
style={{ maxWidth: '250px' }}
title={doc.document_name || doc.name}
>
{doc.document_name || doc.name}
</div>
</td>
<td className='text-nowrap'>{formatDateTime(doc.create_time || doc.created_at)}</td>
<td className='text-nowrap'>{formatDateTime(doc.update_time || doc.updated_at)}</td>
<td>
<div className='btn-group' role='group'>
<button
className='btn btn-sm btn-outline-dark me-2'
onClick={() => handlePreviewDocument(doc.document_id || doc.id)}
>
预览
</button>
{canEdit && (
<button
className='btn btn-sm btn-outline-danger'
onClick={() => handleDeleteDocument(doc.document_id || doc.id)}
>
删除
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
{/* 分页控件 */}
{items.length > 0 && (
<div className='d-flex justify-content-between align-items-center mt-3'>
<div className='text-muted mb-0'>
{items.length} 条记录 {currentPage}/{totalPages}
</div>
<nav aria-label='文档列表分页'>
<ul className='pagination dark-pagination pagination-sm mb-0'>
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
<button className='page-link' onClick={handlePrevPage}>
上一页
</button>
</li>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<li key={page} className={`page-item ${page === currentPage ? 'active' : ''}`}>
<button className='page-link' onClick={() => handlePageChange(page)}>
{page}
</button>
</li>
))}
<li className={`page-item ${currentPage === totalPages ? 'disabled' : ''}`}>
<button className='page-link' onClick={handleNextPage}>
下一页
</button>
</li>
</ul>
</nav>
</div>
)}
</div>
<DocumentPreviewModal
show={previewModalVisible}
documentId={selectedDocumentId}
knowledgeBaseId={knowledgeBaseId}
onClose={handleClosePreviewModal}
/>
</>
);
};
// Helper function to format date string
const formatDateTime = (dateString) => {
if (!dateString) return '-';
// If the utility function exists, use it, otherwise format manually
try {
return formatDate(dateString);
} catch (error) {
const date = new Date(dateString);
return date.toLocaleString();
}
};
export default DocumentList;

View File

@ -0,0 +1,132 @@
import React, { useState, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { get } from '../../../../services/api';
import { showNotification } from '../../../../store/notification.slice';
/**
* 文档预览模态框组件
*/
const DocumentPreviewModal = ({ show, documentId, knowledgeBaseId, onClose }) => {
const dispatch = useDispatch();
const [documentContent, setDocumentContent] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (show && documentId && knowledgeBaseId) {
fetchDocumentContent();
}
}, [show, documentId, knowledgeBaseId]);
const fetchDocumentContent = async () => {
setLoading(true);
try {
const response = await get(`/knowledge-bases/${knowledgeBaseId}/document_content/`, {
params: { document_id: documentId },
});
// API
if (response.code === 200 && response.data) {
setDocumentContent(response.data);
} else if (response.data && response.data.code === 200) {
// data
setDocumentContent(response.data.data);
} else {
//
setDocumentContent(response);
}
console.log(documentContent);
} catch (error) {
console.error('获取文档内容失败:', error);
dispatch(
showNotification({
type: 'danger',
message: '获取文档内容失败',
})
);
} finally {
setLoading(false);
}
};
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: '800px',
maxWidth: '90%',
maxHeight: '80vh',
padding: '20px',
overflow: 'hidden',
}}
>
<div className='modal-header d-flex justify-content-between align-items-center mb-3'>
<h5 className='modal-title m-0'>{documentContent?.document_info?.name || '文档预览'}</h5>
<button type='button' className='btn-close' onClick={onClose} aria-label='Close'></button>
</div>
<div className='modal-body' style={{ overflow: 'auto' }}>
{loading ? (
<div className='text-center py-4'>
<div className='spinner-border text-primary' role='status'>
<span className='visually-hidden'>加载中...</span>
</div>
</div>
) : documentContent ? (
<div className='document-content'>
{documentContent?.paragraphs.length > 0 &&
documentContent.paragraphs.map((section, index) => {
let contentDisplay;
try {
// JSON
const parsedContent = JSON.parse(section.content);
contentDisplay = (
<pre className='bg-light p-3 rounded'>
{JSON.stringify(parsedContent, null, 2)}
</pre>
);
} catch (e) {
// JSON
contentDisplay = <p>{section.content}</p>;
}
return (
<div key={index} className='mb-3 p-3 border rounded'>
{contentDisplay}
</div>
);
})}
</div>
) : (
<div className='text-center py-4 text-muted'>
<p>无法获取文档内容</p>
</div>
)}
</div>
<div className='modal-footer'>
<button type='button' className='btn btn-outline-dark' onClick={onClose}>
关闭
</button>
</div>
</div>
</div>
);
};
export default DocumentPreviewModal;

View File

@ -0,0 +1,293 @@
import React, { useRef, useState, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { uploadDocument, getKnowledgeBaseDocuments } from '../../../../store/knowledgeBase/knowledgeBase.thunks';
/**
* 文件上传模态框组件
*/
const FileUploadModal = ({ show, knowledgeBaseId, onClose }) => {
const dispatch = useDispatch();
const fileInputRef = useRef(null);
const modalRef = useRef(null);
const [selectedFiles, setSelectedFiles] = useState([]);
const [isUploading, setIsUploading] = useState(false);
const [fileError, setFileError] = useState('');
const [uploadResults, setUploadResults] = useState(null);
//
const handleUploadAreaClick = () => {
fileInputRef.current?.click();
};
//
const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
handleFilesSelected(e.dataTransfer.files);
}
};
const handleFileChange = (e) => {
if (e.target.files && e.target.files.length > 0) {
handleFilesSelected(e.target.files);
}
};
const handleFilesSelected = (files) => {
setFileError('');
// FileList
const filesArray = Array.from(files);
setSelectedFiles((prev) => [...prev, ...filesArray]);
};
const removeFile = (index) => {
setSelectedFiles((prev) => prev.filter((_, i) => i !== index));
};
const resetFileInput = () => {
setSelectedFiles([]);
setFileError('');
setUploadResults(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleUpload = async () => {
if (selectedFiles.length === 0) {
setFileError('请至少选择一个要上传的文件');
return;
}
setIsUploading(true);
setUploadResults(null);
try {
const result = await dispatch(
uploadDocument({
knowledge_base_id: knowledgeBaseId,
files: selectedFiles,
})
).unwrap();
//
dispatch(getKnowledgeBaseDocuments({ knowledge_base_id: knowledgeBaseId }));
//
setUploadResults(result);
// 3
if (result.failed_count === 0) {
setTimeout(() => {
handleClose();
}, 3000);
}
} catch (error) {
console.error('Upload failed:', error);
setFileError('文件上传失败: ' + (error?.message || '未知错误'));
} finally {
setIsUploading(false);
}
};
const handleClose = () => {
//
if (!isUploading) {
resetFileInput();
onClose();
}
};
//
useEffect(() => {
return () => {
//
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
if (modalRef.current) {
modalRef.current = null;
}
};
}, []);
if (!show) return null;
return (
<div
ref={modalRef}
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: '600px',
maxWidth: '90%',
padding: '20px',
maxHeight: '90vh',
overflowY: 'auto',
}}
>
<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={handleClose}
disabled={isUploading}
aria-label='Close'
></button>
</div>
<div className='modal-body'>
<div
className={`mb-3 p-4 border rounded text-center ${
fileError ? 'border-danger' : 'border-dashed'
}`}
style={{ cursor: isUploading ? 'not-allowed' : 'pointer' }}
onClick={!isUploading ? handleUploadAreaClick : undefined}
onDrop={!isUploading ? handleDrop : undefined}
onDragOver={handleDragOver}
>
<input
type='file'
ref={fileInputRef}
className='d-none'
onChange={handleFileChange}
accept='.pdf,.doc,.docx,.txt,.md,.csv,.xlsx,.xls'
disabled={isUploading}
multiple
/>
<div>
<p className='mb-1'>点击或拖拽文件到此处上传</p>
<p className='text-muted small mb-0'>支持 PDF, Word, Excel, TXT, Markdown, CSV 等格式</p>
</div>
{fileError && <div className='text-danger mt-2'>{fileError}</div>}
</div>
{/* 选择的文件列表 */}
{selectedFiles.length > 0 && (
<div className='mb-3'>
<p className='fw-bold mb-2'>已选择 {selectedFiles.length} 个文件</p>
<ul className='list-group'>
{selectedFiles.map((file, index) => (
<li
key={index}
className='list-group-item d-flex justify-content-between align-items-center'
>
<div
className='d-flex align-items-center'
style={{ maxWidth: 'calc(100% - 70px)' }}
>
<div className='text-truncate'>{file.name}</div>
<div className='text-nowrap ms-2 text-muted' style={{ flexShrink: 0 }}>
({(file.size / 1024).toFixed(0)} KB)
</div>
</div>
{!isUploading && (
<button
className='btn btn-sm btn-outline-danger'
onClick={() => removeFile(index)}
style={{ minWidth: '60px' }}
>
移除
</button>
)}
</li>
))}
</ul>
</div>
)}
{/* 上传结果显示 */}
{uploadResults && (
<div className='mt-3 border rounded p-3'>
<h6 className='mb-2'>上传结果</h6>
<p className='mb-2'>
总文件: {uploadResults.total_files}, 成功: {uploadResults.uploaded_count}, 失败:{' '}
{uploadResults.failed_count}
</p>
{uploadResults.documents && uploadResults.documents.length > 0 && (
<>
<p className='mb-1 fw-bold text-success'>上传成功:</p>
<ul className='list-group mb-2'>
{uploadResults.documents.map((doc) => (
<li key={doc.id} className='list-group-item py-2 d-flex align-items-center'>
<span className='badge bg-success me-2'></span>
<span className='text-truncate'>{doc.name}</span>
</li>
))}
</ul>
</>
)}
{uploadResults.failed_documents && uploadResults.failed_documents.length > 0 && (
<>
<p className='mb-1 fw-bold text-danger'>上传失败:</p>
<ul className='list-group'>
{uploadResults.failed_documents.map((doc, index) => (
<li key={index} className='list-group-item py-2 d-flex align-items-center'>
<span className='badge bg-danger me-2'></span>
<div className='text-truncate'>
{doc.name}
{doc.reason && (
<small className='ms-2 text-danger'>({doc.reason})</small>
)}
</div>
</li>
))}
</ul>
</>
)}
</div>
)}
</div>
<div className='modal-footer gap-2'>
<button type='button' className='btn btn-secondary' onClick={handleClose} disabled={isUploading}>
关闭
</button>
<button
type='button'
className='btn btn-dark'
onClick={handleUpload}
disabled={selectedFiles.length === 0 || isUploading}
>
{isUploading ? (
<>
<span
className='spinner-border spinner-border-sm me-2'
role='status'
aria-hidden='true'
></span>
上传中...
</>
) : (
`上传文档${selectedFiles.length > 0 ? ` (${selectedFiles.length})` : ''}`
)}
</button>
</div>
</div>
</div>
);
};
export default FileUploadModal;

View File

@ -0,0 +1,283 @@
import React from 'react';
import { useSelector } from 'react-redux';
/**
* 知识库表单组件
*/
const KnowledgeBaseForm = ({
formData,
formErrors,
isSubmitting,
onInputChange,
onSubmit,
onDelete,
onTypeChange,
isAdmin,
departments,
availableGroups,
}) => {
//
const currentUser = useSelector((state) => state.auth.user);
//
const isLeader = currentUser?.role === 'leader';
//
const getAvailableTypes = () => {
if (isAdmin) {
return [
{ value: 'admin', label: '公共知识库' },
{ value: 'leader', label: '组长级知识库' },
{ value: 'member', label: '组内知识库' },
{ value: 'private', label: '私有知识库' },
{ value: 'secret', label: '私密知识库' },
];
} else if (isLeader) {
return [
{ value: 'admin', label: '公共知识库' },
{ value: 'member', label: '组内知识库' },
{ value: 'private', label: '私有知识库' },
];
} else {
return [
{ value: 'admin', label: '公共知识库' },
{ value: 'private', label: '私有知识库' },
];
}
};
const availableTypes = getAvailableTypes();
//
const hasTypeChanged = formData.original_type && formData.original_type !== formData.type;
//
const hasDepartmentOrGroupChanged =
(formData.original_department && formData.department !== formData.original_department) ||
(formData.original_group && formData.group !== formData.original_group);
//
const showTypeChangeButton = hasTypeChanged || (isAdmin && hasDepartmentOrGroupChanged);
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}
disabled={!(formData.permissions && formData.permissions.can_edit)}
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}
disabled={!(formData.permissions && formData.permissions.can_edit)}
onChange={onInputChange}
></textarea>
{formErrors.desc && <div className='invalid-feedback'>{formErrors.desc}</div>}
</div>
<div className='mb-4'>
<label className='form-label'>
知识库类型 <span className='text-danger'>*</span>
</label>
<div className='d-flex flex-column gap-2'>
{availableTypes.map((type) => (
<div className='form-check' key={type.value}>
<input
className='form-check-input'
type='radio'
name='type'
id={`type${type.value}`}
value={type.value}
checked={formData.type === type.value}
onChange={onInputChange}
disabled={!(formData.permissions && formData.permissions.can_edit)}
/>
<label className='form-check-label' htmlFor={`type${type.value}`}>
{type.label}
</label>
</div>
))}
</div>
{currentUser?.role === 'member' && (
<small className='text-muted d-block mt-1'>您可以修改知识库类型为公共或私有</small>
)}
{formErrors.type && <div className='text-danger small mt-1'>{formErrors.type}</div>}
</div>
{/* 仅当不是私有知识库时才显示部门选项 */}
{(formData.type === 'member' || formData.type === 'leader') && (
<div className='mb-3'>
<label htmlFor='department' className='form-label'>
部门 {isAdmin && <span className='text-danger'>*</span>}
</label>
{isAdmin ? (
<>
<select
className={`form-select ${formErrors.department ? 'is-invalid' : ''}`}
id='department'
name='department'
value={formData.department || ''}
onChange={onInputChange}
disabled={isSubmitting}
>
<option value=''>请选择部门</option>
{departments.map((dept, index) => (
<option key={index} value={dept}>
{dept}
</option>
))}
</select>
{formErrors.department && (
<div className='invalid-feedback'>{formErrors.department}</div>
)}
</>
) : (
<>
<input
type='text'
className='form-control bg-light'
id='department'
name='department'
value={formData.department || ''}
readOnly
/>
</>
)}
</div>
)}
{/* 仅当不是私有知识库时才显示组别选项 */}
{formData.type === 'member' && (
<div className='mb-3'>
<label htmlFor='group' className='form-label'>
组别 {isAdmin && <span className='text-danger'>*</span>}
</label>
{isAdmin ? (
<>
<select
className={`form-select ${formErrors.group ? 'is-invalid' : ''}`}
id='group'
name='group'
value={formData.group || ''}
onChange={onInputChange}
disabled={isSubmitting || !formData.department}
>
<option value=''>{formData.department ? '请选择组别' : '请先选择部门'}</option>
{availableGroups.map((group, index) => (
<option key={index} value={group}>
{group}
</option>
))}
</select>
{formErrors.group && <div className='invalid-feedback'>{formErrors.group}</div>}
{!formData.department && (
<small className='text-muted d-block mt-1'>请先选择部门</small>
)}
</>
) : (
<>
<input
type='text'
className='form-control bg-light'
id='group'
name='group'
value={formData.group || ''}
readOnly
/>
</>
)}
</div>
)}
{/* 类型更改按钮 */}
{showTypeChangeButton && (
<div className='alert alert-warning d-flex align-items-center justify-content-between'>
<div>
{hasTypeChanged && (
<p className='mb-0'>
知识库类型更改为 <strong>{formData.type}</strong>
</p>
)}
{isAdmin && hasDepartmentOrGroupChanged && <p className='mb-0'>部门/组别已更改</p>}
<small>点击更新按钮单独保存这些更改</small>
</div>
<button
type='button'
className='btn btn-warning'
onClick={() => onTypeChange && onTypeChange(formData.type)}
disabled={isSubmitting || currentUser?.role === 'member'}
>
{isSubmitting ? (
<>
<span
className='spinner-border spinner-border-sm me-2'
role='status'
aria-hidden='true'
></span>
更新中...
</>
) : (
'更新知识库类型'
)}
</button>
</div>
)}
<div className='d-flex justify-content-between mt-4'>
<button
type='submit'
className='btn btn-dark'
disabled={isSubmitting || !(formData.permissions && formData.permissions.can_edit)}
>
{isSubmitting ? (
<>
<span
className='spinner-border spinner-border-sm me-2'
role='status'
aria-hidden='true'
></span>
保存中...
</>
) : (
'保存设置'
)}
</button>
<button
type='button'
className='btn btn-outline-danger'
onClick={onDelete}
// disabled='true'
disabled={isSubmitting || !(formData.permissions && formData.permissions.can_edit)}
>
删除知识库
</button>
</div>
</form>
</div>
</div>
);
};
export default KnowledgeBaseForm;

View File

@ -0,0 +1,789 @@
import React, { useState, useRef, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { showNotification } from '../../../../store/notification.slice';
import SvgIcon from '../../../../components/SvgIcon';
/**
* 用户权限管理组件
*/
const UserPermissionsManager = ({ knowledgeBase }) => {
const dispatch = useDispatch();
// State for pagination
const [currentPage, setCurrentPage] = useState(1);
const usersPerPage = 10;
// State for edit modal
const [showEditModal, setShowEditModal] = useState(false);
const [editUser, setEditUser] = useState(null);
// State for add user modal
const [showAddUserModal, setShowAddUserModal] = useState(false);
const [newUser, setNewUser] = useState({
username: '',
email: '',
permissionType: '只读',
accessDuration: '一个月',
});
// State for batch operations
const [selectedUsers, setSelectedUsers] = useState([]);
const [selectAll, setSelectAll] = useState(false);
const [showBatchDropdown, setShowBatchDropdown] = useState(false);
const batchDropdownRef = useRef(null);
// State for batch edit modal
const [showBatchEditModal, setShowBatchEditModal] = useState(false);
const [batchEditData, setBatchEditData] = useState({
permissionType: '只读',
accessDuration: '一个月',
});
// Form errors
const [formErrors, setFormErrors] = useState({});
// Mock data for users with permissions
const [users, setUsers] = useState([
{
id: '1001',
username: '张三',
email: 'zhang@abc.com',
permissionType: '只读',
accessDuration: '一个月',
},
{
id: '1002',
username: '李四',
email: 'li@abc.com',
permissionType: '完全访问',
accessDuration: '永久',
},
{
id: '1003',
username: '王五',
email: 'wang@abc.com',
permissionType: '只读',
accessDuration: '三个月',
},
{
id: '1004',
username: '赵六',
email: 'zhao@abc.com',
permissionType: '完全访问',
accessDuration: '六个月',
},
{
id: '1005',
username: '钱七',
email: 'qian@abc.com',
permissionType: '只读',
accessDuration: '一周',
},
{
id: '1006',
username: '孙八',
email: 'sun@abc.com',
permissionType: '只读',
accessDuration: '一个月',
},
{
id: '1007',
username: '周九',
email: 'zhou@abc.com',
permissionType: '完全访问',
accessDuration: '永久',
},
{
id: '1008',
username: '吴十',
email: 'wu@abc.com',
permissionType: '只读',
accessDuration: '三个月',
},
]);
// Get current users for pagination
const indexOfLastUser = currentPage * usersPerPage;
const indexOfFirstUser = indexOfLastUser - usersPerPage;
const currentUsers = users.slice(indexOfFirstUser, indexOfLastUser);
const totalPages = Math.ceil(users.length / usersPerPage);
// Handle click outside batch dropdown
useEffect(() => {
function handleClickOutside(event) {
if (batchDropdownRef.current && !batchDropdownRef.current.contains(event.target)) {
setShowBatchDropdown(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
// Handle edit user permissions
const handleEditUser = (user) => {
setEditUser({ ...user });
setFormErrors({});
setShowEditModal(true);
};
// Handle input change in edit modal
const handleEditInputChange = (e) => {
const { name, value } = e.target;
setEditUser((prev) => ({
...prev,
[name]: value,
}));
// Clear error if exists
if (formErrors[name]) {
setFormErrors((prev) => ({
...prev,
[name]: '',
}));
}
};
// Handle input change in add user modal
const handleAddUserInputChange = (e) => {
const { name, value } = e.target;
setNewUser((prev) => ({
...prev,
[name]: value,
}));
// Clear error if exists
if (formErrors[name]) {
setFormErrors((prev) => ({
...prev,
[name]: '',
}));
}
};
// Handle input change in batch edit modal
const handleBatchEditInputChange = (e) => {
const { name, value } = e.target;
setBatchEditData((prev) => ({
...prev,
[name]: value,
}));
};
// Validate edit form
const validateEditForm = () => {
const errors = {};
if (!editUser.username.trim()) {
errors.username = '请输入用户名';
}
if (!editUser.email.trim()) {
errors.email = '请输入邮箱';
} else if (!/\S+@\S+\.\S+/.test(editUser.email)) {
errors.email = '请输入有效的邮箱地址';
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
// Validate add user form
const validateAddUserForm = () => {
const errors = {};
if (!newUser.username.trim()) {
errors.username = '请输入用户名';
}
if (!newUser.email.trim()) {
errors.email = '请输入邮箱';
} else if (!/\S+@\S+\.\S+/.test(newUser.email)) {
errors.email = '请输入有效的邮箱地址';
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
// Handle save user permissions
const handleSaveUserPermissions = () => {
// Validate form
if (!validateEditForm()) {
return;
}
// Here you would typically call an API to update the user permissions
console.log('Updating user permissions:', editUser);
// Update the users array with the edited user
setUsers((prevUsers) => prevUsers.map((user) => (user.id === editUser.id ? { ...editUser } : user)));
// Show success notification
dispatch(showNotification({ message: '用户权限已更新', type: 'success' }));
// Close modal
setShowEditModal(false);
};
// Handle add new user
const handleAddUser = () => {
// Validate form
if (!validateAddUserForm()) {
return;
}
// Generate a new ID
const newId = `user-${Date.now()}`;
// Create new user object
const userToAdd = {
id: newId,
username: newUser.username,
email: newUser.email,
permissionType: newUser.permissionType,
accessDuration: newUser.accessDuration,
};
// Add new user to the users array
setUsers((prevUsers) => [...prevUsers, userToAdd]);
// Show success notification
dispatch(showNotification({ message: '用户已添加', type: 'success' }));
// Reset form and close modal
setNewUser({
username: '',
email: '',
permissionType: '只读',
accessDuration: '一个月',
});
setShowAddUserModal(false);
};
// Handle delete user
const handleDeleteUser = (userId) => {
// Here you would typically call an API to delete the user
console.log('Deleting user:', userId);
// Remove user from the users array
setUsers((prevUsers) => prevUsers.filter((user) => user.id !== userId));
// Remove from selected users if present
setSelectedUsers((prev) => prev.filter((id) => id !== userId));
// Show success notification
dispatch(showNotification({ message: '用户已删除', type: 'success' }));
};
// Handle batch delete
const handleBatchDelete = () => {
if (selectedUsers.length === 0) return;
// Here you would typically call an API to delete the selected users
console.log('Batch deleting users:', selectedUsers);
// Remove selected users from the users array
setUsers((prevUsers) => prevUsers.filter((user) => !selectedUsers.includes(user.id)));
// Reset selection
setSelectedUsers([]);
setSelectAll(false);
setShowBatchDropdown(false);
// Show success notification
dispatch(showNotification({ message: `已删除 ${selectedUsers.length} 个用户`, type: 'success' }));
};
// Handle batch edit
const handleBatchEdit = () => {
if (selectedUsers.length === 0) return;
// Here you would typically call an API to update the selected users
console.log('Batch editing users:', selectedUsers, 'with data:', batchEditData);
// Update selected users in the users array
setUsers((prevUsers) =>
prevUsers.map((user) => (selectedUsers.includes(user.id) ? { ...user, ...batchEditData } : user))
);
// Close modal
setShowBatchEditModal(false);
setShowBatchDropdown(false);
// Show success notification
dispatch(showNotification({ message: `已更新 ${selectedUsers.length} 个用户的权限`, type: 'success' }));
};
// Handle select all checkbox
const handleSelectAll = () => {
if (selectAll) {
setSelectedUsers([]);
} else {
setSelectedUsers(currentUsers.map((user) => user.id));
}
setSelectAll(!selectAll);
};
// Handle individual user selection
const handleSelectUser = (userId) => {
if (selectedUsers.includes(userId)) {
setSelectedUsers(selectedUsers.filter((id) => id !== userId));
setSelectAll(false);
} else {
setSelectedUsers([...selectedUsers, userId]);
// Check if all current page users are now selected
if (selectedUsers.length + 1 === currentUsers.length) {
setSelectAll(true);
}
}
};
// Handle page change
const handlePageChange = (pageNumber) => {
setCurrentPage(pageNumber);
// Reset selection when changing pages
setSelectedUsers([]);
setSelectAll(false);
};
return (
<div className='card border-0 shadow-sm mt-4'>
<div className='card-body'>
<div className='d-flex justify-content-between align-items-center mb-4'>
<h5 className='card-title mb-0'>用户权限管理</h5>
<div className='d-flex gap-2'>
{selectedUsers.length > 0 && (
<div className='dropdown' ref={batchDropdownRef}>
<button
className='btn btn-outline-primary btn-sm dropdown-toggle'
onClick={() => setShowBatchDropdown(!showBatchDropdown)}
>
批量操作 ({selectedUsers.length})
</button>
{showBatchDropdown && (
<ul className='dropdown-menu show'>
<li>
<button
className='dropdown-item'
onClick={() => {
setShowBatchEditModal(true);
setShowBatchDropdown(false);
}}
>
<SvgIcon className='edit' />
<span className='ms-2'>批量修改权限</span>
</button>
</li>
<li>
<button className='dropdown-item text-danger' onClick={handleBatchDelete}>
<SvgIcon className='trash' />
<span className='ms-2'>批量删除</span>
</button>
</li>
</ul>
)}
</div>
)}
<button className='btn btn-dark btn-sm' onClick={() => setShowAddUserModal(true)}>
<SvgIcon className='plus' />
添加用户
</button>
</div>
</div>
<div className='table-responsive'>
<table className='table table-hover'>
<thead>
<tr>
<th>
<div className='form-check'>
<input
className='form-check-input'
type='checkbox'
checked={selectAll}
onChange={handleSelectAll}
/>
</div>
</th>
<th>用户名</th>
<th>邮箱</th>
<th>权限类型</th>
<th>访问期限</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{currentUsers.length > 0 ? (
currentUsers.map((user) => (
<tr key={user.id}>
<td>
<div className='form-check'>
<input
className='form-check-input'
type='checkbox'
checked={selectedUsers.includes(user.id)}
onChange={() => handleSelectUser(user.id)}
/>
</div>
</td>
<td>{user.username}</td>
<td>{user.email}</td>
<td>
<span
className={`badge ${
user.permissionType === '完全访问'
? 'bg-success-subtle text-success'
: 'bg-warning-subtle text-warning'
}`}
>
{user.permissionType}
</span>
</td>
<td>{user.accessDuration}</td>
<td>
<div className='btn-group'>
<button
className='btn btn-sm text-primary'
onClick={() => handleEditUser(user)}
>
<SvgIcon className='edit' />
</button>
<button
className='btn btn-sm text-danger'
onClick={() => handleDeleteUser(user.id)}
>
<SvgIcon className='trash' />
</button>
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan='6' className='text-center'>
暂无用户
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<nav aria-label='Page navigation'>
<ul className='pagination justify-content-center'>
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
<button
className='page-link'
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
>
上一页
</button>
</li>
{[...Array(totalPages)].map((_, index) => (
<li key={index} className={`page-item ${currentPage === index + 1 ? 'active' : ''}`}>
<button className='page-link' onClick={() => handlePageChange(index + 1)}>
{index + 1}
</button>
</li>
))}
<li className={`page-item ${currentPage === totalPages ? 'disabled' : ''}`}>
<button
className='page-link'
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
下一页
</button>
</li>
</ul>
</nav>
)}
{/* Edit User Modal */}
{showEditModal && (
<div className='modal fade show' style={{ display: 'block' }} tabIndex='-1'>
<div className='modal-dialog'>
<div className='modal-content'>
<div className='modal-header'>
<h5 className='modal-title'>编辑用户权限</h5>
<button
type='button'
className='btn-close'
onClick={() => setShowEditModal(false)}
></button>
</div>
<div className='modal-body'>
<form>
<div className='mb-3'>
<label htmlFor='username' className='form-label'>
用户名
</label>
<input
type='text'
className={`form-control ${formErrors.username ? 'is-invalid' : ''}`}
id='username'
name='username'
value={editUser.username}
onChange={handleEditInputChange}
/>
{formErrors.username && (
<div className='invalid-feedback'>{formErrors.username}</div>
)}
</div>
<div className='mb-3'>
<label htmlFor='email' className='form-label'>
邮箱
</label>
<input
type='email'
className={`form-control ${formErrors.email ? 'is-invalid' : ''}`}
id='email'
name='email'
value={editUser.email}
onChange={handleEditInputChange}
/>
{formErrors.email && (
<div className='invalid-feedback'>{formErrors.email}</div>
)}
</div>
<div className='mb-3'>
<label htmlFor='permissionType' className='form-label'>
权限类型
</label>
<select
className='form-select'
id='permissionType'
name='permissionType'
value={editUser.permissionType}
onChange={handleEditInputChange}
>
<option value='只读'>只读</option>
<option value='完全访问'>完全访问</option>
</select>
</div>
<div className='mb-3'>
<label htmlFor='accessDuration' className='form-label'>
访问期限
</label>
<select
className='form-select'
id='accessDuration'
name='accessDuration'
value={editUser.accessDuration}
onChange={handleEditInputChange}
>
<option value='一周'>一周</option>
<option value='一个月'>一个月</option>
<option value='三个月'>三个月</option>
<option value='六个月'>六个月</option>
<option value='永久'>永久</option>
</select>
</div>
</form>
</div>
<div className='modal-footer'>
<button
type='button'
className='btn btn-secondary'
onClick={() => setShowEditModal(false)}
>
取消
</button>
<button
type='button'
className='btn btn-primary'
onClick={handleSaveUserPermissions}
>
保存
</button>
</div>
</div>
</div>
</div>
)}
{/* Add User Modal */}
{showAddUserModal && (
<div className='modal fade show' style={{ display: 'block' }} tabIndex='-1'>
<div className='modal-dialog'>
<div className='modal-content'>
<div className='modal-header'>
<h5 className='modal-title'>添加用户</h5>
<button
type='button'
className='btn-close'
onClick={() => setShowAddUserModal(false)}
></button>
</div>
<div className='modal-body'>
<form>
<div className='mb-3'>
<label htmlFor='newUsername' className='form-label'>
用户名
</label>
<input
type='text'
className={`form-control ${formErrors.username ? 'is-invalid' : ''}`}
id='newUsername'
name='username'
value={newUser.username}
onChange={handleAddUserInputChange}
/>
{formErrors.username && (
<div className='invalid-feedback'>{formErrors.username}</div>
)}
</div>
<div className='mb-3'>
<label htmlFor='newEmail' className='form-label'>
邮箱
</label>
<input
type='email'
className={`form-control ${formErrors.email ? 'is-invalid' : ''}`}
id='newEmail'
name='email'
value={newUser.email}
onChange={handleAddUserInputChange}
/>
{formErrors.email && (
<div className='invalid-feedback'>{formErrors.email}</div>
)}
</div>
<div className='mb-3'>
<label htmlFor='newPermissionType' className='form-label'>
权限类型
</label>
<select
className='form-select'
id='newPermissionType'
name='permissionType'
value={newUser.permissionType}
onChange={handleAddUserInputChange}
>
<option value='只读'>只读</option>
<option value='完全访问'>完全访问</option>
</select>
</div>
<div className='mb-3'>
<label htmlFor='newAccessDuration' className='form-label'>
访问期限
</label>
<select
className='form-select'
id='newAccessDuration'
name='accessDuration'
value={newUser.accessDuration}
onChange={handleAddUserInputChange}
>
<option value='一周'>一周</option>
<option value='一个月'>一个月</option>
<option value='三个月'>三个月</option>
<option value='六个月'>六个月</option>
<option value='永久'>永久</option>
</select>
</div>
</form>
</div>
<div className='modal-footer'>
<button
type='button'
className='btn btn-secondary'
onClick={() => setShowAddUserModal(false)}
>
取消
</button>
<button type='button' className='btn btn-primary' onClick={handleAddUser}>
添加
</button>
</div>
</div>
</div>
</div>
)}
{/* Batch Edit Modal */}
{showBatchEditModal && (
<div className='modal fade show' style={{ display: 'block' }} tabIndex='-1'>
<div className='modal-dialog'>
<div className='modal-content'>
<div className='modal-header'>
<h5 className='modal-title'>批量修改权限</h5>
<button
type='button'
className='btn-close'
onClick={() => setShowBatchEditModal(false)}
></button>
</div>
<div className='modal-body'>
<p>
您正在修改 <strong>{selectedUsers.length}</strong> 个用户的权限
</p>
<form>
<div className='mb-3'>
<label htmlFor='batchPermissionType' className='form-label'>
权限类型
</label>
<select
className='form-select'
id='batchPermissionType'
name='permissionType'
value={batchEditData.permissionType}
onChange={handleBatchEditInputChange}
>
<option value='只读'>只读</option>
<option value='完全访问'>完全访问</option>
</select>
</div>
<div className='mb-3'>
<label htmlFor='batchAccessDuration' className='form-label'>
访问期限
</label>
<select
className='form-select'
id='batchAccessDuration'
name='accessDuration'
value={batchEditData.accessDuration}
onChange={handleBatchEditInputChange}
>
<option value='一周'>一周</option>
<option value='一个月'>一个月</option>
<option value='三个月'>三个月</option>
<option value='六个月'>六个月</option>
<option value='永久'>永久</option>
</select>
</div>
</form>
</div>
<div className='modal-footer'>
<button
type='button'
className='btn btn-secondary'
onClick={() => setShowBatchEditModal(false)}
>
取消
</button>
<button type='button' className='btn btn-primary' onClick={handleBatchEdit}>
保存
</button>
</div>
</div>
</div>
</div>
)}
{/* Modal backdrop */}
{(showEditModal || showAddUserModal || showBatchEditModal) && (
<div className='modal-backdrop fade show'></div>
)}
</div>
</div>
);
};
export default UserPermissionsManager;

View File

@ -0,0 +1,53 @@
.knowledge-base-page {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
.knowledge-base-header {
background-color: #f8f9fa;
border-radius: 10px;
margin-bottom: 20px;
}
.knowledge-base-cards-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.search-bar-container {
position: relative;
flex: 1;
max-width: 500px;
}
.search-results-dropdown {
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
border-radius: 8px;
z-index: 1050;
}
.search-result-item {
transition: background-color 0.2s;
}
.search-result-item:hover {
background-color: #f8f9fa;
}
.hover-bg-light:hover {
background-color: #f8f9fa;
}
/* 响应式样式 */
@media (max-width: 768px) {
.knowledge-base-cards-container {
grid-template-columns: 1fr;
}
.search-bar-container {
max-width: 100%;
}
}

View File

@ -0,0 +1,538 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { showNotification } from '../../store/notification.slice';
import {
fetchKnowledgeBases,
searchKnowledgeBases,
createKnowledgeBase,
deleteKnowledgeBase,
requestKnowledgeBaseAccess,
} from '../../store/knowledgeBase/knowledgeBase.thunks';
import { clearSearchResults } from '../../store/knowledgeBase/knowledgeBase.slice';
import SvgIcon from '../../components/SvgIcon';
import AccessRequestModal from '../../components/AccessRequestModal';
import CreateKnowledgeBaseModal from '../../components/CreateKnowledgeBaseModal';
import Pagination from '../../components/Pagination';
import SearchBar from '../../components/SearchBar';
import ApiModeSwitch from '../../components/ApiModeSwitch';
//
import KnowledgeBaseList from './components/KnowledgeBaseList';
export default function KnowledgeBase() {
const dispatch = useDispatch();
const navigate = useNavigate();
const [showCreateModal, setShowCreateModal] = useState(false);
const [showAccessRequestModal, setShowAccessRequestModal] = useState(false);
const [formErrors, setFormErrors] = useState({});
const [accessRequestKnowledgeBase, setAccessRequestKnowledgeBase] = useState({
id: '',
title: '',
});
const [isSubmittingRequest, setIsSubmittingRequest] = useState(false);
const [createdKnowledgeBaseId, setCreatedKnowledgeBaseId] = useState(null);
//
const currentUser = useSelector((state) => state.auth.user);
const [newKnowledgeBase, setNewKnowledgeBase] = useState({
name: '',
desc: '',
type: 'private', //
department: currentUser?.department || '',
group: currentUser?.group || '',
});
// Search state
const [searchKeyword, setSearchKeyword] = useState('');
const [isSearchDropdownOpen, setIsSearchDropdownOpen] = useState(false);
// Pagination state
const [pagination, setPagination] = useState({
page: 1,
page_size: 10,
});
// Get knowledge bases from Redux store
// Redux
const knowledgeBases = useSelector((state) => state.knowledgeBase.knowledgeBases);
const loading = useSelector((state) => state.knowledgeBase.loading);
const paginationData = useSelector((state) => state.knowledgeBase.pagination);
const error = useSelector((state) => state.knowledgeBase.error);
const operationStatus = useSelector((state) => state.knowledgeBase.editStatus);
const operationError = useSelector((state) => state.knowledgeBase.error);
// Redux
const searchResults = useSelector((state) => state.knowledgeBase.searchResults);
const searchLoading = useSelector((state) => state.knowledgeBase.searchLoading);
// Fetch knowledge bases when component mounts or pagination changes
useEffect(() => {
//
dispatch(fetchKnowledgeBases(pagination));
}, [dispatch, pagination.page, pagination.page_size]);
// Show loading state while fetching data
const isLoading = loading;
// Show error notification if fetch fails
useEffect(() => {
if (!isLoading && error) {
dispatch(
showNotification({
message: `获取知识库列表失败: ${error.message || error}`,
type: 'danger',
})
);
}
}, [isLoading, error, dispatch]);
// Show notification for operation status
useEffect(() => {
if (operationStatus === 'successful') {
//
// Refresh the list after successful operation
dispatch(fetchKnowledgeBases(pagination));
} else if (operationStatus === 'failed' && operationError) {
dispatch(
showNotification({
message: `操作失败: ${operationError.message || operationError}`,
type: 'danger',
})
);
}
}, [operationStatus, operationError, dispatch, pagination]);
// Handle search input change
const handleSearchInputChange = (e) => {
const value = e.target.value;
console.log('搜索框输入值:', value);
setSearchKeyword(value);
//
if (!value.trim()) {
dispatch(clearSearchResults());
setIsSearchDropdownOpen(false);
}
};
// Handle search submit -
const handleSearch = (e) => {
e.preventDefault();
if (searchKeyword.trim()) {
// isSearching
dispatch(
searchKnowledgeBases({
keyword: searchKeyword,
page: 1,
page_size: 5, //
})
);
setIsSearchDropdownOpen(true);
} else {
//
handleClearSearch();
}
};
// Handle clear search
const handleClearSearch = () => {
setSearchKeyword('');
//
setIsSearchDropdownOpen(false);
dispatch(clearSearchResults());
};
// 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 { name, value } = e.target;
const isAdmin = currentUser?.role === 'admin';
const isLeader = currentUser?.role === 'leader';
//
if (name === 'department' || name === 'group') {
//
if (name === 'department' && !isAdmin) {
return;
}
//
if (name === 'group' && !isAdmin && !isLeader) {
return;
}
//
setNewKnowledgeBase((prev) => ({
...prev,
[name]: value,
}));
//
if (name === 'department') {
setNewKnowledgeBase((prev) => ({
...prev,
group: '', //
}));
}
return;
}
//
if (name === 'type') {
const role = currentUser?.role;
let allowed = false;
//
if (role === 'admin') {
//
allowed = ['admin', 'leader', 'member', 'private', 'secret'].includes(value);
} else if (role === 'leader') {
// member private
allowed = ['admin', 'member', 'private'].includes(value);
} else {
// private
allowed = ['admin', 'private'].includes(value);
}
if (!allowed) {
dispatch(
showNotification({
message: '您没有权限创建此类型的知识库',
type: 'warning',
})
);
return;
}
}
setNewKnowledgeBase((prev) => ({
...prev,
[name]: value,
}));
// Clear error when user types
if (formErrors[name]) {
setFormErrors((prev) => ({
...prev,
[name]: '',
}));
}
};
const validateCreateForm = () => {
const errors = {};
const isAdmin = currentUser?.role === 'admin';
const isLeader = currentUser?.role === 'leader';
// member
const needDepartmentAndGroup = newKnowledgeBase.type === 'member' || newKnowledgeBase.type === 'leader';
//
const isPrivate = newKnowledgeBase.type === 'private';
if (!newKnowledgeBase.name.trim()) {
errors.name = '请输入知识库名称';
}
if (!newKnowledgeBase.desc.trim()) {
errors.desc = '请输入知识库描述';
}
if (!newKnowledgeBase.type) {
errors.type = '请选择知识库类型';
}
// memberleader
if (needDepartmentAndGroup && !isPrivate) {
//
if (isAdmin && !newKnowledgeBase.department) {
errors.department = `创建${newKnowledgeBase.type === 'leader' ? '组长级' : '组内'}知识库时必须选择部门`;
}
// memberleader
if (!newKnowledgeBase.group) {
errors.group = `创建${newKnowledgeBase.type === 'leader' ? '组长级' : '组内'}知识库时必须选择组别`;
}
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
const handleCreateKnowledgeBase = async () => {
// Validate form
if (!validateCreateForm()) {
return;
}
try {
//
const isPrivate = newKnowledgeBase.type === 'private';
// Dispatch create knowledge base action
const resultAction = await dispatch(
createKnowledgeBase({
name: newKnowledgeBase.name,
desc: newKnowledgeBase.desc,
description: newKnowledgeBase.desc,
type: newKnowledgeBase.type,
department: !isPrivate ? newKnowledgeBase.department : '',
group: !isPrivate ? newKnowledgeBase.group : '',
})
);
console.log('创建知识库返回数据:', resultAction);
// Check if the action was successful
if (createKnowledgeBase.fulfilled.match(resultAction)) {
console.log('创建成功payload:', resultAction.payload);
const { knowledge_base } = resultAction.payload;
const { id } = knowledge_base;
// Get ID from payload and navigate
if (id) {
console.log('新知识库ID:', id);
//
dispatch(
showNotification({
message: '知识库创建成功',
type: 'success',
})
);
//
navigate(`/knowledge-base/${id}`);
} else {
console.error('无法获取新知识库ID:', resultAction.payload);
dispatch(
showNotification({
message: '创建成功但无法获取知识库ID',
type: 'warning',
})
);
}
} else {
console.error('创建知识库失败:', resultAction.error);
dispatch(
showNotification({
message: `创建知识库失败: ${resultAction.error?.message || '未知错误'}`,
type: 'danger',
})
);
}
} catch (error) {
console.error('创建知识库出错:', error);
dispatch(
showNotification({
message: `创建知识库出错: ${error.message || '未知错误'}`,
type: 'danger',
})
);
}
// Reset form and close modal
setNewKnowledgeBase({ name: '', desc: '', type: 'private', department: '', group: '' });
setFormErrors({});
setShowCreateModal(false);
};
// Handle card click to navigate to knowledge base detail
const handleCardClick = (id, permissions) => {
//
if (!permissions || permissions.can_read === false) {
dispatch(
showNotification({
message: '您没有访问此知识库的权限,请先申请权限',
type: 'warning',
})
);
return;
}
//
navigate(`/knowledge-base/${id}/datasets`);
};
const handleRequestAccess = (id, title) => {
setAccessRequestKnowledgeBase({
id,
title,
});
setShowAccessRequestModal(true);
};
const handleSubmitAccessRequest = async (requestData) => {
setIsSubmittingRequest(true);
try {
// 使 - dispatchthunk
await dispatch(requestKnowledgeBaseAccess(requestData)).unwrap();
// Close modal after success
setShowAccessRequestModal(false);
} catch (error) {
dispatch(
showNotification({
message: `权限申请失败: ${error.response?.data?.message || '请稍后重试'}`,
type: 'danger',
})
);
} finally {
setIsSubmittingRequest(false);
}
};
const handleDelete = (e, id) => {
e.preventDefault();
e.stopPropagation();
// Dispatch delete knowledge base action
dispatch(deleteKnowledgeBase(id))
.unwrap()
.then(() => {
dispatch(
showNotification({
message: '知识库已删除',
type: 'success',
})
);
// Redux store reducer
})
.catch((error) => {
dispatch(
showNotification({
message: `删除失败: ${error || '未知错误'}`,
type: 'danger',
})
);
});
};
// Calculate total pages
const totalPages = Math.ceil(paginationData.total / pagination.page_size);
//
const handleOpenCreateModal = () => {
const isAdmin = currentUser?.role === 'admin';
const isLeader = currentUser?.role === 'leader';
//
let defaultType = 'admin';
//
let department = currentUser?.department || '';
let group = currentUser?.group || '';
setNewKnowledgeBase({
name: '',
desc: '',
type: defaultType,
department: department,
group: group,
});
setFormErrors({});
setShowCreateModal(true);
};
//
const handleSearchResultClick = (id, permissions) => {
if (permissions?.can_read) {
navigate(`/knowledge-base/${id}/datasets`);
}
};
return (
<div className='knowledge-base container my-4'>
{/* <div className='api-mode-control mb-3'>
<ApiModeSwitch />
</div> */}
<div className='d-flex justify-content-between align-items-center mb-3'>
<SearchBar
searchKeyword={searchKeyword}
isSearching={isSearchDropdownOpen} //
onSearchChange={handleSearchInputChange}
onSearch={handleSearch}
onClearSearch={handleClearSearch}
placeholder='搜索知识库...'
searchResults={searchResults}
isSearchLoading={searchLoading}
onResultClick={handleSearchResultClick}
onRequestAccess={handleRequestAccess}
/>
<button className='btn btn-dark d-flex align-items-center gap-1' onClick={handleOpenCreateModal}>
<SvgIcon className={'plus'} />
新建知识库
</button>
</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={knowledgeBases}
isSearching={false} // false
onCardClick={handleCardClick}
onRequestAccess={handleRequestAccess}
onDelete={handleDelete}
/>
{/* 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={loading}
onClose={() => setShowCreateModal(false)}
onChange={handleInputChange}
onSubmit={handleCreateKnowledgeBase}
currentUser={currentUser}
/>
{/* 申请权限弹窗 */}
<AccessRequestModal
show={showAccessRequestModal}
knowledgeBaseId={accessRequestKnowledgeBase.id}
knowledgeBaseTitle={accessRequestKnowledgeBase.title}
onClose={() => setShowAccessRequestModal(false)}
onSubmit={handleSubmitAccessRequest}
isSubmitting={isSubmittingRequest}
/>
</div>
);
}

View File

@ -0,0 +1,41 @@
import React from 'react';
import KnowledgeCard from './KnowledgeCard';
/**
* 知识库列表组件
*/
const KnowledgeBaseList = ({ knowledgeBases, isSearching, onCardClick, onRequestAccess, onDelete }) => {
if (!knowledgeBases?.length) {
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.highlighted_name || item.name}
description={item.desc || ''}
documents={item.document_count}
date={new Date(item.create_time).toLocaleDateString()}
permissions={item.permissions}
access={item.permissions?.can_edit ? 'full' : item.permissions?.can_read ? 'read' : 'none'}
onClick={() => onCardClick(item.id, item.permissions)}
onRequestAccess={onRequestAccess}
onDelete={(e) => onDelete(e, item.id)}
type={item.type}
department={item.department}
group={item.group}
/>
</React.Fragment>
))}
</div>
);
};
export default KnowledgeBaseList;

View File

@ -0,0 +1,121 @@
import React from 'react';
import SvgIcon from '../../../components/SvgIcon';
import { useNavigate } from 'react-router-dom';
export default function KnowledgeCard({
id,
title,
description,
documents,
date,
access,
permissions,
onClick,
onRequestAccess,
onDelete,
type,
department,
group,
}) {
const navigate = useNavigate();
const handleNewChat = (e) => {
e.preventDefault();
e.stopPropagation();
navigate(`/chat/${id}`);
};
const handleRequestAccess = (e) => {
e.preventDefault();
e.stopPropagation();
onRequestAccess(id, title);
};
//
const descriptionStyle = {
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
minHeight: '3rem', // 使
};
return (
<div className='knowledge-card card shadow border-0 p-0 col' onClick={onClick}>
<div className='card-body'>
<h5 className='card-title' dangerouslySetInnerHTML={{ __html: title }} />
{permissions && permissions.can_delete && (
<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' onClick={onDelete}>
删除
<SvgIcon className={'trash'} />
</li>
</ul>
</div>
)}
<div className='text-muted d-flex align-items-center gap-1'>
<SvgIcon className={'group'} />{department || ''} {group || 'N/A'}
</div>
<p className='card-text text-muted mb-3' style={descriptionStyle} title={description}>
{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-2 d-flex flex-wrap gap-2'>
<span className='badge bg-secondary-subtle text-secondary'>
{type === 'private' ? '私有' : '公开'}
</span>
{department && <span className='badge bg-info-subtle text-info'>{department}</span>}
{group && <span className='badge bg-primary-subtle text-primary'>{group}</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'
// onClick={handleNewChat}
// >
// <SvgIcon className={'chat-dot'} />
//
// </button>
<button
className='btn btn-outline-dark btn-sm d-flex align-items-center gap-1'
onClick={handleRequestAccess}
>
<SvgIcon className={'key'} />
申请权限
</button>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,39 @@
.permissions-container {
padding: 24px;
background-color: #f8f9fa;
min-height: calc(100vh - 64px);
}
.permissions-section {
background-color: #fff;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
}
.api-mode-control {
background-color: #fff;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
}
.api-mode-control .api-mode-switch {
display: flex;
flex-direction: column;
}
@media (max-width: 768px) {
.permissions-container {
padding: 16px;
}
.permissions-section,
.api-mode-control {
padding: 16px;
}
}
.permissions-section:last-child {
margin-bottom: 0;
}

View File

@ -0,0 +1,24 @@
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import PendingRequests from './components/PendingRequests';
import UserPermissions from './components/UserPermissions';
import './Permissions.css';
export default function PermissionsPage() {
const { user } = useSelector((state) => state.auth);
return (
<div className='permissions-container'>
<div className='permissions-section mb-4 container my-4'>
<PendingRequests />
</div>
{user && user.role === 'admin' && (
<div className='permissions-section container my-4'>
<UserPermissions />
</div>
)}
</div>
);
}

View File

@ -0,0 +1,251 @@
.permission-requests {
display: flex;
flex-direction: column;
gap: 1rem;
}
.permission-card {
transition: all 0.2s ease;
border-radius: 8px;
background-color: #343a40;
}
.permission-card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
.custom-badge {
padding: 0.35em 0.65em;
font-size: 0.85em;
}
.badge.bg-info {
background-color: #0dcaf0 !important;
}
.badge.bg-success {
background-color: #198754 !important;
}
.badge.bg-danger {
background-color: #dc3545 !important;
}
.badge.bg-secondary {
background-color: #6c757d !important;
}
/* 表格行鼠标样式 */
.cursor-pointer {
cursor: pointer;
}
/* 滑动面板样式 */
.slide-over-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1040;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.slide-over-backdrop.show {
opacity: 1;
visibility: visible;
}
.slide-over {
position: fixed;
top: 0;
right: -450px;
width: 450px;
height: 100%;
background-color: #fff;
z-index: 1050;
transition: right 0.3s ease;
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.1);
}
.slide-over.show {
right: 0;
}
.slide-over-content {
display: flex;
flex-direction: column;
height: 100%;
}
.slide-over-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #e9ecef;
}
.slide-over-body {
flex: 1;
padding: 1rem;
overflow-y: auto;
}
.slide-over-footer {
padding: 1rem;
border-top: 1px solid #e9ecef;
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
/* 头像占位符 */
.avatar-placeholder {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #6c757d;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 1.2rem;
}
/* 新增样式 - 白色基调 */
.badge-count {
background-color: #ff4d4f;
color: white;
border-radius: 20px;
padding: 4px 12px;
font-size: 14px;
}
.pending-requests-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.pending-request-item {
position: relative;
background-color: #fff;
border-radius: 8px;
padding: 16px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: all 0.2s ease;
}
.pending-request-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.request-header {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
}
.user-info h6 {
font-weight: 600;
}
.department {
color: #666;
font-size: 14px;
margin: 0;
}
.request-date {
color: #999;
font-size: 14px;
}
.request-content {
color: #333;
}
.permission-badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.permission-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.permission-badge.read {
background-color: #e6f7ff;
color: #1890ff;
border: 1px solid #91d5ff;
}
.permission-badge.edit {
background-color: #f6ffed;
color: #52c41a;
border: 1px solid #b7eb8f;
}
.permission-badge.delete {
background-color: #fff2f0;
color: #ff4d4f;
border: 1px solid #ffccc7;
}
.request-actions {
position: absolute;
right: 1rem;
bottom: 1rem;
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
/* 分页控件样式 */
.pagination-container {
display: flex;
justify-content: center;
align-items: center;
margin-top: 20px;
padding: 10px 0;
}
.pagination-button {
background-color: #fff;
border: 1px solid #d9d9d9;
color: #333;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.pagination-button:hover:not(:disabled) {
border-color: #1890ff;
color: #1890ff;
}
.pagination-button:disabled {
color: #d9d9d9;
cursor: not-allowed;
}
.pagination-info {
margin: 0 15px;
color: #666;
font-size: 14px;
}

View File

@ -0,0 +1,470 @@
import React, { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { showNotification } from '../../../store/notification.slice';
import {
fetchPermissionsThunk,
approvePermissionThunk,
rejectPermissionThunk,
} from '../../../store/permissions/permissions.thunks';
import { resetOperationStatus } from '../../../store/permissions/permissions.slice';
import './PendingRequests.css'; // CSS
import SvgIcon from '../../../components/SvgIcon';
import RequestDetailSlideOver from './RequestDetailSlideOver';
export default function PendingRequests() {
const dispatch = useDispatch();
const location = useLocation();
const [responseMessage, setResponseMessage] = useState('');
const [showResponseInput, setShowResponseInput] = useState(false);
const [currentRequestId, setCurrentRequestId] = useState(null);
const [isApproving, setIsApproving] = useState(false);
const [selectedRequest, setSelectedRequest] = useState(null);
const [showSlideOver, setShowSlideOver] = useState(false);
// Redux store
const {
results: permissionRequests,
status: fetchStatus,
error: fetchError,
page: currentPage,
page_size: pageSize,
total,
} = useSelector((state) => state.permissions.pending);
//
const totalPages = Math.ceil(total / pageSize) || 1;
// Redux store/
const {
status: approveRejectStatus,
error: approveRejectError,
currentId: processingId,
} = useSelector((state) => state.permissions.approveReject);
//
useEffect(() => {
dispatch(fetchPermissionsThunk({ page: currentPage, page_size: pageSize }));
}, [dispatch, currentPage, pageSize]);
//
useEffect(() => {
if (location.state?.showRequestDetail) {
const { requestId, requestData } = location.state;
setSelectedRequest(requestData);
setShowSlideOver(true);
// location state
window.history.replaceState({}, document.title);
}
}, [location.state]);
// /
useEffect(() => {
if (approveRejectStatus === 'succeeded') {
dispatch(
showNotification({
message: isApproving ? '已批准申请' : '已拒绝申请',
type: 'success',
})
);
setShowResponseInput(false);
setCurrentRequestId(null);
setResponseMessage('');
setShowSlideOver(false);
setSelectedRequest(null);
//
dispatch(fetchPermissionsThunk({ page: currentPage, page_size: pageSize }));
//
dispatch(resetOperationStatus());
} else if (approveRejectStatus === 'failed') {
dispatch(
showNotification({
message: approveRejectError || '处理申请失败',
type: 'danger',
})
);
//
dispatch(resetOperationStatus());
}
}, [approveRejectStatus, approveRejectError, dispatch, isApproving, currentPage, pageSize]);
//
const handleOpenResponseInput = (requestId, approving) => {
setCurrentRequestId(requestId);
setIsApproving(approving);
setShowResponseInput(true);
};
//
const handleCloseResponseInput = () => {
setShowResponseInput(false);
setCurrentRequestId(null);
setResponseMessage('');
};
//
const handleProcessRequest = () => {
if (!currentRequestId) return;
const params = {
id: currentRequestId,
responseMessage,
};
if (isApproving) {
dispatch(approvePermissionThunk(params));
} else {
dispatch(rejectPermissionThunk(params));
}
};
//
const handleRowClick = (request) => {
setSelectedRequest(request);
setShowSlideOver(true);
};
//
const handleCloseSlideOver = () => {
setShowSlideOver(false);
setTimeout(() => {
setSelectedRequest(null);
}, 300); //
};
//
const handleDirectProcess = (requestId, approve) => {
setCurrentRequestId(requestId);
setIsApproving(approve);
const params = {
id: requestId,
responseMessage: approve ? '已批准' : '已拒绝',
};
if (approve) {
dispatch(approvePermissionThunk(params));
} else {
dispatch(rejectPermissionThunk(params));
}
};
//
const handlePageChange = (page) => {
dispatch(fetchPermissionsThunk({ page, page_size: pageSize }));
};
//
const renderPagination = () => {
if (totalPages <= 1) return null;
return (
<div className='pagination-container'>
<button
className='pagination-button'
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
>
上一页
</button>
<div className='pagination-info'>
{currentPage} / {totalPages}
</div>
<button
className='pagination-button'
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
下一页
</button>
</div>
);
};
//
const renderStatusBadge = (status) => {
switch (status) {
case 'approved':
return (
<span
className='badge bg-success-subtle text-success d-flex align-items-center gap-1'
style={{ width: 'fit-content' }}
>
<SvgIcon className='circle-check' />
已批准
</span>
);
case 'rejected':
return (
<span
className='badge bg-danger-subtle text-danger d-flex align-items-center gap-1'
style={{ width: 'fit-content' }}
>
<SvgIcon className='circle-xmark' />
已拒绝
</span>
);
case 'pending':
return (
<span
className='badge bg-warning-subtle text-warning d-flex align-items-center gap-1'
style={{ width: 'fit-content' }}
>
待处理
</span>
);
default:
return null;
}
};
//
const getKnowledgeBaseTypeText = (type) => {
switch (type) {
case 'admin':
return '公共知识库';
case 'member':
return '组内知识库';
case 'private':
return '私人知识库';
case 'leader':
return '组长级知识库';
case 'secret':
return '私密知识库';
default:
return '未知类型';
}
};
//
if (fetchStatus === 'loading' && permissionRequests.length === 0) {
return (
<div className='text-center py-5'>
<div className='spinner-border' role='status'>
<span className='visually-hidden'>加载中...</span>
</div>
<p className='mt-3'>加载权限申请...</p>
</div>
);
}
//
if (fetchStatus === 'failed' && permissionRequests.length === 0) {
return (
<div className='alert alert-danger' role='alert'>
{fetchError || '获取权限申请失败'}
</div>
);
}
//
if (permissionRequests.length === 0) {
return (
<div className='alert alert-info' role='alert'>
暂无权限申请记录
</div>
);
}
//
return (
<>
<div className='d-flex justify-content-between align-items-center mb-3'>
<h5 className='mb-0'>权限申请列表</h5>
<div className='badge bg-danger'>
{permissionRequests.filter((req) => req.status === 'pending').length}个待处理
</div>
</div>
<div className='pending-requests-list'>
{permissionRequests.map((request) => (
<div key={request.id} className='pending-request-item' onClick={() => handleRowClick(request)}>
<div className='request-header'>
<div className='user-info'>
<h6 className='mb-0'>{request.applicant.name || request.applicant.username}</h6>
<small className='text-muted'>{request.applicant.department || '未分配部门'}</small>
</div>
<div className='request-date'>{new Date(request.created_at).toLocaleDateString()}</div>
</div>
<div className='request-content'>
<div className='d-flex justify-content-between align-items-start mb-2'>
<div>
<p className='mb-0'>申请访问{request.knowledge_base.name}</p>
<small className='text-muted'>
类型{getKnowledgeBaseTypeText(request.knowledge_base.type)}
</small>
</div>
{renderStatusBadge(request.status)}
</div>
{request.permissions.can_edit ? (
<span
className='badge bg-success-subtle text-success d-flex align-items-center gap-1'
style={{ width: 'fit-content' }}
>
<SvgIcon className={'circle-yes'} />
完全访问
</span>
) : (
request.permissions.can_read && (
<span
className='badge bg-warning-subtle text-warning d-flex align-items-center gap-1'
style={{ width: 'fit-content' }}
>
<SvgIcon className={'eye'} />
只读访问
</span>
)
)}
{request.expires_at && (
<div className='mt-2'>
<small className='text-muted'>
到期时间: {new Date(request.expires_at).toLocaleDateString()}
</small>
</div>
)}
{request.response_message && (
<div className='mt-2'>
<small className='text-muted'>审批意见: {request.response_message}</small>
</div>
)}
</div>
<div className='request-actions'>
{request.status === 'pending' && request.role === 'approver' ? (
<>
<button
className='btn btn-outline-danger btn-sm'
onClick={(e) => {
e.stopPropagation();
handleDirectProcess(request.id, false);
}}
disabled={processingId === request.id && approveRejectStatus === 'loading'}
>
{processingId === request.id &&
approveRejectStatus === 'loading' &&
!isApproving
? '处理中...'
: '拒绝'}
</button>
<button
className='btn btn-outline-success btn-sm'
onClick={(e) => {
e.stopPropagation();
handleDirectProcess(request.id, true);
}}
disabled={processingId === request.id && approveRejectStatus === 'loading'}
>
{processingId === request.id && approveRejectStatus === 'loading' && isApproving
? '处理中...'
: '批准'}
</button>
</>
) : (
<div className='status-text'>
{request.status === 'approved'
? '已批准'
: request.status === 'pending'
? '待处理'
: '已拒绝'}
{request.updated_at && (
<small className='text-muted d-block'>
{new Date(request.updated_at).toLocaleDateString()}
</small>
)}
</div>
)}
</div>
</div>
))}
</div>
{/* 分页控件 */}
{renderPagination()}
{/* 使用新的滑动面板组件 */}
<RequestDetailSlideOver
show={showSlideOver}
onClose={handleCloseSlideOver}
request={selectedRequest}
onApprove={(id) => handleOpenResponseInput(id, true)}
onReject={(id) => handleOpenResponseInput(id, false)}
processingId={processingId}
approveRejectStatus={approveRejectStatus}
isApproving={isApproving}
/>
{/* 回复输入弹窗 */}
{showResponseInput && (
<div className='modal fade show' style={{ display: 'block' }} tabIndex='-1'>
<div className='modal-dialog' style={{ zIndex: 9999 }}>
<div className='modal-content'>
<div className='modal-header'>
<h5 className='modal-title'>{isApproving ? '批准' : '拒绝'}申请</h5>
<button
type='button'
className='btn-close'
onClick={handleCloseResponseInput}
disabled={approveRejectStatus === 'loading'}
></button>
</div>
<div className='modal-body'>
<div className='mb-3'>
<label htmlFor='responseMessage' className='form-label'>
审批意见
</label>
<textarea
className='form-control'
id='responseMessage'
rows='3'
value={responseMessage}
onChange={(e) => setResponseMessage(e.target.value)}
placeholder={isApproving ? '请输入批准意见(可选)' : '请输入拒绝理由(可选)'}
></textarea>
</div>
</div>
<div className='modal-footer'>
<button
type='button'
className='btn btn-secondary'
onClick={handleCloseResponseInput}
disabled={approveRejectStatus === 'loading'}
>
取消
</button>
<button
type='button'
className={`btn ${isApproving ? 'btn-success' : 'btn-danger'}`}
onClick={handleProcessRequest}
disabled={approveRejectStatus === 'loading'}
>
{approveRejectStatus === 'loading' ? (
<>
<span
className='spinner-border spinner-border-sm me-1'
role='status'
aria-hidden='true'
></span>
处理中...
</>
) : isApproving ? (
'确认批准'
) : (
'确认拒绝'
)}
</button>
</div>
</div>
</div>
<div className='modal-backdrop fade show'></div>
</div>
)}
</>
);
}

View File

@ -0,0 +1,202 @@
import React from 'react';
import SvgIcon from '../../../components/SvgIcon';
export default function RequestDetailSlideOver({
show,
onClose,
request,
onApprove,
onReject,
processingId,
approveRejectStatus,
isApproving,
}) {
if (!request) return null;
//
const applicantName = request.applicant?.name || request.applicant?.username || '未知用户';
const applicantDept = request.applicant?.department || '未分配部门';
const applicantInitial = applicantName.charAt(0);
const knowledgeBaseName = request.knowledge_base?.name || '未知知识库';
const knowledgeBaseId = request.knowledge_base?.id || '';
const knowledgeBaseType = request.knowledge_base?.type || '';
//
const getKnowledgeBaseTypeText = (type) => {
switch (type) {
case 'admin':
return '公共知识库';
case 'member':
return '组内知识库';
case 'private':
return '私人知识库';
case 'leader':
return '组长级知识库';
case 'secret':
return '私密知识库';
default:
return '未知类型';
}
};
return (
<>
<div className={`slide-over-backdrop ${show ? 'show' : ''}`} onClick={onClose}></div>
<div className={`slide-over ${show ? 'show' : ''}`}>
<div className='slide-over-content'>
<div className='slide-over-header'>
<h5 className='mb-0'>申请详情</h5>
<button type='button' className='btn-close' onClick={onClose}></button>
</div>
<div className='slide-over-body'>
<div className='mb-4'>
<h6 className='text-muted mb-2'>申请人信息</h6>
<div className='d-flex align-items-center mb-3'>
<div className='avatar-placeholder me-3 bg-dark'>{applicantInitial}</div>
<div>
<h5 className='mb-1'>{applicantName}</h5>
<div className='text-muted'>{applicantDept}</div>
</div>
</div>
</div>
<div className='mb-4'>
<h6 className='text-muted mb-2'>知识库信息</h6>
<p className='mb-1'>
<strong>名称</strong> {knowledgeBaseName}
</p>
<p className='mb-1'>
<strong>ID</strong> {knowledgeBaseId}
</p>
{knowledgeBaseType && (
<p className='mb-1'>
<strong>类型</strong> {getKnowledgeBaseTypeText(knowledgeBaseType)}
</p>
)}
</div>
<div className='mb-4'>
<h6 className='text-muted mb-2'>申请权限</h6>
<div className='d-flex flex-wrap gap-2 mb-3'>
{request.permissions?.can_read && !request.permissions?.can_edit && (
<span className='badge bg-warning-subtle text-warning d-flex align-items-center gap-1'>
<SvgIcon className={'eye'} />
只读
</span>
)}
{request.permissions?.can_edit && (
<span className='badge bg-success-subtle text-success d-flex align-items-center gap-1'>
<SvgIcon className={'pencil'} />
编辑
</span>
)}
{request.permissions?.can_delete && (
<span className='badge bg-danger-subtle text-danger d-flex align-items-center gap-1'>
<SvgIcon className={'trash'} />
删除
</span>
)}
</div>
</div>
<div className='mb-4'>
<h6 className='text-muted mb-2'>申请时间</h6>
<p className='mb-1'>{new Date(request.created_at || Date.now()).toLocaleString()}</p>
</div>
{request.expires_at && (
<div className='mb-4'>
<h6 className='text-muted mb-2'>到期时间</h6>
<p className='mb-1'>{new Date(request.expires_at).toLocaleString()}</p>
</div>
)}
{request.status && (
<div className='mb-4'>
<h6 className='text-muted mb-2'>申请状态</h6>
<p className='mb-1'>
{request.status === 'pending' ? (
<span className='badge bg-warning'>待处理</span>
) : request.status === 'approved' ? (
<span className='badge bg-success'>已批准</span>
) : (
<span className='badge bg-danger'>已拒绝</span>
)}
</p>
</div>
)}
{request.approver && (
<div className='mb-4'>
<h6 className='text-muted mb-2'>审批人</h6>
<p className='mb-1'>
{request.approver.name || request.approver.username} (
{request.approver.department || '未分配部门'})
</p>
</div>
)}
{request.response_message && (
<div className='mb-4'>
<h6 className='text-muted mb-2'>审批意见</h6>
<div className='p-3 bg-light rounded'>{request.response_message}</div>
</div>
)}
<div className='mb-4'>
<h6 className='text-muted mb-2'>申请理由</h6>
<div className='p-3 bg-light rounded'>{request.reason || '无申请理由'}</div>
</div>
</div>
{request.status === 'pending' && request.role === 'approver' ? (
<div className='slide-over-footer'>
<button
className='btn btn-outline-danger me-2'
onClick={() => onReject(request.id)}
disabled={processingId === request.id && approveRejectStatus === 'loading'}
>
{processingId === request.id && approveRejectStatus === 'loading' && !isApproving ? (
<>
<span
className='spinner-border spinner-border-sm me-1'
role='status'
aria-hidden='true'
></span>
处理中...
</>
) : (
<>拒绝</>
)}
</button>
<button
className='btn btn-outline-success'
onClick={() => onApprove(request.id)}
disabled={processingId === request.id && approveRejectStatus === 'loading'}
>
{processingId === request.id && approveRejectStatus === 'loading' && isApproving ? (
<>
<span
className='spinner-border spinner-border-sm me-1'
role='status'
aria-hidden='true'
></span>
处理中...
</>
) : (
<>批准</>
)}
</button>
</div>
) : request.status === 'pending' ? (
<div className='slide-over-footer'>
<div className='alert alert-warning mb-0'>
<span className='badge bg-warning-subtle text-warning me-2'>待处理</span>
等待审批人处理
</div>
</div>
) : null}
</div>
</div>
</>
);
}

View File

@ -0,0 +1,300 @@
import React, { useState, useEffect } from 'react';
import { get, post } from '../../../services/api';
export default function UserPermissionDetails({ user, onClose, onSave }) {
const [updatedPermissions, setUpdatedPermissions] = useState({});
const [userPermissions, setUserPermissions] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [savingPermissions, setSavingPermissions] = useState(false);
const [successMessage, setSuccessMessage] = useState(null);
useEffect(() => {
// If we already have the permissions data in the user object, use it
if (user.permissions && Array.isArray(user.permissions)) {
setUserPermissions(user.permissions);
setLoading(false);
}
}, [user]);
const handlePermissionChange = (knowledgeBaseId, newPermissionType) => {
setUpdatedPermissions((prev) => ({
...prev,
[knowledgeBaseId]: newPermissionType,
}));
};
const handleSave = async () => {
setSavingPermissions(true);
setError(null);
setSuccessMessage(null);
try {
const permissionUpdates = Object.entries(updatedPermissions).map(
async ([knowledgeBaseId, permissionType]) => {
const permissions = {
can_read: permissionType !== 'none',
can_edit: ['edit', 'admin'].includes(permissionType),
can_delete: permissionType === 'admin',
};
const requestBody = {
user_id: user.user_info.id,
knowledge_base_id: knowledgeBaseId,
permissions: permissions,
// Optional expiration date - can be added if needed
// expires_at: "2025-12-31T23:59:59Z"
};
try {
const response = await post('/permissions/update_permission/', requestBody);
return response;
} catch (err) {
throw new Error(`更新知识库 ${knowledgeBaseId} 权限失败: ${err.message || '未知错误'}`);
}
}
);
await Promise.all(permissionUpdates);
setSuccessMessage('权限更新成功');
// Reset updated permissions
setUpdatedPermissions({});
// Notify parent component if needed
if (onSave) {
onSave(user.user_info.id);
}
} catch (err) {
setError(err.message || '更新权限时发生错误');
} finally {
setSavingPermissions(false);
}
};
//
const getPermissionTypeText = (permissionType) => {
switch (permissionType) {
case 'none':
return '无权限';
case 'read':
return '只读访问';
case 'edit':
return '编辑权限';
case 'admin':
return '管理权限';
default:
return '未知权限';
}
};
//
const getPermissionType = (permission) => {
if (!permission) return 'none';
if (permission.can_delete) return 'admin';
if (permission.can_edit) return 'edit';
if (permission.can_read) return 'read';
return 'none';
};
return (
<div className='modal fade show' style={{ display: 'block' }} tabIndex='-1'>
<div className='modal-dialog modal-lg modal-dialog-scrollable' style={{ zIndex: 9999 }}>
<div className='modal-content'>
<div className='modal-header'>
<h5 className='modal-title'>{user.user_info.name} 的权限详情</h5>
<button type='button' className='btn-close' onClick={onClose}></button>
</div>
<div className='modal-body'>
{successMessage && (
<div className='alert alert-success alert-dismissible fade show' role='alert'>
{successMessage}
<button
type='button'
className='btn-close'
onClick={() => setSuccessMessage(null)}
></button>
</div>
)}
{loading ? (
<div className='text-center py-4'>
<div className='spinner-border' role='status'>
<span className='visually-hidden'>加载中...</span>
</div>
<p className='mt-3'>加载权限详情...</p>
</div>
) : error ? (
<div>
<div className='alert alert-warning' role='alert'>
{error}
</div>
{userPermissions.length > 0 && (
<div className='table-responsive'>
<table className='table table-hover'>
<thead className='table-light'>
<tr>
<th>知识库名称</th>
<th>所属部门</th>
<th>当前权限</th>
<th>最后访问时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{userPermissions.map((item) => {
const currentPermissionType = getPermissionType(item.permissions);
const updatedPermissionType =
updatedPermissions[item.knowledge_base.id] ||
currentPermissionType;
return (
<tr key={item.knowledge_base.id}>
<td>{item.knowledge_base.name}</td>
<td>{item.knowledge_base.department || '未指定'}</td>
<td>
<span
className={`badge ${
currentPermissionType === 'admin'
? 'bg-danger'
: currentPermissionType === 'edit'
? 'bg-success'
: currentPermissionType === 'read'
? 'bg-info'
: 'bg-secondary'
}`}
>
{getPermissionTypeText(currentPermissionType)}
</span>
</td>
<td>
{item.granted_at
? new Date(item.granted_at).toLocaleString()
: '从未访问'}
</td>
<td>
<select
className='form-select form-select-sm'
value={updatedPermissionType}
onChange={(e) =>
handlePermissionChange(
item.knowledge_base.id,
e.target.value
)
}
>
<option value='none'>无权限</option>
<option value='read'>只读访问</option>
<option value='edit'>编辑权限</option>
<option value='admin'>管理权限</option>
</select>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
) : userPermissions.length === 0 ? (
<div className='alert alert-info' role='alert'>
该用户暂无任何知识库权限
</div>
) : (
<div className='table-responsive'>
<table className='table table-hover'>
<thead className='table-light'>
<tr>
<th>知识库名称</th>
<th>所属部门</th>
<th>当前权限</th>
<th>授权时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{userPermissions.map((item) => {
const currentPermissionType = getPermissionType(item.permissions);
const updatedPermissionType =
updatedPermissions[item.knowledge_base.id] || currentPermissionType;
return (
<tr key={item.knowledge_base.id}>
<td>{item.knowledge_base.name}</td>
<td>{item.knowledge_base.department || '未指定'}</td>
<td>
<span
className={`badge ${
currentPermissionType === 'admin'
? 'bg-danger'
: currentPermissionType === 'edit'
? 'bg-success'
: currentPermissionType === 'read'
? 'bg-info'
: 'bg-secondary'
}`}
>
{getPermissionTypeText(currentPermissionType)}
</span>
</td>
<td>
{item.granted_at
? new Date(item.granted_at).toLocaleString()
: '未记录'}
</td>
<td>
<select
className='form-select form-select-sm'
value={updatedPermissionType}
onChange={(e) =>
handlePermissionChange(
item.knowledge_base.id,
e.target.value
)
}
>
<option value='none'>无权限</option>
<option value='read'>只读访问</option>
<option value='edit'>编辑权限</option>
<option value='admin'>管理权限</option>
</select>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
<div className='modal-footer'>
<button type='button' className='btn btn-secondary' onClick={onClose}>
关闭
</button>
<button
type='button'
className='btn btn-dark'
onClick={handleSave}
disabled={loading || savingPermissions || Object.keys(updatedPermissions).length === 0}
>
{savingPermissions ? (
<>
<span
className='spinner-border spinner-border-sm me-2'
role='status'
aria-hidden='true'
></span>
保存中...
</>
) : (
'保存更改'
)}
</button>
</div>
</div>
</div>
<div className='modal-backdrop fade show' style={{ zIndex: 1050 }}></div>
</div>
);
}

View File

@ -0,0 +1,210 @@
.search-box {
position: relative;
}
.search-input {
padding: 8px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
width: 240px;
font-size: 14px;
outline: none;
transition: all 0.3s;
}
.search-input:focus {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.user-permissions-table {
background-color: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.table-header {
display: flex;
background-color: #fafafa;
border-bottom: 1px solid #f0f0f0;
padding: 12px 16px;
font-weight: 600;
color: #333;
}
.header-cell {
flex: 1;
}
.header-cell:first-child {
flex: 1.5;
}
.table-row {
display: flex;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.3s;
}
.table-row:hover {
background-color: #fafafa;
}
.table-row:last-child {
border-bottom: none;
}
.cell {
flex: 1;
display: flex;
align-items: center;
}
.cell:first-child {
flex: 1.5;
}
.user-cell {
display: flex;
align-items: center;
gap: 12px;
}
.avatar-placeholder {
width: 36px;
height: 36px;
border-radius: 50%;
background-color: #1890ff;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 16px;
}
.user-info {
display: flex;
flex-direction: column;
}
.user-name {
font-weight: 500;
color: #333;
}
.user-username {
font-size: 12px;
color: #999;
}
.permission-badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.permission-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.permission-badge.read {
background-color: #e6f7ff;
color: #1890ff;
border: 1px solid #91d5ff;
}
.permission-badge.edit {
background-color: #f6ffed;
color: #52c41a;
border: 1px solid #b7eb8f;
}
.permission-badge.admin {
background-color: #fff2f0;
color: #ff4d4f;
border: 1px solid #ffccc7;
}
.action-cell {
justify-content: flex-end;
}
.btn-details {
background-color: transparent;
border: 1px solid #1890ff;
color: #1890ff;
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btn-details:hover {
background-color: #e6f7ff;
}
/* 分页控件样式 */
.pagination-container {
display: flex;
justify-content: center;
align-items: center;
margin-top: 20px;
padding: 10px 0;
}
.pagination-button {
background-color: #fff;
border: 1px solid #d9d9d9;
color: #333;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.pagination-button:hover:not(:disabled) {
border-color: #1890ff;
color: #1890ff;
}
.pagination-button:disabled {
color: #d9d9d9;
cursor: not-allowed;
}
.pagination-info {
margin: 0 15px;
color: #666;
font-size: 14px;
}
/* 页面大小选择器样式 */
.page-size-selector {
margin-left: 20px;
display: flex;
align-items: center;
}
.page-size-select {
padding: 5px 10px;
border: 1px solid #d9d9d9;
border-radius: 4px;
background-color: #fff;
font-size: 14px;
color: #333;
cursor: pointer;
outline: none;
transition: all 0.3s;
}
.page-size-select:hover, .page-size-select:focus {
border-color: #1890ff;
}

View File

@ -0,0 +1,373 @@
import React, { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchAllUserPermissions, updateUserPermissions } from '../../../store/permissions/permissions.thunks';
import UserPermissionDetails from './UserPermissionDetails';
import './UserPermissions.css';
import SvgIcon from '../../../components/SvgIcon';
//
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20, 50, 100];
export default function UserPermissions() {
const dispatch = useDispatch();
const [selectedUser, setSelectedUser] = useState(null);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all'); // 'all', 'pending', 'approved', 'rejected'
//
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
// Redux store
const {
results: users,
status: loading,
error,
total,
page,
page_size,
} = useSelector((state) => state.permissions.allUsersPermissions);
//
useEffect(() => {
dispatch(fetchAllUserPermissions({ page: currentPage, page_size: pageSize }));
}, [dispatch, currentPage, pageSize]);
//
const totalPages = Math.ceil(total / page_size);
const handleOpenDetailsModal = (data) => {
setSelectedUser(data);
setShowDetailsModal(true);
};
const handleCloseDetailsModal = () => {
setSelectedUser(null);
setShowDetailsModal(false);
};
const handleSavePermissions = async (userId) => {
try {
// Permission updates are now handled directly in the UserPermissionDetails component
// Just refresh the users list to reflect the updated permissions
handleCloseDetailsModal();
dispatch(fetchAllUserPermissions({ page: currentPage, page_size: pageSize }));
} catch (error) {
console.error('更新权限失败:', error);
}
};
const handleSearchChange = (e) => {
setSearchTerm(e.target.value);
setCurrentPage(1); //
};
const handleStatusFilterChange = (e) => {
setStatusFilter(e.target.value);
setCurrentPage(1); //
};
const handlePageChange = (page) => {
if (page > 0 && page <= totalPages) {
setCurrentPage(page);
}
};
const handlePageSizeChange = (e) => {
const newPageSize = parseInt(e.target.value);
setPageSize(newPageSize);
setCurrentPage(1); //
};
//
const getFilteredUsers = () => {
let filtered = users;
//
if (statusFilter !== 'all') {
filtered = filtered.filter((user) =>
user.permissions.some((permission) => permission.status === statusFilter)
);
}
//
if (searchTerm.trim()) {
filtered = filtered.filter(
(user) =>
user.user_info?.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.user_info?.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.user_info?.department.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.user_info?.role.toLowerCase().includes(searchTerm.toLowerCase())
);
}
return filtered;
};
const filteredUsers = getFilteredUsers();
const renderPagination = () => {
const pageNumbers = [];
const ellipsis = (
<li className='page-item disabled' key='ellipsis'>
<span className='page-link'>...</span>
</li>
);
let startPage = Math.max(1, currentPage - 2);
let endPage = Math.min(totalPages, startPage + 4);
if (endPage - startPage < 4) {
startPage = Math.max(1, endPage - 4);
}
//
if (startPage > 1) {
pageNumbers.push(
<li className={`page-item`} key={1}>
<button className='page-link' onClick={() => handlePageChange(1)}>
1
</button>
</li>
);
//
if (startPage > 2) {
pageNumbers.push(ellipsis);
}
}
//
for (let i = startPage; i <= endPage; i++) {
pageNumbers.push(
<li className={`page-item ${i === currentPage ? 'active' : ''}`} key={i}>
<button className='page-link' onClick={() => handlePageChange(i)}>
{i}
</button>
</li>
);
}
//
if (endPage < totalPages - 1) {
pageNumbers.push(ellipsis);
}
//
if (endPage < totalPages) {
pageNumbers.push(
<li className={`page-item`} key={totalPages}>
<button className='page-link' onClick={() => handlePageChange(totalPages)}>
{totalPages}
</button>
</li>
);
}
return (
<div className='d-flex justify-content-between align-items-center my-3'>
<div className='d-flex align-items-center'>
<span className='me-2'>每页显示:</span>
<select
className='form-select form-select-sm'
value={pageSize}
onChange={handlePageSizeChange}
style={{ width: 'auto' }}
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<span className='ms-3'>
总计 <strong>{total}</strong> 条记录
</span>
</div>
<nav aria-label='Page navigation'>
<ul className='pagination mb-0 dark-pagination'>
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
<button className='page-link' onClick={() => handlePageChange(currentPage - 1)}>
上一页
</button>
</li>
{pageNumbers}
<li className={`page-item ${currentPage === totalPages ? 'disabled' : ''}`}>
<button className='page-link' onClick={() => handlePageChange(currentPage + 1)}>
下一页
</button>
</li>
</ul>
</nav>
</div>
);
};
return (
<>
<div className='d-flex justify-content-between align-items-center mb-3'>
<h5 className='mb-0'>用户权限管理</h5>
</div>
<>
<div className='mb-3 d-flex justify-content-between align-items-center'>
<div className='d-flex gap-3'>
<div className='search-bar' style={{ maxWidth: '300px' }}>
<div className='input-group'>
<span className='input-group-text bg-light border-end-0'>
<SvgIcon className='search' />
</span>
<input
type='text'
className='form-control border-start-0'
placeholder='搜索用户...'
value={searchTerm}
onChange={handleSearchChange}
/>
</div>
</div>
<select
className='form-select'
value={statusFilter}
onChange={handleStatusFilterChange}
style={{ width: 'auto' }}
>
<option value='all'>全部状态</option>
<option value='pending'>待处理</option>
<option value='approved'>已批准</option>
<option value='rejected'>已拒绝</option>
</select>
</div>
</div>
{loading === 'loading' ? (
<div className='text-center my-5'>
<div className='spinner-border text-primary' role='status'>
<span className='visually-hidden'>加载中...</span>
</div>
<p className='mt-2'>加载用户权限列表...</p>
</div>
) : error ? (
<div className='alert alert-danger' role='alert'>
{error}
</div>
) : (
<>
<div className='table-responsive'>
<table className='table table-hover'>
<thead className='table-light'>
<tr>
<th>ID</th>
<th>用户名</th>
<th>姓名</th>
<th>部门</th>
<th>角色</th>
<th>权限类型</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{filteredUsers.length > 0 ? (
filteredUsers.map((userPermission) => (
<tr key={userPermission.user_info?.id}>
<td className='align-middle'>{userPermission.user_info?.id}</td>
<td className='align-middle'>
{userPermission.user_info?.username || 'N/A'}
</td>
<td className='align-middle'>
{userPermission.user_info?.name || 'N/A'}
</td>
<td className='align-middle'>
{userPermission.user_info?.department || 'N/A'}
</td>
<td className='align-middle'>
<span
className={`badge ${
userPermission.user_info?.role === 'admin'
? 'bg-danger-subtle text-danger'
: userPermission.user_info?.role === 'leader'
? 'bg-warning-subtle text-warning'
: 'bg-info-subtle text-info'
}`}
>
{userPermission.user_info?.role === 'admin'
? '管理员'
: userPermission.user_info?.role === 'leader'
? '组长'
: '成员'}
</span>
</td>
<td className='align-middle'>
<div className='d-flex flex-wrap gap-1'>
{userPermission.stats?.by_permission?.full_access > 0 && (
<span
className='badge
bg-success-subtle text-success
d-flex align-items-center gap-1'
>
完全访问:{' '}
{userPermission.stats?.by_permission?.full_access}
</span>
)}
{userPermission.stats?.by_permission?.read_only > 0 && (
<span
className='badge
bg-warning-subtle text-warning
d-flex align-items-center gap-1'
>
只读访问:{' '}
{userPermission.stats?.by_permission?.read_only}
</span>
)}
{userPermission.stats?.by_permission?.read_write > 0 && (
<span
className='badge
bg-info-subtle text-info
d-flex align-items-center gap-1'
>
读写权限:{' '}
{userPermission.stats?.by_permission?.read_write}
</span>
)}
</div>
</td>
<td className='align-middle'>
<button
className='btn btn-sm btn-outline-dark'
onClick={() => handleOpenDetailsModal(userPermission)}
>
查看权限
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan='7' className='text-center py-4'>
无匹配的用户记录
</td>
</tr>
)}
</tbody>
</table>
</div>
{totalPages > 1 && renderPagination()}
</>
)}
{showDetailsModal && selectedUser && (
<UserPermissionDetails
user={selectedUser}
onClose={handleCloseDetailsModal}
onSave={handleSavePermissions}
/>
)}
</>
</>
);
}

120
src/pages/auth/Login.jsx Normal file
View File

@ -0,0 +1,120 @@
import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Link, useNavigate } from 'react-router-dom';
import { checkAuthThunk, loginThunk } from '../../store/auth/auth.thunk';
import { showNotification } from '../../store/notification.slice';
export default function Login() {
const dispatch = useDispatch();
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState({});
const [submitted, setSubmitted] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { user } = useSelector((state) => state.auth);
useEffect(() => {
handleCheckAuth();
}, [dispatch]);
const handleCheckAuth = async () => {
console.log('login page handleCheckAuth');
try {
await dispatch(checkAuthThunk()).unwrap();
if (user) navigate('/');
} catch (error) {
//
}
};
const validateForm = () => {
const newErrors = {};
if (!username) {
newErrors.username = '请输入用户名';
}
if (!password) {
newErrors.password = '请输入密码';
} else if (password.length < 6) {
newErrors.password = '密码长度不能少于6个字符';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
setSubmitted(true);
if (validateForm()) {
setIsLoading(true);
try {
await dispatch(loginThunk({ username, password })).unwrap();
navigate('/');
} catch (error) {
// thunk
console.error('Login failed:', error);
} finally {
setIsLoading(false);
}
}
};
return (
<div className='position-absolute top-50 start-50 translate-middle d-flex flex-column gap-4 align-items-center'>
<div className='title text-center h1'>OOIN 达人智能知识库</div>
<form
className='auth-form login-form d-flex flex-column gap-3 align-items-center'
onSubmit={handleSubmit}
noValidate
>
<div className='input-group has-validation'>
<input
value={username}
type='text'
className={`form-control form-control-lg${submitted && errors.username ? ' is-invalid' : ''}`}
id='username'
placeholder='用户名'
required
onChange={(e) => setUsername(e.target.value.trim())}
></input>
{submitted && errors.username && <div className='invalid-feedback'>{errors.username}</div>}
</div>
<div className='input-group has-validation'>
<input
value={password}
type='password'
id='password'
placeholder='密码'
required
className={`form-control form-control-lg${submitted && errors.password ? ' is-invalid' : ''}`}
aria-describedby='passwordHelpBlock'
onChange={(e) => setPassword(e.target.value.trim())}
></input>
{submitted && errors.password && <div className='invalid-feedback'>{errors.password}</div>}
</div>
<Link to='#' className='find-password text-body-secondary d-none'>
忘记密码?
</Link>
<button type='submit' className='btn btn-dark btn-lg w-100' disabled={isLoading}>
{isLoading ? (
<>
<span
className='spinner-border spinner-border-sm me-2'
role='status'
aria-hidden='true'
></span>
登录中...
</>
) : (
'登录'
)}
</button>
</form>
<Link to='/signup' className='go-to-signup w-100 link-underline-light h5 text-center'>
没有账号去注册
</Link>
</div>
);
}

274
src/pages/auth/Signup.jsx Normal file
View File

@ -0,0 +1,274 @@
import React, { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Link, useNavigate } from 'react-router-dom';
import { checkAuthThunk, signupThunk } from '../../store/auth/auth.thunk';
//
const departmentGroups = {
达人部门: ['达人'],
商务部门: ['商务'],
样本中心: ['样本'],
产品部门: ['产品'],
AI自媒体: ['AI自媒体'],
HR: ['HR'],
技术部门: ['技术'],
};
export default function Signup() {
const dispatch = useDispatch();
const navigate = useNavigate();
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
name: '',
role: 'member',
department: '',
group: '',
});
const [errors, setErrors] = useState({});
const [submitted, setSubmitted] = useState(false);
const [availableGroups, setAvailableGroups] = useState([]);
const { user, loading } = useSelector((state) => state.auth);
useEffect(() => {
handleCheckAuth();
}, [dispatch]);
//
useEffect(() => {
if (formData.department && departmentGroups[formData.department]) {
setAvailableGroups(departmentGroups[formData.department]);
//
if (!departmentGroups[formData.department].includes(formData.group)) {
setFormData(prev => ({
...prev,
group: ''
}));
}
} else {
setAvailableGroups([]);
setFormData(prev => ({
...prev,
group: ''
}));
}
}, [formData.department]);
const handleCheckAuth = async () => {
console.log('signup page handleCheckAuth');
try {
await dispatch(checkAuthThunk()).unwrap();
if (user) navigate('/');
} catch (error) {}
};
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData({
...formData,
[name]: value,
});
//
if (errors[name]) {
setErrors({
...errors,
[name]: '',
});
}
};
const validateForm = () => {
const newErrors = {};
if (!formData.username) {
newErrors.username = 'Username is required';
}
if (!formData.email) {
newErrors.email = 'Email is required';
} else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(formData.email)) {
newErrors.email = 'Invalid email address';
}
if (!formData.password) {
newErrors.password = 'Password is required';
} else if (formData.password.length < 6) {
newErrors.password = 'Password must be at least 6 characters';
}
if (!formData.name) {
newErrors.name = 'Name is required';
}
if (!formData.department) {
newErrors.department = '请选择部门';
}
if (!formData.group) {
newErrors.group = '请选择组别';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
setSubmitted(true);
if (validateForm()) {
console.log('Form submitted successfully!');
console.log('Registration data:', formData);
try {
await dispatch(signupThunk(formData)).unwrap();
navigate('/login');
} catch (error) {
console.error('Signup failed:', error);
}
}
};
return (
<div className='position-absolute top-50 start-50 translate-middle d-flex flex-column gap-4 align-items-center'>
<div className='title text-center h1'>OOIN 达人智能知识库</div>
<form
className='auth-form login-form d-flex flex-column gap-3 align-items-center'
onSubmit={handleSubmit}
noValidate
>
<div className='input-group has-validation'>
<input
type='text'
className={`form-control form-control-lg${submitted && errors.username ? ' is-invalid' : ''}`}
id='username'
name='username'
placeholder='用户名'
value={formData.username}
required
onChange={handleInputChange}
disabled={loading}
></input>
{submitted && errors.username && <div className='invalid-feedback'>{errors.username}</div>}
</div>
<div className='input-group has-validation'>
<input
type='email'
className={`form-control form-control-lg${submitted && errors.email ? ' is-invalid' : ''}`}
id='email'
name='email'
placeholder='邮箱'
value={formData.email}
required
onChange={handleInputChange}
disabled={loading}
></input>
{submitted && errors.email && <div className='invalid-feedback'>{errors.email}</div>}
</div>
<div className='input-group has-validation'>
<input
type='password'
id='password'
name='password'
placeholder='密码'
value={formData.password}
required
className={`form-control form-control-lg${submitted && errors.password ? ' is-invalid' : ''}`}
aria-describedby='passwordHelpBlock'
onChange={handleInputChange}
disabled={loading}
></input>
{submitted && errors.password && <div className='invalid-feedback'>{errors.password}</div>}
</div>
<div className='input-group has-validation'>
<input
type='text'
className={`form-control form-control-lg${submitted && errors.name ? ' is-invalid' : ''}`}
id='name'
name='name'
placeholder='姓名'
value={formData.name}
required
onChange={handleInputChange}
disabled={loading}
></input>
{submitted && errors.name && <div className='invalid-feedback'>{errors.name}</div>}
</div>
<div className='input-group has-validation'>
<select
className={`form-select form-select-lg${submitted && errors.department ? ' is-invalid' : ''}`}
id='department'
name='department'
value={formData.department}
onChange={handleInputChange}
disabled={loading}
required
>
<option value='' disabled>
选择部门
</option>
{Object.keys(departmentGroups).map((dept, index) => (
<option key={index} value={dept}>
{dept}
</option>
))}
</select>
{submitted && errors.department && <div className='invalid-feedback'>{errors.department}</div>}
</div>
<div className='input-group has-validation'>
<select
className={`form-select form-select-lg${submitted && errors.group ? ' is-invalid' : ''}`}
id='group'
name='group'
value={formData.group}
onChange={handleInputChange}
disabled={loading || !formData.department}
required
>
<option value='' disabled>
{formData.department ? '选择组别' : '请先选择部门'}
</option>
{availableGroups.map((group, index) => (
<option key={index} value={group}>
{group}
</option>
))}
</select>
{submitted && errors.group && <div className='invalid-feedback'>{errors.group}</div>}
</div>
<div className='input-group'>
<select
className='form-select form-select-lg'
id='role'
name='role'
value={formData.role}
onChange={handleInputChange}
disabled={loading}
>
<option value='member'>普通成员</option>
<option value='leader'>组长</option>
<option value='admin'>管理员</option>
</select>
</div>
<button type='submit' className='btn btn-dark btn-lg w-100' disabled={loading}>
{loading ? (
<>
<span
className='spinner-border spinner-border-sm me-2'
role='status'
aria-hidden='true'
></span>
注册中...
</>
) : (
'注册'
)}
</button>
</form>
<Link to='/login' className='go-to-signup w-100 link-underline-light h5 text-center'>
已有账号立即登录
</Link>
</div>
);
}

View File

@ -0,0 +1,12 @@
import { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { Navigate, Outlet } from 'react-router-dom';
function ProtectedRoute() {
const { user } = useSelector((state) => state.auth);
return <Outlet />
// return !!user ? <Outlet /> : <Navigate to='/login' replace />;
}
export default ProtectedRoute;

92
src/router/router.jsx Normal file
View File

@ -0,0 +1,92 @@
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/Detail/KnowledgeBaseDetail';
import Chat from '../pages/Chat/Chat';
import PermissionsPage from '../pages/Permissions/PermissionsPage';
import Loading from '../components/Loading';
import Login from '../pages/Auth/Login';
import Signup from '../pages/Auth/Signup';
import ProtectedRoute from './protectedRoute';
import { useSelector } from 'react-redux';
import NotificationSnackbar from '../components/NotificationSnackbar';
function AppRouter() {
const { user } = useSelector((state) => state.auth);
// leader admin
const hasManagePermission = user && (user.role === 'leader' || user.role === 'admin');
return (
<Suspense fallback={<Loading />}>
<NotificationSnackbar />
<Routes>
<Route element={<ProtectedRoute />}>
<Route
path='/'
element={
<Mainlayout>
<KnowledgeBase />
</Mainlayout>
}
/>
<Route
path='/knowledge-base/:id'
element={
<Mainlayout>
<KnowledgeBaseDetail />
</Mainlayout>
}
/>
<Route
path='/knowledge-base/:id/:tab'
element={
<Mainlayout>
<KnowledgeBaseDetail />
</Mainlayout>
}
/>
<Route
path='/chat'
element={
<Mainlayout>
<Chat />
</Mainlayout>
}
/>
<Route
path='/chat/:knowledgeBaseId'
element={
<Mainlayout>
<Chat />
</Mainlayout>
}
/>
<Route
path='/chat/:knowledgeBaseId/:chatId'
element={
<Mainlayout>
<Chat />
</Mainlayout>
}
/>
{/* 权限管理页面路由 */}
<Route
path='/permissions'
element={
<Mainlayout>
<PermissionsPage />
</Mainlayout>
}
/>
</Route>
<Route path='/login' element={<Login />} />
<Route path='/signup' element={<Signup />} />
<Route path='*' element={<Navigate to={!!user ? '/' : '/login'} replace />} />
</Routes>
</Suspense>
);
}
export default AppRouter;

321
src/services/api.js Normal file
View File

@ -0,0 +1,321 @@
import axios from 'axios';
import CryptoJS from 'crypto-js';
import { mockGet, mockPost, mockPut, mockDelete } from './mockApi';
const secretKey = import.meta.env.VITE_SECRETKEY;
// API连接状态
let isServerDown = false;
let hasCheckedServer = false;
// Create Axios instance with base URL
const api = axios.create({
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) => {
console.error('Request error:', error);
return Promise.reject(error);
}
);
// Response Interceptor
api.interceptors.response.use(
(response) => {
// 如果成功收到响应,表示服务器正常工作
if (!hasCheckedServer) {
console.log('Server is up and running');
isServerDown = false;
hasCheckedServer = true;
}
return response;
},
(error) => {
// 处理服务器无法连接的情况
if (!error.response || error.code === 'ECONNABORTED' || error.message.includes('Network Error')) {
console.error('Server appears to be down. Switching to mock data.');
isServerDown = true;
hasCheckedServer = true;
}
// Handle errors in the response
if (error.response) {
// monitor /verify
if (error.response.status === 401 && error.config.url === '/auth/verify-token/') {
if (window.location.pathname !== '/login' && window.location.pathname !== '/signup') {
window.location.href = '/login';
}
}
// The request was made and the server responded with a status code
console.error('API Error Response:', error.response.status, error.response.data.message);
// alert(`Error: ${error.response.status} - ${error.response.data.message || 'Something went wrong'}`);
} else if (error.request) {
// The request was made but no response was received
console.error('API Error Request:', error.request);
// alert('Network error: No response from server');
} else {
// Something happened in setting up the request
console.error('API Error Message:', error.message);
// alert('Error: ' + error.message);
}
return Promise.reject(error); // Reject the promise
}
);
// 检查服务器状态
export const checkServerStatus = async () => {
try {
// await api.get('/health-check', { timeout: 3000 });
isServerDown = false;
hasCheckedServer = true;
console.log('Server connection established');
return true;
} catch (error) {
isServerDown = true;
hasCheckedServer = true;
console.error('Server connection failed, using mock data');
return false;
}
};
// 初始检查服务器状态
checkServerStatus();
// Define common HTTP methods with fallback to mock API
const get = async (url, params = {}) => {
try {
if (isServerDown) {
console.log(`[MOCK MODE] GET ${url}`);
return await mockGet(url, params);
}
const res = await api.get(url, { ...params });
return res.data;
} catch (error) {
if (!hasCheckedServer || (error.request && !error.response)) {
console.log(`Failed to connect to server. Falling back to mock API for GET ${url}`);
return await mockGet(url, params);
}
throw error;
}
};
// Handle POST requests for JSON data with fallback to mock API
const post = async (url, data, isMultipart = false) => {
try {
if (isServerDown) {
console.log(`[MOCK MODE] POST ${url}`);
return await mockPost(url, data);
}
const headers = isMultipart
? { 'Content-Type': 'multipart/form-data' } // For file uploads
: { 'Content-Type': 'application/json' }; // For JSON data
const res = await api.post(url, data, { headers });
return res.data;
} catch (error) {
if (!hasCheckedServer || (error.request && !error.response)) {
console.log(`Failed to connect to server. Falling back to mock API for POST ${url}`);
return await mockPost(url, data);
}
throw error;
}
};
// Handle PUT requests with fallback to mock API
const put = async (url, data) => {
try {
if (isServerDown) {
console.log(`[MOCK MODE] PUT ${url}`);
return await mockPut(url, data);
}
const res = await api.put(url, data, {
headers: { 'Content-Type': 'application/json' },
});
return res.data;
} catch (error) {
if (!hasCheckedServer || (error.request && !error.response)) {
console.log(`Failed to connect to server. Falling back to mock API for PUT ${url}`);
return await mockPut(url, data);
}
throw error;
}
};
// Handle DELETE requests with fallback to mock API
const del = async (url) => {
try {
if (isServerDown) {
console.log(`[MOCK MODE] DELETE ${url}`);
return await mockDelete(url);
}
const res = await api.delete(url);
return res.data;
} catch (error) {
if (!hasCheckedServer || (error.request && !error.response)) {
console.log(`Failed to connect to server. Falling back to mock API for DELETE ${url}`);
return await mockDelete(url);
}
throw error;
}
};
const upload = async (url, data) => {
try {
if (isServerDown) {
console.log(`[MOCK MODE] Upload ${url}`);
return await mockPost(url, data, true);
}
const axiosInstance = await axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'multipart/form-data',
},
});
const res = await axiosInstance.post(url, data);
return res.data;
} catch (error) {
if (!hasCheckedServer || (error.request && !error.response)) {
console.log(`Failed to connect to server. Falling back to mock API for Upload ${url}`);
return await mockPost(url, data, true);
}
throw error;
}
};
// 手动切换到模拟API为调试目的
export const switchToMockApi = () => {
isServerDown = true;
hasCheckedServer = true;
console.log('Manually switched to mock API');
};
// 手动切换回真实API
export const switchToRealApi = async () => {
// 重新检查服务器状态
const isServerUp = await checkServerStatus();
console.log(isServerUp ? 'Switched back to real API' : 'Server still down, continuing with mock API');
return isServerUp;
};
// Handle streaming requests
const streamRequest = async (url, data, onChunk, onError) => {
try {
if (isServerDown) {
console.log(`[MOCK MODE] STREAM ${url}`);
// 模拟流式响应
setTimeout(() => onChunk('{"code":200,"message":"partial","data":{"content":"这是模拟的","conversation_id":"mock-1234"}}'), 300);
setTimeout(() => onChunk('{"code":200,"message":"partial","data":{"content":"流式","conversation_id":"mock-1234"}}'), 600);
setTimeout(() => onChunk('{"code":200,"message":"partial","data":{"content":"响应","conversation_id":"mock-1234"}}'), 900);
setTimeout(() => onChunk('{"code":200,"message":"partial","data":{"content":"数据","conversation_id":"mock-1234","is_end":true}}'), 1200);
return { success: true, conversation_id: 'mock-1234' };
}
// 获取认证Token
const encryptedToken = sessionStorage.getItem('token') || '';
let token = '';
if (encryptedToken) {
token = CryptoJS.AES.decrypt(encryptedToken, secretKey).toString(CryptoJS.enc.Utf8);
}
// 使用fetch API进行流式请求
const response = await fetch(`/api${url}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Token ${token}` : '',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
// 获取响应体的reader
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let conversationId = null;
// 处理流式数据
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 解码并处理数据
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
// 按行分割并处理JSON
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 保留最后一行(可能不完整)
for (const line of lines) {
if (!line.trim()) continue;
try {
// 检查是否为SSE格式(data: {...})
let jsonStr = line;
if (line.startsWith('data:')) {
// 提取data:后面的JSON部分
jsonStr = line.substring(5).trim();
console.log('检测到SSE格式数据提取JSON:', jsonStr);
}
// 尝试解析JSON
const data = JSON.parse(jsonStr);
if (data.code === 200 && data.data && data.data.conversation_id) {
conversationId = data.data.conversation_id;
}
onChunk(jsonStr);
} catch (e) {
console.warn('Failed to parse JSON:', line, e);
}
}
}
return { success: true, conversation_id: conversationId };
} catch (error) {
console.error('Streaming request failed:', error);
if (onError) {
onError(error);
}
throw error;
}
};
// 权限相关API
export const applyPermission = (data) => {
return post('/permissions/', data);
};
export const updatePermission = (data) => {
return post('/permissions/update_permission/', data);
};
export const approvePermission = (permissionId) => {
return post(`/permissions/approve_permission/${permissionId}`);
};
export const rejectPermission = (permissionId) => {
return post(`/permissions/reject_permission/${permissionId}`);
};
export { get, post, put, del, upload, streamRequest };

1131
src/services/mockApi.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,56 @@
import { post } from './api';
/**
* 计算权限过期时间
* @param {string} duration - 权限持续时间 '一周', '一个月', '三个月', '六个月', '永久'
* @returns {string} - ISO 格式的日期字符串
*/
export const calculateExpiresAt = (duration) => {
const now = new Date();
switch (duration) {
case '一周':
now.setDate(now.getDate() + 7);
break;
case '一个月':
now.setMonth(now.getMonth() + 1);
break;
case '三个月':
now.setMonth(now.getMonth() + 3);
break;
case '六个月':
now.setMonth(now.getMonth() + 6);
break;
case '永久':
// 设置为较远的未来日期
now.setFullYear(now.getFullYear() + 10);
break;
default:
now.setDate(now.getDate() + 7);
}
return now.toISOString();
};
/**
* 申请知识库访问权限已废弃请使用store/knowledgeBase/knowledgeBase.thunks中的requestKnowledgeBaseAccess
* @deprecated 请使用Redux thunk版本
* @param {Object} requestData - 请求数据
* @param {string} requestData.id - 知识库ID
* @param {string} requestData.accessType - 访问类型 '只读访问', '编辑权限'
* @param {string} requestData.duration - 访问时长 '一周', '一个月'
* @param {string} requestData.reason - 申请原因
* @returns {Promise} - API 请求的 Promise
*/
export const legacyRequestKnowledgeBaseAccess = async (requestData) => {
const apiRequestData = {
knowledge_base: requestData.id,
permissions: {
can_read: true,
can_edit: requestData.accessType === '编辑权限',
can_delete: false,
},
reason: requestData.reason,
expires_at: calculateExpiresAt(requestData.duration),
};
return post('/permissions/', apiRequestData);
};

View File

@ -0,0 +1,13 @@
import { get, post } from "./api";
export const checkToken = async () => {
const response = await get("/check-token");
const { user, message } = response;
return user;
};
export const login = async (config) => {
const response = await post('login', config);
const {message, user} = response;
return user;
}

245
src/services/websocket.js Normal file
View File

@ -0,0 +1,245 @@
import { addNotification, markNotificationAsRead } from '../store/notificationCenter/notificationCenter.slice';
import store from '../store/store'; // 修改为默认导出
// 从环境变量获取 API URL
const API_URL = import.meta.env.VITE_API_URL || 'http://81.69.223.133:8008';
// 将 HTTP URL 转换为 WebSocket URL
const WS_BASE_URL = API_URL.replace(/^http/, 'ws').replace(/\/api\/?$/, '');
let socket = null;
let reconnectTimer = null;
let pingInterval = null;
let reconnectAttempts = 0; // 添加重连尝试计数器
const RECONNECT_DELAY = 5000; // 5秒后尝试重连
const PING_INTERVAL = 30000; // 30秒发送一次ping
const MAX_RECONNECT_ATTEMPTS = 3; // 最大重连尝试次数
/**
* 初始化WebSocket连接
* @returns {Promise<WebSocket>} WebSocket连接实例
*/
export const initWebSocket = () => {
return new Promise((resolve, reject) => {
// 如果已经有一个连接,先关闭它
if (socket && socket.readyState !== WebSocket.CLOSED) {
socket.close();
}
// 清除之前的定时器
if (reconnectTimer) clearTimeout(reconnectTimer);
if (pingInterval) clearInterval(pingInterval);
try {
// 从sessionStorage获取token
const encryptedToken = sessionStorage.getItem('token');
if (!encryptedToken) {
console.error('No token found, cannot connect to notification service');
reject(new Error('No token found'));
return;
}
const wsUrl = `${WS_BASE_URL}/ws/notifications?token=${encryptedToken}`;
console.log('WebSocket URL:', wsUrl);
socket = new WebSocket(wsUrl);
// 连接建立时的处理
socket.onopen = () => {
console.log('WebSocket connection established');
reconnectAttempts = 0; // 连接成功后重置重连计数器
// 订阅通知频道
subscribeToNotifications();
// 设置定时发送ping消息
pingInterval = setInterval(() => {
if (socket.readyState === WebSocket.OPEN) {
sendPing();
}
}, PING_INTERVAL);
resolve(socket);
};
// 接收消息的处理
socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleWebSocketMessage(data);
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
// 错误处理
socket.onerror = (error) => {
console.error('WebSocket error:', error);
reject(error);
};
// 连接关闭时的处理
socket.onclose = (event) => {
console.log(`WebSocket connection closed: ${event.code} ${event.reason}`);
// 清除ping定时器
if (pingInterval) clearInterval(pingInterval);
// 如果不是正常关闭,尝试重连
if (event.code !== 1000) {
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
reconnectAttempts++;
console.log(`WebSocket reconnection attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`);
reconnectTimer = setTimeout(() => {
console.log('Attempting to reconnect WebSocket...');
initWebSocket().catch((err) => {
console.error('Failed to reconnect WebSocket:', err);
});
}, RECONNECT_DELAY);
} else {
console.log('Maximum reconnection attempts reached. Giving up.');
}
}
};
} catch (error) {
console.error('Error initializing WebSocket:', error);
reject(error);
}
});
};
/**
* 订阅通知频道
*/
export const subscribeToNotifications = () => {
if (socket && socket.readyState === WebSocket.OPEN) {
const subscribeMessage = {
type: 'subscribe',
channel: 'notifications',
};
socket.send(JSON.stringify(subscribeMessage));
}
};
/**
* 发送ping消息保持连接活跃
*/
export const sendPing = () => {
if (socket && socket.readyState === WebSocket.OPEN) {
const pingMessage = {
type: 'ping',
};
socket.send(JSON.stringify(pingMessage));
}
};
/**
* 确认已读通知
* @param {string} notificationId 通知ID
*/
export const acknowledgeNotification = (notificationId) => {
if (socket && socket.readyState === WebSocket.OPEN) {
const ackMessage = {
type: 'acknowledge',
notification_id: notificationId,
};
socket.send(JSON.stringify(ackMessage));
// 使用 store.dispatch 替代 dispatch
store.dispatch(markNotificationAsRead(notificationId));
}
};
/**
* 关闭WebSocket连接
*/
export const closeWebSocket = () => {
if (socket) {
socket.close(1000, 'Normal closure');
socket = null;
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
};
/**
* 处理接收到的WebSocket消息
* @param {Object} data 解析后的消息数据
*/
const handleWebSocketMessage = (data) => {
switch (data.type) {
case 'connection_established':
console.log(`Connection established for user: ${data.user_id}`);
break;
case 'notification':
console.log('Received notification:', data);
// 将通知添加到Redux store
store.dispatch(addNotification(processNotification(data)));
break;
case 'pong':
console.log(`Received pong at ${data.timestamp}`);
break;
case 'error':
console.error(`WebSocket error: ${data.code} - ${data.message}`);
break;
default:
console.log('Received unknown message type:', data);
}
};
/**
* 处理通知数据转换为应用内通知格式
* @param {Object} data 通知数据
* @returns {Object} 处理后的通知数据
*/
const processNotification = (data) => {
const { data: notificationData } = data;
let icon = 'bi-info-circle';
if (notificationData.category === 'system') {
icon = 'bi-info-circle';
} else if (notificationData.category === 'permission') {
icon = 'bi-shield';
}
// 计算时间显示
const createdAt = new Date(notificationData.created_at);
const now = new Date();
const diffMs = now - createdAt;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
let timeDisplay;
if (diffMins < 60) {
timeDisplay = `${diffMins}分钟前`;
} else if (diffHours < 24) {
timeDisplay = `${diffHours}小时前`;
} else {
timeDisplay = `${diffDays}天前`;
}
return {
id: notificationData.id,
type: notificationData.category,
icon,
title: notificationData.title,
content: notificationData.content,
time: timeDisplay,
hasDetail: true,
isRead: notificationData.is_read,
created_at: notificationData.created_at,
metadata: notificationData.metadata || {},
};
};

View File

@ -0,0 +1,72 @@
import { createSlice } from '@reduxjs/toolkit';
import { checkAuthThunk, loginThunk, logoutThunk, signupThunk, updateProfileThunk } from './auth.thunk';
const setPending = (state) => {
state.loading = true;
state.error = null;
};
const setFulfilled = (state, action) => {
state.user = action.payload;
state.loading = false;
state.error = null;
};
const setRejected = (state, action) => {
state.error = action.payload;
state.loading = false;
};
const authSlice = createSlice({
name: 'auth',
initialState: {
loading: false,
error: null,
user: null,
},
reducers: {
login: (state, action) => {
state.user = action.payload;
},
logout: (state) => {
state.user = null;
state.error = null;
state.loading = false;
},
},
extraReducers: (builder) => {
builder
.addCase(checkAuthThunk.pending, setPending)
.addCase(checkAuthThunk.fulfilled, setFulfilled)
.addCase(checkAuthThunk.rejected, setRejected)
.addCase(loginThunk.pending, setPending)
.addCase(loginThunk.fulfilled, setFulfilled)
.addCase(loginThunk.rejected, setRejected)
.addCase(signupThunk.pending, setPending)
.addCase(signupThunk.fulfilled, setFulfilled)
.addCase(signupThunk.rejected, setRejected)
.addCase(updateProfileThunk.pending, setPending)
.addCase(updateProfileThunk.fulfilled, (state, action) => {
state.user = {
...state.user,
...action.payload,
};
state.loading = false;
})
.addCase(updateProfileThunk.rejected, setRejected)
.addCase(logoutThunk.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(logoutThunk.fulfilled)
.addCase(logoutThunk.rejected, setRejected);
},
});
export const { login, logout } = authSlice.actions;
const authReducer = authSlice.reducer;
export default authReducer;

View File

@ -0,0 +1,139 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { get, post, put } 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, data, code } = await post('/auth/login/', { username, password });
if (code !== 200) {
throw new Error(message);
}
if (!data) {
throw new Error(message);
}
const { token } = data;
// encrypt token
const encryptedToken = CryptoJS.AES.encrypt(token, secretKey).toString();
sessionStorage.setItem('token', encryptedToken);
return data;
} catch (error) {
const errorMessage = error.response?.data?.message || 'Something went wrong';
dispatch(
showNotification({
message: errorMessage,
type: 'danger',
})
);
return rejectWithValue(errorMessage);
}
}
);
export const signupThunk = createAsyncThunk('auth/signup', async (userData, { rejectWithValue, dispatch }) => {
try {
// 使用新的注册 API
const { data, message, code } = await post('/auth/register/', userData);
console.log('注册返回数据:', data);
if (code !== 200) {
throw new Error(message);
}
// 将 token 加密存储到 sessionStorage
const { token } = data;
if (token) {
const encryptedToken = CryptoJS.AES.encrypt(token, secretKey).toString();
sessionStorage.setItem('token', encryptedToken);
}
// 显示注册成功通知
dispatch(
showNotification({
message: '注册成功',
type: 'success',
})
);
return data;
} catch (error) {
const errorMessage = error.response?.data?.message || '注册失败,请稍后重试';
dispatch(
showNotification({
message: errorMessage,
type: 'danger',
})
);
return rejectWithValue(errorMessage);
}
});
export const checkAuthThunk = createAsyncThunk('auth/verify', async (_, { rejectWithValue, dispatch }) => {
try {
const { data, message } = await post('/auth/verify-token/');
const { user } = data;
if (!user) {
dispatch(logout());
throw new Error(message || 'No token found');
}
return user;
} catch (error) {
dispatch(logout());
return rejectWithValue(error.response?.data?.message || 'Token verification failed');
}
});
// Async thunk for logging out
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('/auth/logout/');
dispatch(logout());
} catch (error) {
const errorMessage = error.response?.data?.message || 'Log out failed';
dispatch(
showNotification({
message: errorMessage,
type: 'danger',
})
);
return rejectWithValue(errorMessage);
}
});
// 更新个人资料
export const updateProfileThunk = createAsyncThunk('auth/updateProfile', async (userData, { rejectWithValue, dispatch }) => {
try {
const { data, message, code } = await put('/users/profile/', userData);
if (code !== 200) {
throw new Error(message);
}
// 显示更新成功通知
dispatch(
showNotification({
message: '个人信息更新成功',
type: 'success',
})
);
return data;
} catch (error) {
const errorMessage = error.response?.data?.message || '更新失败,请稍后重试';
dispatch(
showNotification({
message: errorMessage,
type: 'danger',
})
);
return rejectWithValue(errorMessage);
}
});

View File

@ -0,0 +1,45 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { get, post } from '../../services/api';
/**
* 获取聊天消息
* @param {string} chatId - 聊天ID
*/
export const fetchMessages = createAsyncThunk('chat/fetchMessages', async (chatId, { rejectWithValue }) => {
try {
const response = await get(`/chat-history/${chatId}/messages/`);
// 处理返回格式
if (response && response.code === 200) {
return response.data.messages;
}
return response.data?.messages || [];
} catch (error) {
return rejectWithValue(error.response?.data?.message || 'Failed to fetch messages');
}
});
/**
* 发送聊天消息
* @param {Object} params - 消息参数
* @param {string} params.chatId - 聊天ID
* @param {string} params.content - 消息内容
*/
export const sendMessage = createAsyncThunk('chat/sendMessage', async ({ chatId, content }, { rejectWithValue }) => {
try {
const response = await post(`/chat-history/${chatId}/messages/`, {
content,
type: 'text',
});
// 处理返回格式
if (response && response.code === 200) {
return response.data;
}
return response.data || {};
} catch (error) {
return rejectWithValue(error.response?.data?.message || 'Failed to send message');
}
});

View File

@ -0,0 +1,354 @@
import { createSlice } from '@reduxjs/toolkit';
import {
fetchAvailableDatasets,
fetchChats,
createChat,
updateChat,
deleteChat,
createChatRecord,
fetchConversationDetail,
createConversation,
} from './chat.thunks';
import { fetchMessages, sendMessage } from './chat.messages.thunks';
// 初始状态
const initialState = {
// Chat history state
history: {
items: [],
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
},
// Chat session creation state
createSession: {
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
sessionId: null,
},
// Chat messages state
messages: {
items: [],
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
},
// Send message state
sendMessage: {
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
},
// 可用于聊天的知识库列表
availableDatasets: {
items: [],
status: 'idle',
error: null,
},
// 操作状态(创建、更新、删除)
operations: {
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
},
// 兼容旧版本的state结构
list: {
items: [],
total: 0,
page: 1,
page_size: 10,
status: 'idle',
error: null,
},
// 当前聊天
currentChat: {
data: null,
status: 'idle',
error: null,
},
};
// 创建 slice
const chatSlice = createSlice({
name: 'chat',
initialState,
reducers: {
// 重置操作状态
resetOperationStatus: (state) => {
state.operations.status = 'idle';
state.operations.error = null;
},
// 重置当前聊天
resetCurrentChat: (state) => {
state.currentChat.data = null;
state.currentChat.status = 'idle';
state.currentChat.error = null;
},
// 设置当前聊天
setCurrentChat: (state, action) => {
state.currentChat.data = action.payload;
state.currentChat.status = 'succeeded';
},
// 重置消息状态
resetMessages: (state) => {
state.messages.items = [];
state.messages.status = 'idle';
state.messages.error = null;
},
// 重置发送消息状态
resetSendMessageStatus: (state) => {
state.sendMessage.status = 'idle';
state.sendMessage.error = null;
},
// 添加消息
addMessage: (state, action) => {
state.messages.items.push(action.payload);
},
// 更新消息(用于流式传输)
updateMessage: (state, action) => {
const { id, ...updates } = action.payload;
const messageIndex = state.messages.items.findIndex((msg) => msg.id === id);
if (messageIndex !== -1) {
// 更新现有消息
state.messages.items[messageIndex] = {
...state.messages.items[messageIndex],
...updates,
};
// 如果流式传输结束,更新发送消息状态
if (updates.is_streaming === false) {
state.sendMessage.status = 'succeeded';
}
}
},
},
extraReducers: (builder) => {
// 获取聊天列表
builder
.addCase(fetchChats.pending, (state) => {
state.list.status = 'loading';
state.history.status = 'loading';
})
.addCase(fetchChats.fulfilled, (state, action) => {
state.list.status = 'succeeded';
// 检查是否是追加模式
if (action.payload.append) {
// 追加模式:将新结果添加到现有列表的前面
state.list.items = [...action.payload.results, ...state.list.items];
state.history.items = [...action.payload.results, ...state.history.items];
} else {
// 替换模式:使用新结果替换整个列表
state.list.items = action.payload.results;
state.list.total = action.payload.total;
state.list.page = action.payload.page;
state.list.page_size = action.payload.page_size;
// 同时更新新的状态结构
state.history.items = action.payload.results;
}
state.history.status = 'succeeded';
state.history.error = null;
})
.addCase(fetchChats.rejected, (state, action) => {
state.list.status = 'failed';
state.list.error = action.payload || action.error.message;
// 同时更新新的状态结构
state.history.status = 'failed';
state.history.error = action.payload || action.error.message;
})
// 创建聊天
.addCase(createChat.pending, (state) => {
state.operations.status = 'loading';
})
.addCase(createChat.fulfilled, (state, action) => {
state.operations.status = 'succeeded';
state.list.items.unshift(action.payload);
state.list.total += 1;
state.currentChat.data = action.payload;
state.currentChat.status = 'succeeded';
})
.addCase(createChat.rejected, (state, action) => {
state.operations.status = 'failed';
state.operations.error = action.payload || action.error.message;
})
// 删除聊天
.addCase(deleteChat.pending, (state) => {
state.operations.status = 'loading';
})
.addCase(deleteChat.fulfilled, (state, action) => {
state.operations.status = 'succeeded';
// 更新旧的状态结构
state.list.items = state.list.items.filter((chat) => chat.id !== action.payload);
// 更新新的状态结构
state.history.items = state.history.items.filter((chat) => chat.conversation_id !== action.payload);
if (state.list.total > 0) {
state.list.total -= 1;
}
if (state.currentChat.data && state.currentChat.data.id === action.payload) {
state.currentChat.data = null;
}
})
.addCase(deleteChat.rejected, (state, action) => {
state.operations.status = 'failed';
state.operations.error = action.payload || action.error.message;
})
// 更新聊天
.addCase(updateChat.pending, (state) => {
state.operations.status = 'loading';
})
.addCase(updateChat.fulfilled, (state, action) => {
state.operations.status = 'succeeded';
const index = state.list.items.findIndex((chat) => chat.id === action.payload.id);
if (index !== -1) {
state.list.items[index] = action.payload;
}
if (state.currentChat.data && state.currentChat.data.id === action.payload.id) {
state.currentChat.data = action.payload;
}
})
.addCase(updateChat.rejected, (state, action) => {
state.operations.status = 'failed';
state.operations.error = action.payload || action.error.message;
})
// 获取聊天消息
.addCase(fetchMessages.pending, (state) => {
state.messages.status = 'loading';
state.messages.error = null;
})
.addCase(fetchMessages.fulfilled, (state, action) => {
state.messages.status = 'succeeded';
state.messages.items = action.payload;
})
.addCase(fetchMessages.rejected, (state, action) => {
state.messages.status = 'failed';
state.messages.error = action.error.message;
})
// 发送聊天消息
.addCase(sendMessage.pending, (state) => {
state.sendMessage.status = 'loading';
state.sendMessage.error = null;
})
.addCase(sendMessage.fulfilled, (state, action) => {
state.sendMessage.status = 'succeeded';
// 更新消息列表
const index = state.messages.items.findIndex(
(msg) => msg.content === action.payload.content && msg.sender === action.payload.sender
);
if (index === -1) {
state.messages.items.push(action.payload);
}
})
.addCase(sendMessage.rejected, (state, action) => {
state.sendMessage.status = 'failed';
state.sendMessage.error = action.error.message;
})
// 处理创建聊天记录
.addCase(createChatRecord.pending, (state) => {
state.sendMessage.status = 'loading';
state.sendMessage.error = null;
})
.addCase(createChatRecord.fulfilled, (state, action) => {
// 更新状态以反映聊天已创建
if (action.payload.conversation_id && !state.currentChat.data) {
// 设置当前聊天的会话ID
state.currentChat.data = {
conversation_id: action.payload.conversation_id,
// 其他信息将由流式更新填充
};
}
// 不再在这里添加消息因为消息已经在thunk函数中添加
})
.addCase(createChatRecord.rejected, (state, action) => {
state.sendMessage.status = 'failed';
state.sendMessage.error = action.error.message;
})
// 处理创建会话
.addCase(createConversation.pending, (state) => {
state.createSession.status = 'loading';
state.createSession.error = null;
})
.addCase(createConversation.fulfilled, (state, action) => {
state.createSession.status = 'succeeded';
state.createSession.sessionId = action.payload.conversation_id;
// 当前聊天设置 - 使用与fetchConversationDetail相同的数据结构
state.currentChat.data = {
conversation_id: action.payload.conversation_id,
datasets: action.payload.datasets || [],
// 添加其他必要的字段确保与fetchConversationDetail返回的数据结构兼容
messages: [],
create_time: new Date().toISOString(),
update_time: new Date().toISOString(),
};
state.currentChat.status = 'succeeded';
state.currentChat.error = null;
})
.addCase(createConversation.rejected, (state, action) => {
state.createSession.status = 'failed';
state.createSession.error = action.payload || action.error.message;
})
// 处理获取可用知识库
.addCase(fetchAvailableDatasets.pending, (state) => {
state.availableDatasets.status = 'loading';
state.availableDatasets.error = null;
})
.addCase(fetchAvailableDatasets.fulfilled, (state, action) => {
state.availableDatasets.status = 'succeeded';
state.availableDatasets.items = action.payload || [];
state.availableDatasets.error = null;
})
.addCase(fetchAvailableDatasets.rejected, (state, action) => {
state.availableDatasets.status = 'failed';
state.availableDatasets.error = action.payload || '获取可用知识库失败';
})
// 获取会话详情
.addCase(fetchConversationDetail.pending, (state) => {
state.currentChat.status = 'loading';
state.currentChat.error = null;
})
.addCase(fetchConversationDetail.fulfilled, (state, action) => {
if (action.payload) {
state.currentChat.status = 'succeeded';
state.currentChat.data = action.payload;
} else {
state.currentChat.status = 'idle';
state.currentChat.data = null;
}
})
.addCase(fetchConversationDetail.rejected, (state, action) => {
state.currentChat.status = 'failed';
state.currentChat.error = action.payload || action.error.message;
});
},
});
// 导出 actions
export const {
resetOperationStatus,
resetCurrentChat,
setCurrentChat,
resetMessages,
resetSendMessageStatus,
addMessage,
updateMessage,
} = chatSlice.actions;
// 导出 reducer
export default chatSlice.reducer;

View File

@ -0,0 +1,491 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { get, post, put, del, streamRequest } from '../../services/api';
import { showNotification } from '../notification.slice';
import { addMessage, updateMessage, setCurrentChat } from './chat.slice';
/**
* 获取聊天列表
* @param {Object} params - 查询参数
* @param {number} params.page - 页码
* @param {number} params.page_size - 每页数量
*/
export const fetchChats = createAsyncThunk('chat/fetchChats', async (params = {}, { rejectWithValue }) => {
try {
const response = await get('/chat-history/', { params });
// 处理返回格式
if (response && response.code === 200) {
return {
results: response.data.results,
total: response.data.total,
page: response.data.page || 1,
page_size: response.data.page_size || 10,
};
}
return { results: [], total: 0, page: 1, page_size: 10 };
} catch (error) {
console.error('Error fetching chats:', error);
return rejectWithValue(error.response?.data?.message || 'Failed to fetch chats');
}
});
/**
* 创建新聊天
* @param {Object} chatData - 聊天数据
* @param {string} chatData.knowledge_base_id - 知识库ID
* @param {string} chatData.title - 聊天标题
*/
export const createChat = createAsyncThunk('chat/createChat', async (chatData, { rejectWithValue }) => {
try {
const response = await post('/chat-history/', chatData);
// 处理返回格式
if (response && response.code === 200) {
return response.data.chat;
}
return response.data?.chat || {};
} catch (error) {
return rejectWithValue(error.response?.data?.message || 'Failed to create chat');
}
});
/**
* 更新聊天
* @param {Object} params - 更新参数
* @param {string} params.id - 聊天ID
* @param {Object} params.data - 更新数据
*/
export const updateChat = createAsyncThunk('chat/updateChat', async ({ id, data }, { rejectWithValue }) => {
try {
const response = await put(`/chat-history/${id}/`, data);
// 处理返回格式
if (response && response.code === 200) {
return response.data.chat;
}
return response.data?.chat || {};
} catch (error) {
return rejectWithValue(error.response?.data?.message || 'Failed to update chat');
}
});
/**
* 删除聊天
* @param {string} conversationId - 聊天ID
*/
export const deleteChat = createAsyncThunk('chat/deleteChat', async (conversationId, { rejectWithValue }) => {
try {
const response = await del(`/chat-history/delete_conversation?conversation_id=${conversationId}`);
// 处理返回格式
if (response && response.code === 200) {
return conversationId;
}
return conversationId;
} catch (error) {
console.error('Error deleting chat:', error);
return rejectWithValue(error.response?.data?.message || '删除聊天失败');
}
});
/**
* 获取可用于聊天的知识库列表
*/
export const fetchAvailableDatasets = createAsyncThunk(
'chat/fetchAvailableDatasets',
async (_, { rejectWithValue }) => {
try {
const response = await get('/chat-history/available_datasets/');
if (response && response.code === 200) {
return response.data;
}
return rejectWithValue('获取可用知识库列表失败');
} catch (error) {
console.error('Error fetching available datasets:', error);
return rejectWithValue(error.response?.data?.message || '获取可用知识库列表失败');
}
}
);
/**
* 创建聊天记录
* @param {Object} params - 聊天参数
* @param {string[]} params.dataset_id_list - 知识库ID列表
* @param {string} params.question - 用户问题
* @param {string} params.conversation_id - 会话ID可选
*/
export const createChatRecord = createAsyncThunk(
'chat/createChatRecord',
async ({ question, conversation_id, dataset_id_list }, { dispatch, getState, rejectWithValue }) => {
try {
// 构建请求数据
const requestBody = {
question,
dataset_id_list,
conversation_id,
};
console.log('准备发送聊天请求:', requestBody);
// 先添加用户消息到聊天窗口
const userMessageId = Date.now().toString();
dispatch(
addMessage({
id: userMessageId,
role: 'user',
content: question,
created_at: new Date().toISOString(),
})
);
// 添加临时的助手消息(流式传输期间显示)
const assistantMessageId = (Date.now() + 1).toString();
dispatch(
addMessage({
id: assistantMessageId,
role: 'assistant',
content: '',
created_at: new Date().toISOString(),
is_streaming: true,
})
);
let finalMessage = '';
let conversationId = conversation_id;
// 使用流式请求函数处理
const result = await streamRequest(
'/chat-history/',
requestBody,
// 处理每个数据块
(chunkText) => {
try {
const data = JSON.parse(chunkText);
console.log('收到聊天数据块:', data);
if (data.code === 200) {
// 保存会话ID (无论消息类型只要找到会话ID就保存)
if (data.data && data.data.conversation_id && !conversationId) {
conversationId = data.data.conversation_id;
console.log('获取到会话ID:', conversationId);
}
// 处理各种可能的消息类型
const messageType = data.message;
// 处理部分内容更新
if ((messageType === 'partial' || messageType === '部分') && data.data) {
// 累加内容
if (data.data.content !== undefined) {
finalMessage += data.data.content;
console.log('累加内容:', finalMessage);
// 更新消息内容
dispatch(
updateMessage({
id: assistantMessageId,
content: finalMessage,
})
);
}
// 处理结束标志
if (data.data.is_end) {
console.log('检测到消息结束标志');
dispatch(
updateMessage({
id: assistantMessageId,
is_streaming: false,
})
);
}
}
// 处理开始流式传输的消息
else if (messageType === '开始流式传输' || messageType === 'start_streaming') {
console.log('开始流式传输会话ID:', data.data?.conversation_id);
}
// 处理完成消息
else if (
messageType === 'completed' ||
messageType === '完成' ||
messageType === 'end_streaming' ||
messageType === '结束流式传输'
) {
console.log('收到完成消息');
dispatch(
updateMessage({
id: assistantMessageId,
is_streaming: false,
})
);
}
// 其他类型的消息
else {
console.log('收到其他类型消息:', messageType);
// 如果有content字段也尝试更新
if (data.data && data.data.content !== undefined) {
finalMessage += data.data.content;
dispatch(
updateMessage({
id: assistantMessageId,
content: finalMessage,
})
);
}
}
} else {
console.warn('收到非成功状态码:', data.code, data.message);
}
} catch (error) {
console.error('解析或处理JSON失败:', error, '原始数据:', chunkText);
}
},
// 处理错误
(error) => {
console.error('流式请求错误:', error);
dispatch(
updateMessage({
id: assistantMessageId,
content: `错误: ${error.message || '请求失败'}`,
is_streaming: false,
})
);
}
);
// 确保流式传输结束后标记消息已完成
dispatch(
updateMessage({
id: assistantMessageId,
is_streaming: false,
})
);
// 返回会话信息
const chatInfo = {
conversation_id: conversationId || result.conversation_id,
success: true,
};
// 如果聊天创建成功,添加到历史列表,并设置为当前激活聊天
if (chatInfo.conversation_id) {
// 获取知识库信息
const state = getState();
const availableDatasets = state.chat.availableDatasets.items || [];
const existingChats = state.chat.history.items || [];
// 检查是否已存在此会话ID的记录
const existingChat = existingChats.find((chat) => chat.conversation_id === chatInfo.conversation_id);
// 只有在不存在相同会话ID的记录时才创建新的记录
if (!existingChat) {
console.log('创建新的聊天sidebar记录:', chatInfo.conversation_id);
// 创建一个新的聊天记录对象添加到历史列表
const newChatEntry = {
conversation_id: chatInfo.conversation_id,
datasets: dataset_id_list.map((id) => {
// 尝试查找知识库名称
const formattedId = id.includes('-')
? id
: id.replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, '$1-$2-$3-$4-$5');
const dataset = availableDatasets.find((ds) => ds.id === formattedId);
return {
id: formattedId,
name: dataset?.name || '新知识库对话',
};
}),
create_time: new Date().toISOString(),
last_message: question,
message_count: 2, // 用户问题和助手回复
};
// 更新当前聊天
dispatch({
type: 'chat/fetchChats/fulfilled',
payload: {
results: [newChatEntry],
total: 1,
append: true, // 标记为追加,而不是替换
},
});
} else {
console.log('聊天sidebar记录已存在不再创建:', chatInfo.conversation_id);
}
// 设置为当前聊天
dispatch(
setCurrentChat({
conversation_id: chatInfo.conversation_id,
datasets: existingChat
? existingChat.datasets
: dataset_id_list.map((id) => {
const formattedId = id.includes('-')
? id
: id.replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, '$1-$2-$3-$4-$5');
const dataset = availableDatasets.find((ds) => ds.id === formattedId);
return {
id: formattedId,
name: dataset?.name || '新知识库对话',
};
}),
})
);
}
return chatInfo;
} catch (error) {
console.error('创建聊天记录失败:', error);
// 显示错误通知
dispatch(
showNotification({
message: `发送失败: ${error.message || '未知错误'}`,
type: 'danger',
})
);
return rejectWithValue(error.message || '创建聊天记录失败');
}
}
);
/**
* 获取会话详情
* @param {string} conversationId - 会话ID
*/
export const fetchConversationDetail = createAsyncThunk(
'chat/fetchConversationDetail',
async (conversationId, { rejectWithValue, dispatch, getState }) => {
try {
// 先检查是否是刚创建的会话
const state = getState();
const createSession = state.chat.createSession || {};
const currentChat = state.chat.currentChat.data;
// 如果是刚创建成功的会话且会话ID匹配则直接返回现有会话数据
if (
createSession.status === 'succeeded' &&
createSession.sessionId === conversationId &&
currentChat?.conversation_id === conversationId
) {
console.log('使用新创建的会话数据,跳过详情请求:', conversationId);
return currentChat;
}
const response = await get('/chat-history/conversation_detail', {
params: { conversation_id: conversationId },
});
if (response && response.code === 200) {
// 如果存在消息更新Redux状态
if (response.data.messages) {
dispatch({
type: 'chat/fetchMessages/fulfilled',
payload: response.data.messages,
});
}
return response.data;
}
return rejectWithValue('获取会话详情失败');
} catch (error) {
// 明确检查是否是404错误
const is404Error = error.response && error.response.status === 404;
if (is404Error) {
console.log('会话未找到,可能是新创建的会话:', conversationId);
return null;
}
console.error('Error fetching conversation detail:', error);
return rejectWithValue(error.response?.data?.message || '获取会话详情失败');
}
}
);
/**
* 创建新会话仅获取会话ID不发送消息
* @param {Object} params - 参数
* @param {string[]} params.dataset_id_list - 知识库ID列表
*/
export const createConversation = createAsyncThunk(
'chat/createConversation',
async ({ dataset_id_list }, { dispatch, getState, rejectWithValue }) => {
try {
console.log('创建新会话知识库ID列表:', dataset_id_list);
const params = {
dataset_id_list: dataset_id_list,
};
const response = await post('/chat-history/create_conversation/', params);
if (response && response.code === 200) {
const conversationData = response.data;
console.log('会话创建成功:', conversationData);
// 获取知识库信息
const state = getState();
const availableDatasets = state.chat.availableDatasets.items || [];
// 创建一个新的聊天记录对象添加到历史列表
const newChatEntry = {
conversation_id: conversationData.conversation_id,
datasets: dataset_id_list.map((id) => {
// 尝试查找知识库名称
const formattedId = id.includes('-')
? id
: id.replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, '$1-$2-$3-$4-$5');
const dataset = availableDatasets.find((ds) => ds.id === formattedId);
return {
id: formattedId,
name: dataset?.name || '新知识库对话',
};
}),
create_time: new Date().toISOString(),
last_message: '',
message_count: 0,
};
// 更新聊天历史列表
dispatch({
type: 'chat/fetchChats/fulfilled',
payload: {
results: [newChatEntry],
total: 1,
append: true, // 标记为追加,而不是替换
},
});
// 设置为当前聊天
dispatch(
setCurrentChat({
conversation_id: conversationData.conversation_id,
datasets: newChatEntry.datasets,
})
);
return conversationData;
}
return rejectWithValue('创建会话失败');
} catch (error) {
console.error('创建会话失败:', error);
// 显示错误通知
dispatch(
showNotification({
message: `创建会话失败: ${error.message || '未知错误'}`,
type: 'danger',
})
);
return rejectWithValue(error.message || '创建会话失败');
}
}
);

View File

@ -0,0 +1,269 @@
import { createSlice } from '@reduxjs/toolkit';
import {
fetchKnowledgeBases,
createKnowledgeBase,
updateKnowledgeBase,
deleteKnowledgeBase,
changeKnowledgeBaseType,
searchKnowledgeBases,
requestKnowledgeBaseAccess,
getKnowledgeBaseById,
uploadDocument,
getKnowledgeBaseDocuments,
deleteKnowledgeBaseDocument,
} from './knowledgeBase.thunks';
const initialState = {
knowledgeBases: [],
currentKnowledgeBase: null,
searchResults: [],
searchLoading: false,
loading: false,
error: null,
pagination: {
total: 0,
page: 1,
page_size: 10,
total_pages: 1,
},
batchPermissions: {},
batchLoading: false,
editStatus: 'idle',
requestAccessStatus: 'idle',
uploadStatus: 'idle',
documents: {
items: [],
loading: false,
error: null,
pagination: {
total: 0,
page: 1,
page_size: 10,
},
},
};
const knowledgeBaseSlice = createSlice({
name: 'knowledgeBase',
initialState,
reducers: {
clearCurrentKnowledgeBase: (state) => {
state.currentKnowledgeBase = null;
},
clearSearchResults: (state) => {
state.searchResults = [];
},
clearEditStatus: (state) => {
state.editStatus = 'idle';
},
},
extraReducers: (builder) => {
builder
// 获取知识库列表
.addCase(fetchKnowledgeBases.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchKnowledgeBases.fulfilled, (state, action) => {
state.loading = false;
state.knowledgeBases = action.payload.items || [];
state.pagination = {
total: action.payload.total || 0,
page: action.payload.page || 1,
page_size: action.payload.page_size || 10,
total_pages: action.payload.total_pages || 1,
};
})
.addCase(fetchKnowledgeBases.rejected, (state, action) => {
state.loading = false;
state.error = action.payload || 'Failed to fetch knowledge bases';
})
// 创建知识库
.addCase(createKnowledgeBase.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(createKnowledgeBase.fulfilled, (state, action) => {
state.loading = false;
state.editStatus = 'successful';
// 不需要更新 knowledgeBases因为创建后会跳转到详情页
})
.addCase(createKnowledgeBase.rejected, (state, action) => {
state.loading = false;
state.error = action.payload || 'Failed to create knowledge base';
state.editStatus = 'failed';
})
// 更新知识库
.addCase(updateKnowledgeBase.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(updateKnowledgeBase.fulfilled, (state, action) => {
state.loading = false;
// 只更新基本信息保留其他属性如permissions等
if (state.currentKnowledgeBase) {
state.currentKnowledgeBase = {
...state.currentKnowledgeBase,
name: action.payload.name,
desc: action.payload.desc || action.payload.description,
description: action.payload.description || action.payload.desc,
type: action.payload.type || state.currentKnowledgeBase.type,
department: action.payload.department || state.currentKnowledgeBase.department,
group: action.payload.group || state.currentKnowledgeBase.group,
};
}
state.editStatus = 'successful';
})
.addCase(updateKnowledgeBase.rejected, (state, action) => {
state.loading = false;
state.error = action.payload || 'Failed to update knowledge base';
state.editStatus = 'failed';
})
// 删除知识库
.addCase(deleteKnowledgeBase.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(deleteKnowledgeBase.fulfilled, (state, action) => {
state.loading = false;
const deletedId = action.payload;
state.knowledgeBases = state.knowledgeBases.filter((kb) => kb.id !== deletedId);
if (state.pagination.total > 0) {
state.pagination.total -= 1;
state.pagination.total_pages = Math.ceil(state.pagination.total / state.pagination.page_size);
}
})
.addCase(deleteKnowledgeBase.rejected, (state, action) => {
state.loading = false;
state.error = action.payload || 'Failed to delete knowledge base';
})
// 修改知识库类型
.addCase(changeKnowledgeBaseType.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(changeKnowledgeBaseType.fulfilled, (state, action) => {
state.loading = false;
if (state.currentKnowledgeBase) {
state.currentKnowledgeBase = {
...state.currentKnowledgeBase,
type: action.payload.type,
department: action.payload.department,
group: action.payload.group,
};
}
state.editStatus = 'successful';
})
.addCase(changeKnowledgeBaseType.rejected, (state, action) => {
state.loading = false;
state.error = action.payload || 'Failed to change knowledge base type';
state.editStatus = 'failed';
})
// 搜索知识库
.addCase(searchKnowledgeBases.pending, (state) => {
state.searchLoading = true;
state.error = null;
})
.addCase(searchKnowledgeBases.fulfilled, (state, action) => {
state.searchLoading = false;
if (action.payload && action.payload.code === 200) {
state.searchResults = action.payload.data.items || [];
} else {
state.searchResults = action.payload.items || [];
}
})
.addCase(searchKnowledgeBases.rejected, (state, action) => {
state.searchLoading = false;
state.error = action.payload || 'Failed to search knowledge bases';
})
// 申请知识库访问权限
.addCase(requestKnowledgeBaseAccess.pending, (state) => {
state.requestAccessStatus = 'loading';
})
.addCase(requestKnowledgeBaseAccess.fulfilled, (state) => {
state.requestAccessStatus = 'successful';
})
.addCase(requestKnowledgeBaseAccess.rejected, (state) => {
state.requestAccessStatus = 'failed';
})
// 获取知识库详情
.addCase(getKnowledgeBaseById.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(getKnowledgeBaseById.fulfilled, (state, action) => {
state.loading = false;
state.currentKnowledgeBase = action.payload.knowledge_base || action.payload;
})
.addCase(getKnowledgeBaseById.rejected, (state, action) => {
state.loading = false;
state.error = action.payload || 'Failed to get knowledge base details';
})
// 上传文档
.addCase(uploadDocument.pending, (state) => {
state.uploadStatus = 'loading';
})
.addCase(uploadDocument.fulfilled, (state) => {
state.uploadStatus = 'successful';
})
.addCase(uploadDocument.rejected, (state, action) => {
state.uploadStatus = 'failed';
state.error = action.payload || 'Failed to upload document';
})
// 获取知识库文档列表
.addCase(getKnowledgeBaseDocuments.pending, (state) => {
state.documents.loading = true;
state.documents.error = null;
})
.addCase(getKnowledgeBaseDocuments.fulfilled, (state, action) => {
state.documents.loading = false;
state.documents.items = action.payload.items || [];
state.documents.pagination = {
total: action.payload.total || 0,
page: 1,
page_size: 10,
};
console.log('文档数据已更新到store:', {
itemsCount: state.documents.items.length,
items: state.documents.items,
});
})
.addCase(getKnowledgeBaseDocuments.rejected, (state, action) => {
state.documents.loading = false;
state.documents.error = action.payload || 'Failed to get documents';
})
// 删除知识库文档
.addCase(deleteKnowledgeBaseDocument.pending, (state) => {
state.documents.loading = true;
state.documents.error = null;
})
.addCase(deleteKnowledgeBaseDocument.fulfilled, (state, action) => {
state.documents.loading = false;
const deletedDocId = action.payload;
state.documents.items = state.documents.items.filter(
(doc) => doc.document_id !== deletedDocId
);
if (state.documents.pagination.total > 0) {
state.documents.pagination.total -= 1;
}
})
.addCase(deleteKnowledgeBaseDocument.rejected, (state, action) => {
state.documents.loading = false;
state.documents.error = action.payload || 'Failed to delete document';
});
},
});
export const { clearCurrentKnowledgeBase, clearSearchResults, clearEditStatus } = knowledgeBaseSlice.actions;
export default knowledgeBaseSlice.reducer;

View File

@ -0,0 +1,333 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { get, post, put, del, upload } from '../../services/api';
import { showNotification } from '../notification.slice';
/**
* 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', { params: { page, page_size } });
// 处理新的返回格式
if (response.data && response.data.code === 200) {
return response.data.data;
}
return response.data;
} catch (error) {
console.log(error);
return rejectWithValue(error.response?.data.error.message || '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/search', async (params, { rejectWithValue }) => {
try {
const { keyword, page = 1, page_size = 10 } = params;
const response = await get('/knowledge-bases/search', {
params: {
keyword,
page,
page_size,
},
});
// 处理新的返回格式
if (response.data && response.data.code === 200) {
return response.data;
}
return response.data;
} catch (error) {
return rejectWithValue(error.response?.data?.message || error.message);
}
});
/**
* Create a new knowledge base
*/
export const createKnowledgeBase = createAsyncThunk(
'knowledgeBase/createKnowledgeBase',
async (knowledgeBaseData, { rejectWithValue }) => {
try {
const response = await post('/knowledge-bases/', knowledgeBaseData);
// 处理新的返回格式
if (response.data && response.data.code === 200) {
return response.data.data.knowledge_base;
}
return response.data;
} catch (error) {
return rejectWithValue(error.response?.data?.message || '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}/`);
// 处理新的返回格式
if (response.data && response.data.code === 200) {
return response.data.data;
}
return response.data;
} catch (error) {
return rejectWithValue(error.response?.data?.message || '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);
// 处理新的返回格式
if (response.data && response.data.code === 200) {
return response.data.data.knowledge_base;
}
return response.data;
} catch (error) {
return rejectWithValue(error.response?.data?.message || '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?.message || 'Failed to delete knowledge base');
}
}
);
/**
* Change knowledge base type
* @param {Object} params - Parameters
* @param {string} params.id - Knowledge base ID
* @param {string} params.type - New knowledge base type
* @param {string} params.department - User department
* @param {string} params.group - User group
*/
export const changeKnowledgeBaseType = createAsyncThunk(
'knowledgeBase/changeType',
async ({ id, type, department, group }, { rejectWithValue }) => {
try {
console.log(id, type, department, group);
const response = await post(`/knowledge-bases/${id}/change_type/`, {
type,
department,
group,
});
// 处理新的返回格式
if (response.data && response.data.code === 200) {
return response.data.data;
}
return response.data;
} catch (error) {
return rejectWithValue(error.response?.data?.message || '修改知识库类型失败');
}
}
);
/**
* 申请知识库访问权限
* @param {Object} params - 参数
* @param {string} params.knowledge_base - 知识库ID
* @param {Object} params.permissions - 权限对象
* @param {string} params.reason - 申请原因
* @param {string} params.expires_at - 过期时间
* @returns {Promise} - Promise对象
*/
export const requestKnowledgeBaseAccess = createAsyncThunk(
'knowledgeBase/requestAccess',
async (params, { rejectWithValue, dispatch }) => {
try {
const response = await post('/permissions/', params);
dispatch(
showNotification({
type: 'success',
message: '权限申请已发送,请等待管理员审核',
})
);
return response.data;
} catch (error) {
dispatch(
showNotification({
type: 'danger',
message: error.response?.data?.detail || '权限申请失败,请稍后重试',
})
);
return rejectWithValue(error.response?.data?.message || error.message);
}
}
);
/**
* Upload documents to a knowledge base
* @param {Object} params - Upload parameters
* @param {string} params.knowledge_base_id - Knowledge base ID
* @param {File[]} params.files - Files to upload
*/
export const uploadDocument = createAsyncThunk(
'knowledgeBase/uploadDocument',
async ({ knowledge_base_id, files }, { rejectWithValue, dispatch }) => {
try {
const formData = new FormData();
// 支持单文件和多文件上传
if (Array.isArray(files)) {
// 多文件上传
files.forEach(file => {
formData.append('files', file);
});
} else {
// 单文件上传(向后兼容)
formData.append('files', files);
}
const response = await post(`/knowledge-bases/${knowledge_base_id}/upload_document/`, formData, true);
// 处理新的返回格式
if (response.data && response.data.code === 200) {
const result = response.data.data;
// 使用API返回的消息作为通知
dispatch(
showNotification({
type: 'success',
message: response.data.message || `文档上传完成,成功: ${result.uploaded_count},失败: ${result.failed_count}`,
})
);
return result;
}
dispatch(
showNotification({
type: 'success',
message: Array.isArray(files)
? `${files.length} 个文档上传成功`
: `文档 ${files.name} 上传成功`,
})
);
return response.data;
} catch (error) {
const errorMessage = error.response?.data?.message || error.message || '文档上传失败';
dispatch(
showNotification({
type: 'danger',
message: errorMessage,
})
);
return rejectWithValue(errorMessage);
}
}
);
/**
* Get documents list for a knowledge base
* @param {Object} params - Parameters
* @param {string} params.knowledge_base_id - Knowledge base ID
*/
export const getKnowledgeBaseDocuments = createAsyncThunk(
'knowledgeBase/getDocuments',
async ({ knowledge_base_id }, { rejectWithValue }) => {
try {
console.log('获取知识库文档列表:', knowledge_base_id);
const { data, code } = await get(`/knowledge-bases/${knowledge_base_id}/documents`);
console.log('文档列表API响应:', { data, code });
// 处理返回格式
if (code === 200) {
console.log('API返回数据:', data);
return {
items: data || [],
total: (data || []).length
};
} else {
// 未知格式,尝试提取数据
const items = data?.items || data || [];
console.log('未识别格式,提取数据:', items);
return {
items: items,
total: items.length
};
}
} catch (error) {
console.error('获取知识库文档失败:', error);
return rejectWithValue(error.response?.data?.message || '获取文档列表失败');
}
}
);
/**
* Delete a document from a knowledge base
* @param {Object} params - Parameters
* @param {string} params.knowledge_base_id - Knowledge base ID
* @param {string} params.document_id - Document ID
*/
export const deleteKnowledgeBaseDocument = createAsyncThunk(
'knowledgeBase/deleteDocument',
async ({ knowledge_base_id, document_id }, { rejectWithValue, dispatch }) => {
try {
console.log(knowledge_base_id, document_id);
await del(`/knowledge-bases/${knowledge_base_id}/delete_document?document_id=${document_id}`);
dispatch(
showNotification({
type: 'success',
message: '文档删除成功',
})
);
return document_id;
} catch (error) {
dispatch(
showNotification({
type: 'danger',
message: error.response?.data?.message || '文档删除失败',
})
);
return rejectWithValue(error.response?.data?.message || '文档删除失败');
}
}
);

View File

@ -0,0 +1,14 @@
import { createSlice } from '@reduxjs/toolkit';
const notificationSlice = createSlice({
name: 'notification',
initialState: null, // type(success/primary/warning/danger), message, duration
reducers: {
showNotification: (state, action) => action.payload,
hideNotification: () => null,
},
});
export const { showNotification, hideNotification } = notificationSlice.actions;
const notificationReducer = notificationSlice.reducer;
export default notificationReducer;

View File

@ -0,0 +1,123 @@
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
notifications: [
{
id: 1,
type: 'permission',
icon: 'bi-shield',
title: '新的权限请求',
content: '张三请求访问销售数据集',
time: '10分钟前',
hasDetail: true,
isRead: false,
},
{
id: 2,
type: 'system',
icon: 'bi-info-circle',
title: '系统更新通知',
content: '系统将在今晚23:00进行例行维护',
time: '1小时前',
hasDetail: false,
isRead: false,
},
{
id: 3,
type: 'permission',
icon: 'bi-shield',
title: '新的权限请求',
content: '李四请求访问用户数据集',
time: '2小时前',
hasDetail: true,
isRead: false,
},
{
id: 4,
type: 'system',
icon: 'bi-exclamation-circle',
title: '安全提醒',
content: '检测到异常登录行为,请及时查看',
time: '3小时前',
hasDetail: true,
isRead: false,
},
{
id: 5,
type: 'permission',
icon: 'bi-shield',
title: '权限变更通知',
content: '管理员修改了您的数据访问权限',
time: '1天前',
hasDetail: true,
isRead: false,
},
],
unreadCount: 5,
isConnected: false,
};
const notificationCenterSlice = createSlice({
name: 'notificationCenter',
initialState,
reducers: {
clearNotifications: (state) => {
state.notifications = [];
state.unreadCount = 0;
},
addNotification: (state, action) => {
// 检查通知是否已存在
const exists = state.notifications.some((n) => n.id === action.payload.id);
if (!exists) {
// 将新通知添加到列表的开头
state.notifications.unshift(action.payload);
// 如果通知未读,增加未读计数
if (!action.payload.isRead) {
state.unreadCount += 1;
}
}
},
markNotificationAsRead: (state, action) => {
const notification = state.notifications.find((n) => n.id === action.payload);
if (notification && !notification.isRead) {
notification.isRead = true;
state.unreadCount = Math.max(0, state.unreadCount - 1);
}
},
markAllNotificationsAsRead: (state) => {
state.notifications.forEach((notification) => {
notification.isRead = true;
});
state.unreadCount = 0;
},
setWebSocketConnected: (state, action) => {
state.isConnected = action.payload;
},
removeNotification: (state, action) => {
const notificationIndex = state.notifications.findIndex((n) => n.id === action.payload);
if (notificationIndex !== -1) {
const notification = state.notifications[notificationIndex];
if (!notification.isRead) {
state.unreadCount = Math.max(0, state.unreadCount - 1);
}
state.notifications.splice(notificationIndex, 1);
}
},
},
});
export const {
clearNotifications,
addNotification,
markNotificationAsRead,
markAllNotificationsAsRead,
setWebSocketConnected,
removeNotification,
} = notificationCenterSlice.actions;
export default notificationCenterSlice.reducer;

View File

@ -0,0 +1,159 @@
import { createSlice } from '@reduxjs/toolkit';
import {
fetchUserPermissions,
updateUserPermissions,
fetchPermissionsThunk,
approvePermissionThunk,
rejectPermissionThunk,
fetchAllUserPermissions,
} from './permissions.thunks';
const initialState = {
users: {
items: [],
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
},
allUsersPermissions: {
results: [],
total: 0,
page: 1,
page_size: 10,
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
},
operations: {
status: 'idle',
error: null,
},
pending: {
results: [], // 更改为results
total: 0, // 添加total
page: 1, // 添加page
page_size: 10, // 添加page_size
status: 'idle',
error: null,
},
approveReject: {
status: 'idle',
error: null,
currentId: null,
},
};
const permissionsSlice = createSlice({
name: 'permissions',
initialState,
reducers: {
resetOperationStatus: (state) => {
state.operations.status = 'idle';
state.operations.error = null;
},
},
extraReducers: (builder) => {
builder
// 获取用户权限列表
.addCase(fetchUserPermissions.pending, (state) => {
state.users.status = 'loading';
state.users.error = null;
})
.addCase(fetchUserPermissions.fulfilled, (state, action) => {
state.users.status = 'succeeded';
state.users.items = action.payload;
})
.addCase(fetchUserPermissions.rejected, (state, action) => {
state.users.status = 'failed';
state.users.error = action.error.message;
})
// 更新用户权限
.addCase(updateUserPermissions.pending, (state) => {
state.operations.status = 'loading';
state.operations.error = null;
})
.addCase(updateUserPermissions.fulfilled, (state, action) => {
state.operations.status = 'succeeded';
// 更新用户列表中的权限信息
const index = state.users.items.findIndex((user) => user.id === action.payload.userId);
if (index !== -1) {
state.users.items[index] = {
...state.users.items[index],
permissions: action.payload.permissions,
};
}
})
.addCase(updateUserPermissions.rejected, (state, action) => {
state.operations.status = 'failed';
state.operations.error = action.error.message;
})
// 获取待处理申请列表
.addCase(fetchPermissionsThunk.pending, (state) => {
state.pending.status = 'loading';
state.pending.error = null;
})
.addCase(fetchPermissionsThunk.fulfilled, (state, action) => {
state.pending.status = 'succeeded';
state.pending.results = action.payload.results || [];
state.pending.total = action.payload.total || 0;
state.pending.page = action.payload.page || 1;
state.pending.page_size = action.payload.page_size || 10;
})
.addCase(fetchPermissionsThunk.rejected, (state, action) => {
state.pending.status = 'failed';
state.pending.error = action.error.message;
})
// 批准/拒绝权限申请
.addCase(approvePermissionThunk.pending, (state, action) => {
state.approveReject.status = 'loading';
state.approveReject.error = null;
state.approveReject.currentId = action.meta.arg.id;
})
.addCase(approvePermissionThunk.fulfilled, (state) => {
state.approveReject.status = 'succeeded';
state.approveReject.currentId = null;
})
.addCase(approvePermissionThunk.rejected, (state, action) => {
state.approveReject.status = 'failed';
state.approveReject.error = action.error.message;
state.approveReject.currentId = null;
})
.addCase(rejectPermissionThunk.pending, (state, action) => {
state.approveReject.status = 'loading';
state.approveReject.error = null;
state.approveReject.currentId = action.meta.arg.id;
})
.addCase(rejectPermissionThunk.fulfilled, (state) => {
state.approveReject.status = 'succeeded';
state.approveReject.currentId = null;
})
.addCase(rejectPermissionThunk.rejected, (state, action) => {
state.approveReject.status = 'failed';
state.approveReject.error = action.error.message;
state.approveReject.currentId = null;
})
// 获取所有用户及其权限列表
.addCase(fetchAllUserPermissions.pending, (state) => {
state.allUsersPermissions.status = 'loading';
state.allUsersPermissions.error = null;
})
.addCase(fetchAllUserPermissions.fulfilled, (state, action) => {
state.allUsersPermissions.status = 'succeeded';
state.allUsersPermissions.results = action.payload.results || [];
state.allUsersPermissions.total = action.payload.total || 0;
state.allUsersPermissions.page = action.payload.page || 1;
state.allUsersPermissions.page_size = action.payload.page_size || 10;
})
.addCase(fetchAllUserPermissions.rejected, (state, action) => {
state.allUsersPermissions.status = 'failed';
state.allUsersPermissions.error = action.payload || action.error.message;
});
},
});
export const { resetOperationStatus } = permissionsSlice.actions;
export default permissionsSlice.reducer;

View File

@ -0,0 +1,167 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { get, post, put } from '../../services/api';
import { showNotification } from '../notification.slice';
// 获取权限申请列表
export const fetchPermissionsThunk = createAsyncThunk(
'permissions/fetchPermissions',
async (params = {}, { rejectWithValue }) => {
try {
const response = await get('/permissions/', { params });
if (response && response.code === 200) {
return {
results: response.data.results || [],
total: response.data.total || 0,
page: response.data.page || 1,
page_size: response.data.page_size || 10,
};
}
return rejectWithValue('获取权限申请列表失败');
} catch (error) {
console.error('获取权限申请列表失败:', error);
return rejectWithValue(error.response?.data?.message || '获取权限申请列表失败');
}
}
);
// 批准权限申请
export const approvePermissionThunk = createAsyncThunk(
'permissions/approvePermission',
async ({ id, responseMessage }, { rejectWithValue }) => {
try {
const response = await post(`/permissions/${id}/approve/`, {
response_message: responseMessage || '已批准',
});
return response;
} catch (error) {
console.error('批准权限申请失败:', error);
return rejectWithValue('批准权限申请失败');
}
}
);
// 拒绝权限申请
export const rejectPermissionThunk = createAsyncThunk(
'permissions/rejectPermission',
async ({ id, responseMessage }, { rejectWithValue }) => {
try {
const response = await post(`/permissions/${id}/reject/`, {
response_message: responseMessage || '已拒绝',
});
return response;
} catch (error) {
console.error('拒绝权限申请失败:', error);
return rejectWithValue('拒绝权限申请失败');
}
}
);
// 生成模拟数据
const generateMockUsers = () => {
const users = [];
const userNames = [
{ username: 'zhangsan', name: '张三', department: '达人组', position: '达人对接' },
{ username: 'lisi', name: '李四', department: '达人组', position: '达人对接' },
{ username: 'wangwu', name: '王五', department: '达人组', position: '达人对接' },
{ username: 'zhaoliu', name: '赵六', department: '达人组', position: '达人对接' },
{ username: 'qianqi', name: '钱七', department: '达人组', position: '达人对接' },
{ username: 'sunba', name: '孙八', department: '达人组', position: '达人对接' },
{ username: 'zhoujiu', name: '周九', department: '达人组', position: '达人对接' },
{ username: 'wushi', name: '吴十', department: '达人组', position: '达人对接' },
];
for (let i = 1; i <= 20; i++) {
const randomUser = userNames[Math.floor(Math.random() * userNames.length)];
const hasAdminPermission = Math.random() > 0.8; // 20%的概率有管理员权限
const hasEditPermission = Math.random() > 0.5; // 50%的概率有编辑权限
const hasReadPermission = hasAdminPermission || hasEditPermission || Math.random() > 0.3; // 如果有管理员或编辑权限,一定有读取权限
users.push({
id: i.toString(),
username: randomUser.username,
name: randomUser.name,
department: randomUser.department,
position: randomUser.position,
permissions_count: {
read: hasReadPermission ? 1 : 0,
edit: hasEditPermission ? 1 : 0,
admin: hasAdminPermission ? 1 : 0,
},
});
}
return users;
};
// 获取用户权限列表
export const fetchUserPermissions = createAsyncThunk(
'permissions/fetchUserPermissions',
async (_, { rejectWithValue }) => {
try {
// 模拟API延迟
await new Promise((resolve) => setTimeout(resolve, 1000));
// 返回模拟数据
return generateMockUsers();
} catch (error) {
return rejectWithValue(error.message);
}
}
);
// 更新用户权限
export const updateUserPermissions = createAsyncThunk(
'permissions/updateUserPermissions',
async ({ userId, permissions }, { dispatch, rejectWithValue }) => {
try {
const response = await put(`/users/${userId}/permissions/`, { permissions });
if (response && response.code === 200) {
dispatch(
showNotification({
message: '权限更新成功',
type: 'success',
})
);
return {
userId,
permissions: response.data.permissions,
};
}
return rejectWithValue(response?.message || '更新权限失败');
} catch (error) {
dispatch(
showNotification({
message: error.message || '更新权限失败',
type: 'danger',
})
);
return rejectWithValue(error.message);
}
}
);
// 获取所有用户及其权限列表
export const fetchAllUserPermissions = createAsyncThunk(
'permissions/fetchAllUserPermissions',
async (params = {}, { rejectWithValue }) => {
try {
const response = await get('/permissions/all_permissions/', { params });
if (response && response.code === 200) {
return {
total: response.data.total,
page: response.data.page,
page_size: response.data.page_size,
results: response.data.results,
};
}
return rejectWithValue('获取用户权限列表失败');
} catch (error) {
console.error('获取用户权限列表失败:', error);
return rejectWithValue(error.response?.data?.message || '获取用户权限列表失败');
}
}
);

41
src/store/store.js Normal file
View File

@ -0,0 +1,41 @@
import { combineReducers, configureStore } from '@reduxjs/toolkit';
import { persistReducer, persistStore } from 'redux-persist';
import sessionStorage from 'redux-persist/lib/storage/session';
import notificationReducer from './notification.slice.js';
import authReducer from './auth/auth.slice.js';
import knowledgeBaseReducer from './knowledgeBase/knowledgeBase.slice.js';
import chatReducer from './chat/chat.slice.js';
import permissionsReducer from './permissions/permissions.slice.js';
import notificationCenterReducer from './notificationCenter/notificationCenter.slice.js';
const rootRducer = combineReducers({
auth: authReducer,
notification: notificationReducer,
knowledgeBase: knowledgeBaseReducer,
chat: chatReducer,
permissions: permissionsReducer,
notificationCenter: notificationCenterReducer,
});
const persistConfig = {
key: 'root',
storage: sessionStorage,
whitelist: ['auth'],
};
// Persist configuration
const persistedReducer = persistReducer(persistConfig, rootRducer);
const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false, // Disable serializable check for redux-persist
}),
devTools: true,
});
// Create the persistor to manage rehydrating the store
export const persistor = persistStore(store);
export default store;

12
src/styles/base.scss Normal file
View File

@ -0,0 +1,12 @@
// Import all of Bootstrap's CSS
@import 'bootstrap/scss/bootstrap';
ul,
li {
padding: 0;
margin: 0;
list-style: none;
}
a {
text-decoration: none;
}

446
src/styles/style.scss Normal file
View File

@ -0,0 +1,446 @@
@import 'bootstrap/scss/bootstrap';
#root {
min-width: 24rem;
}
.dropdown-toggle {
outline: 0;
}
.snackbar {
top: 6.5rem;
z-index: 9999;
}
/* Markdown styling in chat messages */
.markdown-content {
font-size: 0.95rem;
line-height: 1.6;
color: inherit;
/* Heading styles */
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 1rem;
margin-bottom: 0.75rem;
font-weight: 600;
}
h1 {
font-size: 1.5rem;
}
h2 {
font-size: 1.35rem;
}
h3 {
font-size: 1.2rem;
}
h4 {
font-size: 1.1rem;
}
h5,
h6 {
font-size: 1rem;
}
/* Paragraph spacing */
p {
margin-bottom: 0.75rem;
}
/* Lists */
ul,
ol {
padding-left: 1.5rem;
margin-bottom: 0.75rem;
}
/* Code blocks with syntax highlighting */
pre,
pre.prism-code {
margin: 0.5rem 0 !important;
padding: 0.75rem !important;
border-radius: 0.375rem !important;
font-size: 0.85rem !important;
line-height: 1.5 !important;
/* Improve readability on dark background */
code span {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
}
}
/* Add copy button positioning for future enhancements */
.code-block-container {
position: relative;
margin: 0.75rem 0;
}
/* Inline code */
code {
background-color: rgba(0, 0, 0, 0.05);
padding: 0.15rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.9rem;
}
/* Block quotes */
blockquote {
border-left: 3px solid #dee2e6;
padding-left: 1rem;
margin-left: 0;
color: #6c757d;
}
/* Links */
a {
color: #0d6efd;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
/* Tables */
table {
width: 100%;
margin-bottom: 0.75rem;
border-collapse: collapse;
th,
td {
padding: 0.5rem;
border: 1px solid #dee2e6;
}
th {
background-color: #f8f9fa;
}
}
/* Images */
img {
max-width: 100%;
height: auto;
border-radius: 0.25rem;
}
/* Horizontal rule */
hr {
margin: 1rem 0;
border: 0;
border-top: 1px solid #dee2e6;
}
}
/* Apply different text colors based on message background */
.bg-dark .markdown-content {
color: white;
code {
background-color: rgba(255, 255, 255, 0.1);
}
pre {
background-color: rgba(255, 255, 255, 0.1);
}
blockquote {
border-left-color: rgba(255, 255, 255, 0.3);
color: rgba(255, 255, 255, 0.8);
}
a {
color: #8bb9fe;
}
}
.knowledge-card {
min-width: 20rem;
cursor: pointer;
.hoverdown:hover .hoverdown-menu {
display: block;
color: red;
}
.hoverdown {
position: relative;
.hoverdown-menu {
display: none;
position: absolute;
z-index: 1;
.hoverdown-item {
width: max-content;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
border-radius: 4px;
color: $dark;
&:hover {
background-color: $gray-100;
}
}
}
}
}
.auth-form {
input {
min-width: 300px !important;
}
}
/* 自定义黑色系开关样式 */
.dark-switch .form-check-input {
border: 1px solid #dee2e6;
background-color: #fff; /* 关闭状态背景色 */
}
/* 关闭状态滑块 */
.dark-switch .form-check-input:not(:checked) {
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle fill='%23adb5bd' r='3'/></svg>");
}
/* 打开状态 */
.dark-switch .form-check-input:checked {
background-color: #000; /* 打开状态背景色 */
border-color: #000;
}
/* 打开状态滑块 */
.dark-switch .form-check-input:checked {
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle fill='%23fff' r='3'/></svg>");
}
/* 悬停效果 */
.dark-switch .form-check-input:hover {
filter: brightness(0.9);
}
/* 禁用状态 */
.dark-switch .form-check-input:disabled {
opacity: 0.5;
background-color: #e9ecef;
}
// 通知中心样式
.notification-item {
transition: background-color 0.2s ease;
&:hover {
background-color: $gray-100;
}
}
// 黑色主题的开关按钮
.form-check-input:checked {
background-color: $dark;
border-color: $dark;
}
/* 自定义分页样式 */
.dark-pagination {
margin: 0;
}
.dark-pagination .page-link {
color: #000; /* 默认文字颜色 */
background-color: #fff; /* 默认背景 */
border: 1px solid #dee2e6; /* 边框颜色 */
transition: all 0.3s ease; /* 平滑过渡效果 */
}
/* 激活状态 */
.dark-pagination .page-item.active .page-link {
background-color: #000 !important;
border-color: #000;
color: #fff !important;
}
/* 悬停状态 */
.dark-pagination .page-link:hover {
background-color: #f8f9fa; /* 浅灰背景 */
border-color: #adb5bd;
}
/* 禁用状态 */
.dark-pagination .page-item.disabled .page-link {
color: #6c757d !important;
background-color: #e9ecef !important;
border-color: #dee2e6;
pointer-events: none;
opacity: 0.7;
}
/* 自定义下拉框 */
.dark-select {
border: 1px solid #000 !important;
color: #000 !important;
}
.dark-select:focus {
box-shadow: 0 0 0 0.25rem rgba(0, 0, 0, 0.25); /* 黑色聚焦阴影 */
}
/* 下拉箭头颜色 */
.dark-select {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='%23000' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
}
/* Code Block Styles */
.code-block-container {
position: relative;
margin: 0.75rem 0;
border-radius: 0.375rem;
overflow: hidden;
background-color: #282c34; /* Dark background matching atomDark theme */
}
.code-block-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.375rem 0.75rem;
background-color: rgba(0, 0, 0, 0.2);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.code-language-badge {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.7);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.copy-button {
background: none;
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
padding: 0.125rem 0.375rem;
font-size: 0.75rem;
display: flex;
align-items: center;
gap: 0.25rem;
border-radius: 0.25rem;
transition: all 0.2s;
&:hover {
color: rgba(255, 255, 255, 0.9);
background-color: rgba(255, 255, 255, 0.1);
}
}
.copied-indicator {
color: #10b981; /* Green color for success */
display: flex;
align-items: center;
gap: 0.25rem;
}
/* Override the default SyntaxHighlighter styles */
.code-block-container pre {
margin: 0 !important;
border-radius: 0 !important; /* Remove rounded corners inside the container */
}
/* Markdown fallback styling */
.markdown-fallback {
font-size: 0.95rem;
.text-danger {
font-weight: 500;
}
pre {
white-space: pre-wrap;
word-break: break-word;
background-color: rgba(0, 0, 0, 0.05);
padding: 0.75rem;
border-radius: 0.375rem;
margin-top: 0.5rem;
}
}
/* Streaming message indicator */
.streaming-indicator {
display: inline-flex;
align-items: center;
margin-left: 5px;
.dot {
width: 6px;
height: 6px;
background-color: #6c757d;
border-radius: 50%;
margin: 0 2px;
animation: pulse 1.5s infinite ease-in-out;
&.dot1 {
animation-delay: 0s;
}
&.dot2 {
animation-delay: 0.3s;
}
&.dot3 {
animation-delay: 0.6s;
}
}
@keyframes pulse {
0%,
100% {
transform: scale(0.8);
opacity: 0.5;
}
50% {
transform: scale(1.2);
opacity: 1;
}
}
}
// SearchBar component styles
.search-input-group {
border: 1px solid #ced4da;
overflow: hidden;
&.rounded-pill {
border-radius: 50rem;
}
.search-input {
border: none;
box-shadow: none;
&:focus {
box-shadow: none;
}
}
.btn {
border: none;
background-color: transparent;
color: #6c757d;
&:hover,
&:active,
&:focus {
background-color: transparent;
color: #495057;
}
}
.search-button {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
}

48
src/utils/dateUtils.js Normal file
View File

@ -0,0 +1,48 @@
/**
* 格式化日期时间
* @param {string} dateString - 日期字符串
* @returns {string} 格式化后的日期字符串
*/
export const formatDate = (dateString) => {
if (!dateString) return '-';
const date = new Date(dateString);
// 检查日期是否有效
if (isNaN(date.getTime())) {
return dateString;
}
// 格式化为 YYYY-MM-DD HH:MM:SS
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
/**
* 格式化日期仅日期部分
* @param {string} dateString - 日期字符串
* @returns {string} 格式化后的日期字符串
*/
export const formatDateOnly = (dateString) => {
if (!dateString) return '-';
const date = new Date(dateString);
// 检查日期是否有效
if (isNaN(date.getTime())) {
return dateString;
}
// 格式化为 YYYY-MM-DD
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};

25
vite.config.js Normal file
View File

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