mirror of
https://github.com/Funkoala14/knowledgebase_influencer.git
synced 2025-06-07 10:38:14 +08:00
Initial commit
This commit is contained in:
commit
f4c1d03dc8
4
.env
Normal file
4
.env
Normal 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
4
.env.development
Normal 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
4
.env.production
Normal 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
2
.gitattributes
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
38
eslint.config.js
Normal file
38
eslint.config.js
Normal 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
13
index.html
Normal 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
6396
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
package.json
Normal file
42
package.json
Normal 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
61
src/App.jsx
Normal 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
1
src/assets/react.svg
Normal 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 |
232
src/components/AccessRequestModal.jsx
Normal file
232
src/components/AccessRequestModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
83
src/components/ApiModeSwitch.jsx
Normal file
83
src/components/ApiModeSwitch.jsx
Normal 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>
|
||||
);
|
||||
}
|
41
src/components/CodeBlock.jsx
Normal file
41
src/components/CodeBlock.jsx
Normal 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;
|
287
src/components/CreateKnowledgeBaseModal.jsx
Normal file
287
src/components/CreateKnowledgeBaseModal.jsx
Normal 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;
|
47
src/components/ErrorBoundary.jsx
Normal file
47
src/components/ErrorBoundary.jsx
Normal 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;
|
11
src/components/Loading.jsx
Normal file
11
src/components/Loading.jsx
Normal 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;
|
310
src/components/NotificationCenter.jsx
Normal file
310
src/components/NotificationCenter.jsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
22
src/components/NotificationSnackbar.jsx
Normal file
22
src/components/NotificationSnackbar.jsx
Normal 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;
|
59
src/components/Pagination.jsx
Normal file
59
src/components/Pagination.jsx
Normal 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;
|
50
src/components/SafeMarkdown.jsx
Normal file
50
src/components/SafeMarkdown.jsx
Normal 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;
|
215
src/components/SearchBar.jsx
Normal file
215
src/components/SearchBar.jsx
Normal 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;
|
41
src/components/Snackbar.jsx
Normal file
41
src/components/Snackbar.jsx
Normal 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;
|
46
src/components/SvgIcon.jsx
Normal file
46
src/components/SvgIcon.jsx
Normal 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] || ''),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
268
src/components/UserSettingsModal.jsx
Normal file
268
src/components/UserSettingsModal.jsx
Normal 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
125
src/icons/icons.js
Normal 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>`
|
||||
};
|
168
src/layouts/HeaderWithNav.jsx
Normal file
168
src/layouts/HeaderWithNav.jsx
Normal 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>
|
||||
);
|
||||
}
|
13
src/layouts/Mainlayout.jsx
Normal file
13
src/layouts/Mainlayout.jsx
Normal 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
22
src/main.jsx
Normal 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
179
src/pages/Chat/Chat.jsx
Normal 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>
|
||||
);
|
||||
}
|
134
src/pages/Chat/ChatSidebar.jsx
Normal file
134
src/pages/Chat/ChatSidebar.jsx
Normal 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>
|
||||
);
|
||||
}
|
403
src/pages/Chat/ChatWindow.jsx
Normal file
403
src/pages/Chat/ChatWindow.jsx
Normal 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([]);
|
||||
|
||||
// 当conversation或knowledgeBaseId更新时,更新selectedKnowledgeBaseIds
|
||||
useEffect(() => {
|
||||
// 优先使用conversation中的知识库列表
|
||||
if (conversation && conversation.datasets && conversation.datasets.length > 0) {
|
||||
const datasetIds = conversation.datasets.map((ds) => ds.id);
|
||||
console.log('从会话中获取知识库列表:', datasetIds);
|
||||
setSelectedKnowledgeBaseIds(datasetIds);
|
||||
}
|
||||
// 其次使用URL中传入的知识库ID
|
||||
else if (knowledgeBaseId) {
|
||||
// 可能是单个ID或以逗号分隔的多个ID
|
||||
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) {
|
||||
// 如果是新会话,使用当前选择的知识库
|
||||
// 可能是单个ID或以逗号分隔的多个ID
|
||||
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
256
src/pages/Chat/NewChat.jsx
Normal 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) {
|
||||
// 找到现有聊天记录,导航到该聊天页面
|
||||
// 使用所有知识库ID作为URL参数,以逗号分隔
|
||||
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) {
|
||||
// 使用所有知识库ID作为URL参数,以逗号分隔
|
||||
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>
|
||||
);
|
||||
}
|
386
src/pages/KnowledgeBase/Detail/DatasetTab.jsx
Normal file
386
src/pages/KnowledgeBase/Detail/DatasetTab.jsx
Normal 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'>«</span>
|
||||
</button>
|
||||
</li>
|
||||
<li className='page-item'>
|
||||
<button className='page-link' aria-label='Next'>
|
||||
<span aria-hidden='true'>»</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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
119
src/pages/KnowledgeBase/Detail/KnowledgeBaseDetail.jsx
Normal file
119
src/pages/KnowledgeBase/Detail/KnowledgeBaseDetail.jsx
Normal 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>
|
||||
);
|
||||
}
|
351
src/pages/KnowledgeBase/Detail/SettingsTab.jsx
Normal file
351
src/pages/KnowledgeBase/Detail/SettingsTab.jsx
Normal 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;
|
||||
}
|
||||
|
||||
// 所有用户都可以修改为admin或private类型,无需额外检查
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
34
src/pages/KnowledgeBase/Detail/components/Breadcrumb.jsx
Normal file
34
src/pages/KnowledgeBase/Detail/components/Breadcrumb.jsx
Normal 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;
|
@ -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;
|
222
src/pages/KnowledgeBase/Detail/components/DocumentList.jsx
Normal file
222
src/pages/KnowledgeBase/Detail/components/DocumentList.jsx
Normal 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;
|
@ -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;
|
293
src/pages/KnowledgeBase/Detail/components/FileUploadModal.jsx
Normal file
293
src/pages/KnowledgeBase/Detail/components/FileUploadModal.jsx
Normal 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;
|
283
src/pages/KnowledgeBase/Detail/components/KnowledgeBaseForm.jsx
Normal file
283
src/pages/KnowledgeBase/Detail/components/KnowledgeBaseForm.jsx
Normal 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;
|
@ -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;
|
53
src/pages/KnowledgeBase/KnowledgeBase.css
Normal file
53
src/pages/KnowledgeBase/KnowledgeBase.css
Normal 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%;
|
||||
}
|
||||
}
|
538
src/pages/KnowledgeBase/KnowledgeBase.jsx
Normal file
538
src/pages/KnowledgeBase/KnowledgeBase.jsx
Normal 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 = '请选择知识库类型';
|
||||
}
|
||||
|
||||
// 对于member和leader级别的知识库,检查是否选择了部门和组别
|
||||
if (needDepartmentAndGroup && !isPrivate) {
|
||||
// 管理员必须选择部门
|
||||
if (isAdmin && !newKnowledgeBase.department) {
|
||||
errors.department = `创建${newKnowledgeBase.type === 'leader' ? '组长级' : '组内'}知识库时必须选择部门`;
|
||||
}
|
||||
|
||||
// 所有用户创建member和leader级别知识库时必须选择组别
|
||||
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 {
|
||||
// 使用权限服务发送请求 - 通过dispatch调用thunk
|
||||
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>
|
||||
);
|
||||
}
|
41
src/pages/KnowledgeBase/components/KnowledgeBaseList.jsx
Normal file
41
src/pages/KnowledgeBase/components/KnowledgeBaseList.jsx
Normal 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;
|
121
src/pages/KnowledgeBase/components/KnowledgeCard.jsx
Normal file
121
src/pages/KnowledgeBase/components/KnowledgeCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
39
src/pages/Permissions/Permissions.css
Normal file
39
src/pages/Permissions/Permissions.css
Normal 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;
|
||||
}
|
24
src/pages/Permissions/PermissionsPage.jsx
Normal file
24
src/pages/Permissions/PermissionsPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
251
src/pages/Permissions/components/PendingRequests.css
Normal file
251
src/pages/Permissions/components/PendingRequests.css
Normal 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;
|
||||
}
|
470
src/pages/Permissions/components/PendingRequests.jsx
Normal file
470
src/pages/Permissions/components/PendingRequests.jsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
202
src/pages/Permissions/components/RequestDetailSlideOver.jsx
Normal file
202
src/pages/Permissions/components/RequestDetailSlideOver.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
300
src/pages/Permissions/components/UserPermissionDetails.jsx
Normal file
300
src/pages/Permissions/components/UserPermissionDetails.jsx
Normal 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>
|
||||
);
|
||||
}
|
210
src/pages/Permissions/components/UserPermissions.css
Normal file
210
src/pages/Permissions/components/UserPermissions.css
Normal 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;
|
||||
}
|
373
src/pages/Permissions/components/UserPermissions.jsx
Normal file
373
src/pages/Permissions/components/UserPermissions.jsx
Normal 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
120
src/pages/auth/Login.jsx
Normal 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
274
src/pages/auth/Signup.jsx
Normal 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>
|
||||
);
|
||||
}
|
12
src/router/protectedRoute.jsx
Normal file
12
src/router/protectedRoute.jsx
Normal 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
92
src/router/router.jsx
Normal 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
321
src/services/api.js
Normal 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
1131
src/services/mockApi.js
Normal file
File diff suppressed because it is too large
Load Diff
56
src/services/permissionService.js
Normal file
56
src/services/permissionService.js
Normal 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);
|
||||
};
|
13
src/services/userServices.js
Normal file
13
src/services/userServices.js
Normal 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
245
src/services/websocket.js
Normal 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 || {},
|
||||
};
|
||||
};
|
72
src/store/auth/auth.slice.js
Normal file
72
src/store/auth/auth.slice.js
Normal 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;
|
139
src/store/auth/auth.thunk.js
Normal file
139
src/store/auth/auth.thunk.js
Normal 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);
|
||||
}
|
||||
});
|
45
src/store/chat/chat.messages.thunks.js
Normal file
45
src/store/chat/chat.messages.thunks.js
Normal 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');
|
||||
}
|
||||
});
|
354
src/store/chat/chat.slice.js
Normal file
354
src/store/chat/chat.slice.js
Normal 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;
|
491
src/store/chat/chat.thunks.js
Normal file
491
src/store/chat/chat.thunks.js
Normal 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 || '创建会话失败');
|
||||
}
|
||||
}
|
||||
);
|
269
src/store/knowledgeBase/knowledgeBase.slice.js
Normal file
269
src/store/knowledgeBase/knowledgeBase.slice.js
Normal 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;
|
333
src/store/knowledgeBase/knowledgeBase.thunks.js
Normal file
333
src/store/knowledgeBase/knowledgeBase.thunks.js
Normal 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 || '文档删除失败');
|
||||
}
|
||||
}
|
||||
);
|
14
src/store/notification.slice.js
Normal file
14
src/store/notification.slice.js
Normal 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;
|
123
src/store/notificationCenter/notificationCenter.slice.js
Normal file
123
src/store/notificationCenter/notificationCenter.slice.js
Normal 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;
|
159
src/store/permissions/permissions.slice.js
Normal file
159
src/store/permissions/permissions.slice.js
Normal 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;
|
167
src/store/permissions/permissions.thunks.js
Normal file
167
src/store/permissions/permissions.thunks.js
Normal 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
41
src/store/store.js
Normal 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
12
src/styles/base.scss
Normal 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
446
src/styles/style.scss
Normal 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
48
src/utils/dateUtils.js
Normal 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
25
vite.config.js
Normal 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,
|
||||
},
|
||||
};
|
||||
});
|
Loading…
Reference in New Issue
Block a user