mirror of
https://github.com/Funkoala14/KnowledgeBase_OOIN.git
synced 2025-06-07 23:08:15 +08:00
[dev]knowledgebase & chat API test and changes
This commit is contained in:
parent
fbfff98123
commit
1ba460b4cf
30
src/App.jsx
30
src/App.jsx
@ -4,22 +4,50 @@ 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();
|
||||
console.log('user', user);
|
||||
if (!user) navigate('/login');
|
||||
} catch (error) {
|
||||
console.log('error', error);
|
||||
|
@ -1,6 +1,17 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import SvgIcon from './SvgIcon';
|
||||
|
||||
// 部门和组别的映射关系
|
||||
const departmentGroups = {
|
||||
技术部: ['开发组', '测试组', '运维组', '架构组', '安全组'],
|
||||
产品部: ['产品规划组', '用户研究组', '交互设计组', '项目管理组'],
|
||||
市场部: ['品牌推广组', '市场调研组', '客户关系组', '社交媒体组'],
|
||||
行政部: ['人事组', '财务组', '行政管理组', '后勤组'],
|
||||
};
|
||||
|
||||
// 部门列表
|
||||
const departments = Object.keys(departmentGroups);
|
||||
|
||||
/**
|
||||
* 创建知识库模态框组件
|
||||
* @param {Object} props
|
||||
@ -13,22 +24,38 @@ import SvgIcon from './SvgIcon';
|
||||
* @param {Function} props.onSubmit - 提交表单的回调函数
|
||||
* @param {Object} props.currentUser - 当前用户信息
|
||||
*/
|
||||
const CreateKnowledgeBaseModal = ({
|
||||
show,
|
||||
formData,
|
||||
formErrors,
|
||||
isSubmitting,
|
||||
onClose,
|
||||
onChange,
|
||||
const CreateKnowledgeBaseModal = ({
|
||||
show,
|
||||
formData,
|
||||
formErrors,
|
||||
isSubmitting,
|
||||
onClose,
|
||||
onChange,
|
||||
onSubmit,
|
||||
currentUser
|
||||
currentUser,
|
||||
}) => {
|
||||
if (!show) return null;
|
||||
|
||||
// 根据用户角色确定可以创建的知识库类型
|
||||
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) {
|
||||
@ -37,22 +64,24 @@ const CreateKnowledgeBaseModal = ({
|
||||
{ value: 'leader', label: 'Leader 级知识库' },
|
||||
{ value: 'member', label: 'Member 级知识库' },
|
||||
{ value: 'private', label: '私有知识库' },
|
||||
{ value: 'secret', label: '保密知识库' }
|
||||
{ value: 'secret', label: '保密知识库' },
|
||||
];
|
||||
} else if (isLeader) {
|
||||
return [
|
||||
{ value: 'member', label: 'Member 级知识库' },
|
||||
{ value: 'private', label: '私有知识库' }
|
||||
{ value: 'private', label: '私有知识库' },
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
{ value: 'private', label: '私有知识库' }
|
||||
];
|
||||
return [{ value: 'private', label: '私有知识库' }];
|
||||
}
|
||||
};
|
||||
|
||||
const availableTypes = getAvailableTypes();
|
||||
|
||||
// 判断是否需要选择组别
|
||||
const isMemberTypeSelected = formData.type === 'member';
|
||||
const needSelectGroup = isMemberTypeSelected;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='modal-backdrop'
|
||||
@ -145,32 +174,84 @@ const CreateKnowledgeBaseModal = ({
|
||||
)}
|
||||
{formErrors.type && <div className='text-danger small mt-1'>{formErrors.type}</div>}
|
||||
</div>
|
||||
<div className='mb-3'>
|
||||
<label htmlFor='department' className='form-label'>
|
||||
部门
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
className='form-control bg-light'
|
||||
id='department'
|
||||
name='department'
|
||||
value={formData.department || ''}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div className='mb-3'>
|
||||
<label htmlFor='group' className='form-label'>
|
||||
组别
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
className='form-control bg-light'
|
||||
id='group'
|
||||
name='group'
|
||||
value={formData.group || ''}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 仅当不是私有知识库时才显示部门选项 */}
|
||||
{formData.type !== 'private' && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 仅当不是私有知识库时才显示组别选项 */}
|
||||
{formData.type !== 'private' && (
|
||||
<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}>
|
||||
|
@ -1,28 +1,81 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { clearNotifications } from '../store/notificationCenter/notificationCenter.slice';
|
||||
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 } = useSelector((state) => state.notificationCenter);
|
||||
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, 2);
|
||||
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);
|
||||
@ -82,54 +135,97 @@ export default function NotificationCenter({ show, onClose }) {
|
||||
}}
|
||||
>
|
||||
<div className='card-header bg-white border-0 d-flex justify-content-between align-items-center py-3'>
|
||||
<h6 className='mb-0'>通知中心</h6>
|
||||
<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.map((notification) => (
|
||||
<div key={notification.id} className='notification-item p-3 border-bottom hover-bg-light'>
|
||||
<div className='d-flex gap-3'>
|
||||
<div className='notification-icon'>
|
||||
<i className={`bi ${notification.icon} text-dark fs-5`}></i>
|
||||
</div>
|
||||
<div className='flex-grow-1'>
|
||||
<div className='d-flex justify-content-between align-items-start'>
|
||||
<h6 className='mb-1'>{notification.title}</h6>
|
||||
<small className='text-muted'>{notification.time}</small>
|
||||
{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>
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
<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 ? '收起' : '查看全部通知'}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* 使用滑动面板组件 */}
|
||||
|
@ -24,7 +24,7 @@ const Pagination = ({ currentPage, totalPages, pageSize, onPageChange, onPageSiz
|
||||
</select>
|
||||
</div>
|
||||
<nav aria-label='分页导航'>
|
||||
<ul className='pagination mb-0'>
|
||||
<ul className='pagination mb-0 dark-pagination'>
|
||||
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
|
||||
<button
|
||||
className='page-link'
|
||||
|
@ -13,7 +13,7 @@ export default function HeaderWithNav() {
|
||||
const { user } = useSelector((state) => state.auth);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
const { notifications } = useSelector((state) => state.notificationCenter);
|
||||
const { notifications, unreadCount, isConnected } = useSelector((state) => state.notificationCenter);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
@ -85,11 +85,22 @@ export default function HeaderWithNav() {
|
||||
<button
|
||||
className='btn btn-link text-dark p-0'
|
||||
onClick={() => setShowNotifications(!showNotifications)}
|
||||
title={isConnected ? '通知服务已连接' : '通知服务未连接'}
|
||||
>
|
||||
<SvgIcon className={'bell'} />
|
||||
{notifications.length > 0 && (
|
||||
{unreadCount > 0 && (
|
||||
<span className='position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger'>
|
||||
{notifications.length}
|
||||
{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>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { fetchChats, deleteChat } from '../../store/chat/chat.thunks';
|
||||
import { fetchChats, deleteChat, createChatRecord } from '../../store/chat/chat.thunks';
|
||||
import { showNotification } from '../../store/notification.slice';
|
||||
import ChatSidebar from './ChatSidebar';
|
||||
import NewChat from './NewChat';
|
||||
@ -13,13 +13,17 @@ export default function Chat() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// 从 Redux store 获取聊天记录列表
|
||||
const { items: chatHistory, status, error } = useSelector((state) => state.chat.list);
|
||||
const operationStatus = useSelector((state) => state.chat.operations.status);
|
||||
const operationError = useSelector((state) => state.chat.operations.error);
|
||||
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());
|
||||
dispatch(fetchChats({ page: 1, page_size: 20 }));
|
||||
}, [dispatch]);
|
||||
|
||||
// 监听操作状态,显示通知
|
||||
@ -41,23 +45,88 @@ export default function Chat() {
|
||||
}
|
||||
}, [operationStatus, operationError, dispatch]);
|
||||
|
||||
// If we have a knowledgeBaseId but no chatId, create a new chat
|
||||
// If we have a knowledgeBaseId but no chatId, check if we have an existing chat or create a new one
|
||||
useEffect(() => {
|
||||
if (knowledgeBaseId && !chatId) {
|
||||
// In a real app, you would create a new chat and get its ID from the API
|
||||
const newChatId = Date.now().toString();
|
||||
navigate(`/chat/${knowledgeBaseId}/${newChatId}`);
|
||||
// 检查是否存在包含此知识库的聊天记录
|
||||
const existingChat = chatHistory.find((chat) => {
|
||||
// 检查知识库ID是否匹配
|
||||
if (chat.datasets && Array.isArray(chat.datasets)) {
|
||||
return chat.datasets.some((ds) => ds.id === knowledgeBaseId);
|
||||
}
|
||||
// 兼容旧格式
|
||||
if (chat.dataset_id_list && Array.isArray(chat.dataset_id_list)) {
|
||||
return chat.dataset_id_list.includes(knowledgeBaseId.replace(/-/g, ''));
|
||||
}
|
||||
return false;
|
||||
});
|
||||
console.log('existingChat', existingChat);
|
||||
|
||||
if (existingChat) {
|
||||
// 找到现有聊天记录,导航到该聊天页面
|
||||
navigate(`/chat/${knowledgeBaseId}/${existingChat.conversation_id}`);
|
||||
} else {
|
||||
// 创建新聊天
|
||||
dispatch(
|
||||
createChatRecord({
|
||||
dataset_id_list: [knowledgeBaseId.replace(/-/g, '')],
|
||||
question: '选择当前知识库,创建聊天',
|
||||
})
|
||||
)
|
||||
.unwrap()
|
||||
.then((response) => {
|
||||
// 创建成功,使用返回的conversation_id导航
|
||||
if (response && response.conversation_id) {
|
||||
navigate(`/chat/${knowledgeBaseId}/${response.conversation_id}`);
|
||||
} else {
|
||||
// 错误处理
|
||||
dispatch(
|
||||
showNotification({
|
||||
message: '创建聊天失败:未能获取会话ID',
|
||||
type: 'danger',
|
||||
})
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
dispatch(
|
||||
showNotification({
|
||||
message: `创建聊天失败: ${error}`,
|
||||
type: 'danger',
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [knowledgeBaseId, chatId, navigate]);
|
||||
}, [knowledgeBaseId, chatId, chatHistory, navigate, dispatch]);
|
||||
|
||||
const handleDeleteChat = (id) => {
|
||||
// 调用 Redux action 删除聊天
|
||||
dispatch(deleteChat(id));
|
||||
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');
|
||||
}
|
||||
// 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 (
|
||||
|
@ -82,36 +82,50 @@ export default function ChatSidebar({ chatHistory = [], onDeleteChat, isLoading
|
||||
<ul className='list-group list-group-flush'>
|
||||
{chatHistory.map((chat) => (
|
||||
<li
|
||||
key={chat.id}
|
||||
key={chat.conversation_id}
|
||||
className={`list-group-item border-0 position-relative ${
|
||||
chatId === chat.id ? 'bg-light' : ''
|
||||
chatId === chat.conversation_id ? 'bg-light' : ''
|
||||
}`}
|
||||
>
|
||||
<Link
|
||||
to={`/chat/${chat.knowledge_base_id}/${chat.id}`}
|
||||
to={`/chat/${chat.datasets?.[0]?.id || knowledgeBaseId}/${chat.conversation_id}`}
|
||||
className={`text-decoration-none d-flex align-items-center gap-2 py-2 text-dark ${
|
||||
chatId === chat.id ? 'fw-bold' : ''
|
||||
chatId === chat.conversation_id ? 'fw-bold' : ''
|
||||
}`}
|
||||
>
|
||||
<div className='text-truncate'>{chat.title}</div>
|
||||
<div className='d-flex flex-column'>
|
||||
<div className='text-truncate fw-medium'>
|
||||
{chat.datasets?.map((ds) => ds.name).join(', ') || '未命名知识库'}
|
||||
</div>
|
||||
<div className='small text-muted text-truncate' style={{ maxWidth: '160px' }}>
|
||||
{chat.last_message
|
||||
? chat.last_message.length > 30
|
||||
? chat.last_message.substring(0, 30) + '...'
|
||||
: chat.last_message
|
||||
: '新对话'}
|
||||
</div>
|
||||
<div className='x-small text-muted mt-1'>
|
||||
{chat.last_time && new Date(chat.last_time).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div
|
||||
className='dropdown-hover-area position-absolute end-0 top-0 bottom-0'
|
||||
style={{ width: '40px' }}
|
||||
onMouseEnter={() => handleMouseEnter(chat.id)}
|
||||
onMouseEnter={() => handleMouseEnter(chat.conversation_id)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<button className='btn btn-sm position-absolute end-0 top-50 translate-middle-y me-2'>
|
||||
<SvgIcon className='more-dot' width='5' height='16' />
|
||||
</button>
|
||||
{activeDropdown === chat.id && (
|
||||
{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.id)}
|
||||
onClick={(e) => handleDeleteChat(e, chat.conversation_id)}
|
||||
>
|
||||
<SvgIcon className='trash' />
|
||||
<span>删除</span>
|
||||
|
@ -1,22 +1,23 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { fetchMessages, sendMessage } from '../../store/chat/chat.messages.thunks';
|
||||
import { fetchMessages } from '../../store/chat/chat.messages.thunks';
|
||||
import { resetMessages, resetSendMessageStatus, addMessage } from '../../store/chat/chat.slice';
|
||||
import { showNotification } from '../../store/notification.slice';
|
||||
import SvgIcon from '../../components/SvgIcon';
|
||||
import { createChatRecord, fetchAvailableDatasets, fetchConversationDetail } from '../../store/chat/chat.thunks';
|
||||
import { fetchKnowledgeBases } from '../../store/knowledgeBase/knowledgeBase.thunks';
|
||||
import SvgIcon from '../../components/SvgIcon';
|
||||
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);
|
||||
|
||||
// 从 Redux store 获取数据
|
||||
const {
|
||||
items: messages,
|
||||
status: messagesStatus,
|
||||
error: messagesError,
|
||||
} = useSelector((state) => state.chat.messages);
|
||||
// 从 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状态结构
|
||||
@ -24,18 +25,61 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
|
||||
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);
|
||||
|
||||
// 获取聊天详情
|
||||
useEffect(() => {
|
||||
if (chatId) {
|
||||
dispatch(fetchMessages(chatId));
|
||||
setLoading(true);
|
||||
dispatch(fetchConversationDetail(chatId))
|
||||
.unwrap()
|
||||
.catch((error) => {
|
||||
// 如果是新聊天,API会返回404,此时不显示错误
|
||||
if (error && error !== 'Error: Request failed with status code 404') {
|
||||
dispatch(
|
||||
showNotification({
|
||||
message: `获取聊天详情失败: ${error || '未知错误'}`,
|
||||
type: 'danger',
|
||||
})
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
// 组件卸载时重置消息状态
|
||||
// 组件卸载时清空消息
|
||||
return () => {
|
||||
dispatch(resetMessages());
|
||||
};
|
||||
}, [chatId, dispatch]);
|
||||
|
||||
// 新会话自动添加欢迎消息
|
||||
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) {
|
||||
@ -54,32 +98,73 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
// 从 Redux store 获取知识库信息
|
||||
// 获取当前会话的知识库信息
|
||||
useEffect(() => {
|
||||
if (!knowledgeBase && !isLoadingKnowledgeBases) {
|
||||
dispatch(fetchKnowledgeBases({ page: 1, page_size: 50 }));
|
||||
// 如果conversation有数据集信息,优先使用它
|
||||
if (conversation && conversation.datasets && conversation.datasets.length > 0) {
|
||||
return;
|
||||
}
|
||||
}, [dispatch, knowledgeBase, isLoadingKnowledgeBases]);
|
||||
|
||||
// 如果没有会话数据集信息,但有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;
|
||||
|
||||
// 立即添加用户消息到界面
|
||||
const userMessage = {
|
||||
id: Date.now(),
|
||||
content: inputMessage,
|
||||
sender: 'user',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
dispatch(addMessage(userMessage));
|
||||
// 获取知识库ID列表
|
||||
let dataset_id_list = [];
|
||||
|
||||
if (conversation && conversation.datasets) {
|
||||
// 如果已有会话,使用会话中的知识库
|
||||
dataset_id_list = conversation.datasets.map((ds) => ds.id.replace(/-/g, ''));
|
||||
} else if (knowledgeBaseId) {
|
||||
// 如果是新会话,使用当前选择的知识库
|
||||
dataset_id_list = [knowledgeBaseId.replace(/-/g, '')];
|
||||
} else if (availableDatasets.length > 0) {
|
||||
// 如果都没有,尝试使用可用知识库列表中的第一个
|
||||
dataset_id_list = [availableDatasets[0].id.replace(/-/g, '')];
|
||||
}
|
||||
|
||||
if (dataset_id_list.length === 0) {
|
||||
dispatch(
|
||||
showNotification({
|
||||
message: '发送失败:未选择知识库',
|
||||
type: 'danger',
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 发送消息到服务器
|
||||
dispatch(
|
||||
createChatRecord({
|
||||
dataset_id_list: dataset_id_list,
|
||||
question: inputMessage,
|
||||
conversation_id: chatId,
|
||||
})
|
||||
)
|
||||
.unwrap()
|
||||
.then(() => {
|
||||
// 成功发送后,可以执行任何需要的操作
|
||||
// 例如:在用户发送第一条消息后更新URL中的会话ID
|
||||
})
|
||||
.catch((error) => {
|
||||
// 发送失败,显示错误信息
|
||||
dispatch(
|
||||
showNotification({
|
||||
message: `发送失败: ${error}`,
|
||||
type: 'danger',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// 清空输入框
|
||||
setInputMessage('');
|
||||
|
||||
// 发送消息到服务器
|
||||
dispatch(sendMessage({ chatId, content: inputMessage }));
|
||||
};
|
||||
|
||||
// 渲染加载状态
|
||||
@ -94,38 +179,55 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
|
||||
|
||||
// 渲染错误状态
|
||||
const renderError = () => (
|
||||
<div className='p-5 text-center'>
|
||||
<div className='text-danger mb-3'>
|
||||
<SvgIcon className='error' width='48' height='48' />
|
||||
</div>
|
||||
<div className='text-muted'>加载聊天记录失败,请重试</div>
|
||||
<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 = () => (
|
||||
<div className='p-5 text-center'>
|
||||
<div className='text-muted'>暂无聊天记录,发送一条消息开始聊天吧</div>
|
||||
</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'>
|
||||
<h5 className='mb-0'>{knowledgeBase?.name || '加载中...'}</h5>
|
||||
<small className='text-muted'>{knowledgeBase?.description}</small>
|
||||
{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'>
|
||||
{messagesStatus === 'loading'
|
||||
{messageStatus === 'loading'
|
||||
? renderLoading()
|
||||
: messagesStatus === 'failed'
|
||||
: messageStatus === 'failed'
|
||||
? renderError()
|
||||
: messages.length === 0
|
||||
? renderEmpty()
|
||||
@ -133,12 +235,12 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
|
||||
<div
|
||||
key={message.id}
|
||||
className={`d-flex ${
|
||||
message.sender === 'user' ? 'justify-content-end' : 'justify-content-start'
|
||||
message.role === 'user' ? 'justify-content-end' : 'justify-content-start'
|
||||
} mb-3`}
|
||||
>
|
||||
<div
|
||||
className={`chat-message p-3 rounded-3 ${
|
||||
message.sender === 'user' ? 'bg-dark text-white' : 'bg-light'
|
||||
message.role === 'user' ? 'bg-dark text-white' : 'bg-light'
|
||||
}`}
|
||||
style={{
|
||||
maxWidth: '75%',
|
||||
@ -146,6 +248,9 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
|
||||
}}
|
||||
>
|
||||
<div className='message-content'>{message.content}</div>
|
||||
<div className='message-time small text-muted mt-1'>
|
||||
{message.created_at && new Date(message.created_at).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
@ -2,21 +2,26 @@ 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 { fetchKnowledgeBases } from '../../store/knowledgeBase/knowledgeBase.thunks';
|
||||
import { fetchAvailableDatasets, fetchChats, createChatRecord } from '../../store/chat/chat.thunks';
|
||||
import SvgIcon from '../../components/SvgIcon';
|
||||
|
||||
export default function NewChat() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// 从 Redux store 获取知识库数据 - 使用新的状态结构
|
||||
const knowledgeBases = useSelector((state) => state.knowledgeBase.knowledgeBases || []);
|
||||
const isLoading = useSelector((state) => state.knowledgeBase.loading);
|
||||
const error = useSelector((state) => state.knowledgeBase.error);
|
||||
// 从 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');
|
||||
|
||||
// 获取可用知识库列表和聊天历史
|
||||
useEffect(() => {
|
||||
dispatch(fetchKnowledgeBases({ page: 1, page_size: 50 }));
|
||||
dispatch(fetchAvailableDatasets());
|
||||
dispatch(fetchChats({ page: 1, page_size: 50 }));
|
||||
}, [dispatch]);
|
||||
|
||||
// 监听错误状态
|
||||
@ -24,23 +29,39 @@ export default function NewChat() {
|
||||
if (error) {
|
||||
dispatch(
|
||||
showNotification({
|
||||
message: `获取知识库列表失败: ${error.message || error}`,
|
||||
message: `获取可用知识库列表失败: ${error}`,
|
||||
type: 'danger',
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [error, dispatch]);
|
||||
|
||||
// 过滤出有 can_read 权限的知识库
|
||||
const readableKnowledgeBases = knowledgeBases.filter((kb) => kb.permissions && kb.permissions.can_read === true);
|
||||
// 处理知识库选择
|
||||
const handleSelectKnowledgeBase = (dataset) => {
|
||||
// 判断聊天历史中是否已存在该知识库的聊天记录
|
||||
const existingChat = chatHistory.find((chat) => {
|
||||
// 检查知识库ID是否匹配
|
||||
if (chat.datasets && Array.isArray(chat.datasets)) {
|
||||
return chat.datasets.some((ds) => ds.id === dataset.id);
|
||||
}
|
||||
// 兼容旧格式
|
||||
if (chat.dataset_id_list && Array.isArray(chat.dataset_id_list)) {
|
||||
return chat.dataset_id_list.includes(dataset.id.replace(/-/g, ''));
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const handleSelectKnowledgeBase = (knowledgeBaseId) => {
|
||||
// 创建新聊天并导航到聊天页面
|
||||
navigate(`/chat/${knowledgeBaseId}`);
|
||||
if (existingChat) {
|
||||
// 找到现有聊天记录,导航到该聊天页面
|
||||
navigate(`/chat/${dataset.id}/${existingChat.conversation_id}`);
|
||||
} else {
|
||||
// 没有找到现有聊天记录,创建新的聊天
|
||||
navigate(`/chat/${dataset.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染加载状态
|
||||
if (isLoading) {
|
||||
if (isLoading || chatHistoryLoading) {
|
||||
return (
|
||||
<div className='container-fluid px-4 py-5 text-center'>
|
||||
<div className='spinner-border' role='status'>
|
||||
@ -54,24 +75,26 @@ export default function NewChat() {
|
||||
<div className='container-fluid px-4 py-5'>
|
||||
<h4 className='mb-4'>选择知识库开始聊天</h4>
|
||||
<div className='row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4 justify-content-center'>
|
||||
{readableKnowledgeBases.length > 0 ? (
|
||||
readableKnowledgeBases.map((kb) => (
|
||||
<div key={kb.id} className='col'>
|
||||
{datasets.length > 0 ? (
|
||||
datasets.map((dataset) => (
|
||||
<div key={dataset.id} className='col'>
|
||||
<div
|
||||
className='card h-100 shadow-sm border-0 cursor-pointer'
|
||||
onClick={() => handleSelectKnowledgeBase(kb.id)}
|
||||
onClick={() => handleSelectKnowledgeBase(dataset)}
|
||||
>
|
||||
<div className='card-body'>
|
||||
<h5 className='card-title'>{kb.name}</h5>
|
||||
<p className='card-text text-muted'>{kb.desc || kb.description || ''}</p>
|
||||
<h5 className='card-title'>{dataset.name}</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'>
|
||||
<SvgIcon className='file' />
|
||||
{kb.document_count} 文档
|
||||
{dataset.document_count || 0} 文档
|
||||
</span>
|
||||
<span className='d-flex align-items-center gap-1'>
|
||||
<SvgIcon className='clock' />
|
||||
{new Date(kb.create_time).toLocaleDateString()}
|
||||
{dataset.create_time
|
||||
? new Date(dataset.create_time).toLocaleDateString()
|
||||
: 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -2,7 +2,7 @@ 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 { fetchKnowledgeBaseDetail } from '../../../store/knowledgeBase/knowledgeBase.thunks';
|
||||
import { getKnowledgeBaseById } from '../../../store/knowledgeBase/knowledgeBase.thunks';
|
||||
import SvgIcon from '../../../components/SvgIcon';
|
||||
import DatasetTab from './DatasetTab';
|
||||
import SettingsTab from './SettingsTab';
|
||||
@ -21,7 +21,7 @@ export default function KnowledgeBaseDetail() {
|
||||
// Fetch knowledge base details when component mounts or ID changes
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
dispatch(fetchKnowledgeBaseDetail(id));
|
||||
dispatch(getKnowledgeBaseById(id));
|
||||
}
|
||||
}, [dispatch, id]);
|
||||
|
||||
|
@ -123,6 +123,8 @@ export default function SettingsTab({ knowledgeBase }) {
|
||||
// Validate knowledge base form
|
||||
const validateForm = () => {
|
||||
const errors = {};
|
||||
// 私有知识库不需要部门和组别
|
||||
const isPrivate = knowledgeBaseForm.type === 'private';
|
||||
|
||||
if (!knowledgeBaseForm.name.trim()) {
|
||||
errors.name = '请输入知识库名称';
|
||||
@ -136,12 +138,15 @@ export default function SettingsTab({ knowledgeBase }) {
|
||||
errors.type = '请选择知识库类型';
|
||||
}
|
||||
|
||||
if (isAdmin && !knowledgeBaseForm.department) {
|
||||
errors.department = '请选择部门';
|
||||
}
|
||||
// 只有非私有知识库需要验证部门和组别
|
||||
if (!isPrivate) {
|
||||
if (isAdmin && !knowledgeBaseForm.department) {
|
||||
errors.department = '请选择部门';
|
||||
}
|
||||
|
||||
if (isAdmin && !knowledgeBaseForm.group) {
|
||||
errors.group = '请选择组别';
|
||||
if (isAdmin && !knowledgeBaseForm.group) {
|
||||
errors.group = '请选择组别';
|
||||
}
|
||||
}
|
||||
|
||||
setFormErrors(errors);
|
||||
@ -174,8 +179,10 @@ export default function SettingsTab({ knowledgeBase }) {
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
const department = isAdmin ? knowledgeBaseForm.department : currentUser.department || '';
|
||||
const group = isAdmin ? knowledgeBaseForm.group : currentUser.group || '';
|
||||
// 私有知识库不需要部门和组别
|
||||
const isPrivate = newType === 'private';
|
||||
const department = isPrivate ? '' : isAdmin ? knowledgeBaseForm.department : currentUser.department || '';
|
||||
const group = isPrivate ? '' : isAdmin ? knowledgeBaseForm.group : currentUser.group || '';
|
||||
|
||||
dispatch(
|
||||
changeKnowledgeBaseType({
|
||||
|
@ -120,86 +120,92 @@ const KnowledgeBaseForm = ({
|
||||
{formErrors.type && <div className='text-danger small mt-1'>{formErrors.type}</div>}
|
||||
</div>
|
||||
|
||||
<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
|
||||
/>
|
||||
<small className='text-muted'>部门信息根据知识库创建者自动填写</small>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* 仅当不是私有知识库时才显示部门选项 */}
|
||||
{formData.type !== 'private' && (
|
||||
<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
|
||||
/>
|
||||
<small className='text-muted'>部门信息根据知识库创建者自动填写</small>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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
|
||||
/>
|
||||
<small className='text-muted'>组别信息根据知识库创建者自动填写</small>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* 仅当不是私有知识库时才显示组别选项 */}
|
||||
{formData.type !== 'private' && (
|
||||
<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
|
||||
/>
|
||||
<small className='text-muted'>组别信息根据知识库创建者自动填写</small>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 类型更改按钮 */}
|
||||
{showTypeChangeButton && (
|
||||
|
@ -163,8 +163,35 @@ export default function KnowledgeBase() {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -212,6 +239,12 @@ export default function KnowledgeBase() {
|
||||
|
||||
const validateCreateForm = () => {
|
||||
const errors = {};
|
||||
const isAdmin = currentUser?.role === 'admin';
|
||||
const isLeader = currentUser?.role === 'leader';
|
||||
// 只有member类型知识库需要选择组别,私有知识库不需要
|
||||
const needSelectGroup = newKnowledgeBase.type === 'member';
|
||||
// 私有知识库不需要选择部门和组别
|
||||
const isPrivate = newKnowledgeBase.type === 'private';
|
||||
|
||||
if (!newKnowledgeBase.name.trim()) {
|
||||
errors.name = '请输入知识库名称';
|
||||
@ -225,6 +258,19 @@ export default function KnowledgeBase() {
|
||||
errors.type = '请选择知识库类型';
|
||||
}
|
||||
|
||||
// 对于member级别的知识库,检查是否选择了部门和组别
|
||||
if (needSelectGroup && !isPrivate) {
|
||||
// 管理员必须选择部门
|
||||
if (isAdmin && !newKnowledgeBase.department) {
|
||||
errors.department = '创建member级别知识库时必须选择部门';
|
||||
}
|
||||
|
||||
// 所有用户创建member级别知识库时必须选择组别
|
||||
if (!newKnowledgeBase.group) {
|
||||
errors.group = '创建member级别知识库时必须选择组别';
|
||||
}
|
||||
}
|
||||
|
||||
setFormErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
@ -236,6 +282,9 @@ export default function KnowledgeBase() {
|
||||
}
|
||||
|
||||
try {
|
||||
// 私有知识库不需要部门和组别信息
|
||||
const isPrivate = newKnowledgeBase.type === 'private';
|
||||
|
||||
// Dispatch create knowledge base action
|
||||
const resultAction = await dispatch(
|
||||
createKnowledgeBase({
|
||||
@ -243,8 +292,8 @@ export default function KnowledgeBase() {
|
||||
desc: newKnowledgeBase.desc,
|
||||
description: newKnowledgeBase.desc,
|
||||
type: newKnowledgeBase.type,
|
||||
department: newKnowledgeBase.department,
|
||||
group: newKnowledgeBase.group,
|
||||
department: !isPrivate ? newKnowledgeBase.department : '',
|
||||
group: !isPrivate ? newKnowledgeBase.group : '',
|
||||
})
|
||||
);
|
||||
|
||||
@ -385,16 +434,25 @@ export default function KnowledgeBase() {
|
||||
|
||||
// 打开创建知识库弹窗
|
||||
const handleOpenCreateModal = () => {
|
||||
const isAdmin = currentUser?.role === 'admin';
|
||||
const isLeader = currentUser?.role === 'leader';
|
||||
|
||||
// 默认知识库类型基于用户角色
|
||||
let defaultType = 'private';
|
||||
|
||||
// 确保部门和组别字段使用当前用户的信息
|
||||
setNewKnowledgeBase((prev) => ({
|
||||
...prev,
|
||||
department: currentUser?.department || '',
|
||||
group: currentUser?.group || '',
|
||||
// 初始部门和组别
|
||||
let department = currentUser?.department || '';
|
||||
let group = currentUser?.group || '';
|
||||
|
||||
setNewKnowledgeBase({
|
||||
name: '',
|
||||
desc: '',
|
||||
type: defaultType,
|
||||
}));
|
||||
department: department,
|
||||
group: group,
|
||||
});
|
||||
|
||||
setFormErrors({});
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
|
@ -17,14 +17,15 @@ export default function PermissionsPage() {
|
||||
}, [user, navigate]);
|
||||
|
||||
return (
|
||||
<div className='permissions-container'>
|
||||
<div className='permissions-container'>
|
||||
<div className='permissions-section mb-4'>
|
||||
<PendingRequests />
|
||||
</div>
|
||||
|
||||
<div className='permissions-section'>
|
||||
<UserPermissions />
|
||||
</div>
|
||||
{user && user.role === 'admin' && (
|
||||
<div className='permissions-section'>
|
||||
<UserPermissions />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -68,7 +68,7 @@ export default function UserPermissionDetails({ user, onClose, onSave }) {
|
||||
const fetchUserPermissions = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await get(`/users/${user.id}/permissions/`);
|
||||
const response = await get(`/users/${user.user.id}/permissions/`);
|
||||
|
||||
if (response && response.code === 200) {
|
||||
// 检查API返回的数据
|
||||
@ -114,7 +114,7 @@ export default function UserPermissionDetails({ user, onClose, onSave }) {
|
||||
|
||||
// 处理保存
|
||||
const handleSave = () => {
|
||||
onSave(user.id, updatedPermissions);
|
||||
onSave(user.user.id, updatedPermissions);
|
||||
};
|
||||
|
||||
// 获取权限类型的显示文本
|
||||
@ -147,7 +147,7 @@ export default function UserPermissionDetails({ user, onClose, onSave }) {
|
||||
<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.name} 的权限详情</h5>
|
||||
<h5 className='modal-title'>{user.user.name} 的权限详情</h5>
|
||||
<button type='button' className='btn-close' onClick={onClose}></button>
|
||||
</div>
|
||||
<div className='modal-body'>
|
||||
@ -179,7 +179,8 @@ export default function UserPermissionDetails({ user, onClose, onSave }) {
|
||||
{userPermissions.map((item) => {
|
||||
const currentPermissionType = getPermissionType(item.permission);
|
||||
const updatedPermissionType =
|
||||
updatedPermissions[item.knowledge_base.id] || currentPermissionType;
|
||||
updatedPermissions[item.knowledge_base.id] ||
|
||||
currentPermissionType;
|
||||
|
||||
return (
|
||||
<tr key={item.knowledge_base.id}>
|
||||
|
@ -1,12 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { fetchUserPermissions, updateUserPermissions } from '../../../store/permissions/permissions.thunks';
|
||||
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];
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20, 50, 100];
|
||||
|
||||
export default function UserPermissions() {
|
||||
const dispatch = useDispatch();
|
||||
@ -16,282 +16,316 @@ export default function UserPermissions() {
|
||||
|
||||
// 分页状态
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(5);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
// 从 Redux store 获取用户列表和状态
|
||||
const { items: users, status: loading, error } = useSelector((state) => state.permissions.users);
|
||||
const {
|
||||
results: users,
|
||||
status: loading,
|
||||
error,
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
} = useSelector((state) => state.permissions.allUsersPermissions);
|
||||
|
||||
// 获取用户列表
|
||||
useEffect(() => {
|
||||
dispatch(fetchUserPermissions());
|
||||
}, [dispatch]);
|
||||
dispatch(fetchAllUserPermissions({ page: currentPage, page_size: pageSize }));
|
||||
}, [dispatch, currentPage, pageSize]);
|
||||
|
||||
// 当用户列表或搜索词变化时,更新总页数
|
||||
useEffect(() => {
|
||||
const filteredUsers = getFilteredUsers();
|
||||
setTotalPages(Math.ceil(filteredUsers.length / pageSize));
|
||||
// 如果当前页超出了新的总页数,则重置为第一页
|
||||
if (currentPage > Math.ceil(filteredUsers.length / pageSize)) {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
}, [users, searchTerm, pageSize]);
|
||||
// 计算总页数
|
||||
const totalPages = Math.ceil(total / page_size);
|
||||
|
||||
// 打开用户权限详情弹窗
|
||||
const handleOpenDetailsModal = (user) => {
|
||||
setSelectedUser(user);
|
||||
setShowDetailsModal(true);
|
||||
};
|
||||
|
||||
// 关闭用户权限详情弹窗
|
||||
const handleCloseDetailsModal = () => {
|
||||
setShowDetailsModal(false);
|
||||
setSelectedUser(null);
|
||||
setShowDetailsModal(false);
|
||||
};
|
||||
|
||||
// 保存用户权限更改
|
||||
const handleSavePermissions = async (userId, updatedPermissions) => {
|
||||
try {
|
||||
await dispatch(updateUserPermissions({ userId, permissions: updatedPermissions })).unwrap();
|
||||
handleCloseDetailsModal();
|
||||
// 重新获取用户列表以更新权限信息
|
||||
dispatch(fetchAllUserPermissions({ page: currentPage, page_size: pageSize }));
|
||||
} catch (error) {
|
||||
console.error('更新权限失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理搜索输入变化
|
||||
const handleSearchChange = (e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1); // 重置为第一页
|
||||
setCurrentPage(1); // 重置到第一页
|
||||
};
|
||||
|
||||
// 处理页码变化
|
||||
const handlePageChange = (page) => {
|
||||
setCurrentPage(page);
|
||||
if (page > 0 && page <= totalPages) {
|
||||
setCurrentPage(page);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理每页显示数量变化
|
||||
const handlePageSizeChange = (e) => {
|
||||
const newPageSize = parseInt(e.target.value);
|
||||
setPageSize(newPageSize);
|
||||
setCurrentPage(1); // 重置为第一页
|
||||
setCurrentPage(1); // 重置到第一页
|
||||
};
|
||||
|
||||
// 获取过滤后的用户列表
|
||||
// 过滤用户列表
|
||||
const getFilteredUsers = () => {
|
||||
if (!searchTerm.trim()) return users;
|
||||
if (!searchTerm.trim()) {
|
||||
return users;
|
||||
}
|
||||
|
||||
return users.filter(
|
||||
(user) =>
|
||||
user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.department.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.position.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
user.user.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.user.department.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.user.role.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
};
|
||||
|
||||
// 获取当前页的数据
|
||||
const getCurrentPageData = () => {
|
||||
const filteredUsers = getFilteredUsers();
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
return filteredUsers.slice(startIndex, endIndex);
|
||||
};
|
||||
const filteredUsers = getFilteredUsers();
|
||||
|
||||
// 渲染分页控件
|
||||
const renderPagination = () => {
|
||||
if (totalPages <= 1) return null;
|
||||
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-center align-items-center mt-4'>
|
||||
<nav aria-label='用户权限分页'>
|
||||
<ul className='pagination dark-pagination'>
|
||||
<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)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<button className='page-link' onClick={() => handlePageChange(currentPage - 1)}>
|
||||
上一页
|
||||
</button>
|
||||
</li>
|
||||
<li className='page-item active'>
|
||||
<span className='page-link'>
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
</li>
|
||||
{pageNumbers}
|
||||
<li className={`page-item ${currentPage === totalPages ? 'disabled' : ''}`}>
|
||||
<button
|
||||
className='page-link'
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<button className='page-link' onClick={() => handlePageChange(currentPage + 1)}>
|
||||
下一页
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className='ms-3'>
|
||||
<select
|
||||
className='form-select form-select-sm'
|
||||
value={pageSize}
|
||||
onChange={handlePageSizeChange}
|
||||
aria-label='每页显示数量'
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}条/页
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染加载状态
|
||||
if (loading === 'loading' && users.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 (error && users.length === 0) {
|
||||
return (
|
||||
<div className='alert alert-danger' role='alert'>
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染空状态
|
||||
if (users.length === 0) {
|
||||
return (
|
||||
<div className='alert alert-info' role='alert'>
|
||||
暂无用户数据
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 获取当前页的数据
|
||||
const currentPageData = getCurrentPageData();
|
||||
|
||||
// 获取过滤后的总用户数
|
||||
const filteredUsersCount = getFilteredUsers().length;
|
||||
|
||||
// 渲染用户列表
|
||||
return (
|
||||
<>
|
||||
<div className='d-flex justify-content-between align-items-center mb-3'>
|
||||
<div className='card'>
|
||||
<div className='card-header'>
|
||||
<h5 className='mb-0'>用户权限管理</h5>
|
||||
<div className='input-group' style={{ maxWidth: '250px' }}>
|
||||
<input
|
||||
type='text'
|
||||
className='form-control'
|
||||
placeholder='搜索用户...'
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
<span className='input-group-text'>
|
||||
<SvgIcon className='magnifying-glass' />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='card-body'>
|
||||
<div className='mb-3 d-flex justify-content-between align-items-center'>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{filteredUsersCount > 0 ? (
|
||||
<>
|
||||
<div className='card'>
|
||||
{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 mb-0'>
|
||||
<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>
|
||||
<th scope='col' className='text-end'>
|
||||
操作
|
||||
</th>
|
||||
<th>ID</th>
|
||||
<th>用户名</th>
|
||||
<th>姓名</th>
|
||||
<th>部门</th>
|
||||
<th>角色</th>
|
||||
<th>权限类型</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentPageData.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td>
|
||||
<div className='d-flex align-items-center'>
|
||||
<div
|
||||
className='bg-dark rounded-circle text-white d-flex align-items-center justify-content-center me-2'
|
||||
style={{ width: '36px', height: '36px' }}
|
||||
{filteredUsers.length > 0 ? (
|
||||
filteredUsers.map((userPermission) => (
|
||||
<tr key={userPermission.id}>
|
||||
<td className='align-middle'>{userPermission.id}</td>
|
||||
<td className='align-middle'>{userPermission.user.username}</td>
|
||||
<td className='align-middle'>{userPermission.user.name}</td>
|
||||
<td className='align-middle'>{userPermission.user.department}</td>
|
||||
<td className='align-middle'>
|
||||
<span
|
||||
className={`badge ${
|
||||
userPermission.user.role === 'admin'
|
||||
? 'bg-danger-subtle text-danger'
|
||||
: userPermission.user.role === 'leader'
|
||||
? 'bg-warning-subtle text-warning'
|
||||
: 'bg-info-subtle text-info'
|
||||
}`}
|
||||
>
|
||||
{user.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div className='fw-medium'>{user.name}</div>
|
||||
<div className='text-muted small'>{user.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className='align-middle'>{user.department}</td>
|
||||
<td className='align-middle'>{user.position}</td>
|
||||
<td className='align-middle'>
|
||||
{user.permissions_count && (
|
||||
{userPermission.user.role === 'admin'
|
||||
? '管理员'
|
||||
: userPermission.user.role === 'leader'
|
||||
? '组长'
|
||||
: '成员'}
|
||||
</span>
|
||||
</td>
|
||||
<td className='align-middle'>
|
||||
{userPermission.user.permissions_count && (
|
||||
<div className='d-flex flex-wrap gap-1'>
|
||||
{user.permissions_count.read > 0 && (
|
||||
<span className='badge bg-success-subtle text-success d-flex align-items-center gap-1'>
|
||||
完全访问: {user.permissions_count.read}
|
||||
{userPermission.user.permissions_count.read > 0 && (
|
||||
<span className='badge
|
||||
bg-success-subtle text-success
|
||||
d-flex align-items-center gap-1'>
|
||||
完全访问: {userPermission.user.
|
||||
permissions_count.read}
|
||||
</span>
|
||||
)}
|
||||
{user.permissions_count.edit > 0 && (
|
||||
<span className='badge bg-warning-subtle text-warning d-flex align-items-center gap-1'>
|
||||
只读访问: {user.permissions_count.edit}
|
||||
{userPermission.user.permissions_count.edit > 0 && (
|
||||
<span className='badge
|
||||
bg-warning-subtle text-warning
|
||||
d-flex align-items-center gap-1'>
|
||||
只读访问: {userPermission.user.
|
||||
permissions_count.edit}
|
||||
</span>
|
||||
)}
|
||||
{user.permissions_count.admin > 0 && (
|
||||
<span className='badge bg-dark-subtle d-flex align-items-center gap-1'>
|
||||
无访问权限: {user.permissions_count.admin}
|
||||
{userPermission.user.permissions_count.admin > 0 && (
|
||||
<span className='badge
|
||||
bg-dark-subtle d-flex
|
||||
align-items-center gap-1'>
|
||||
无访问权限: {userPermission.user.
|
||||
permissions_count.admin}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className='text-end align-middle'>
|
||||
<button
|
||||
className='btn btn-outline-dark btn-sm'
|
||||
onClick={() => handleOpenDetailsModal(user)}
|
||||
>
|
||||
修改权限
|
||||
</button>
|
||||
<td className='align-middle'>
|
||||
<button
|
||||
className='btn btn-sm btn-outline-dark'
|
||||
onClick={() => handleOpenDetailsModal(userPermission)}
|
||||
>
|
||||
查看权限
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan='5' className='text-center py-4'>
|
||||
无匹配的用户记录
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分页控件 */}
|
||||
{renderPagination()}
|
||||
</>
|
||||
) : (
|
||||
<div className='alert alert-info' role='alert'>
|
||||
没有找到匹配的用户
|
||||
</div>
|
||||
)}
|
||||
{totalPages > 1 && renderPagination()}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 用户权限详情弹窗 */}
|
||||
{showDetailsModal && selectedUser && (
|
||||
<UserPermissionDetails
|
||||
user={selectedUser}
|
||||
onClose={handleCloseDetailsModal}
|
||||
onSave={handleSavePermissions}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
{showDetailsModal && selectedUser && (
|
||||
<UserPermissionDetails
|
||||
user={selectedUser}
|
||||
onClose={handleCloseDetailsModal}
|
||||
onSave={handleSavePermissions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
234
src/services/websocket.js
Normal file
234
src/services/websocket.js
Normal file
@ -0,0 +1,234 @@
|
||||
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 URL 转换为 WebSocket URL
|
||||
const WS_BASE_URL = API_URL.replace(/^http/, 'ws').replace(/\/api\/?$/, '');
|
||||
|
||||
let socket = null;
|
||||
let reconnectTimer = null;
|
||||
let pingInterval = null;
|
||||
const RECONNECT_DELAY = 5000; // 5秒后尝试重连
|
||||
const PING_INTERVAL = 30000; // 30秒发送一次ping
|
||||
|
||||
/**
|
||||
* 初始化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}`;
|
||||
socket = new WebSocket(wsUrl);
|
||||
|
||||
// 连接建立时的处理
|
||||
socket.onopen = () => {
|
||||
console.log('WebSocket connection established');
|
||||
|
||||
// 订阅通知频道
|
||||
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) {
|
||||
reconnectTimer = setTimeout(() => {
|
||||
console.log('Attempting to reconnect WebSocket...');
|
||||
initWebSocket().catch((err) => {
|
||||
console.error('Failed to reconnect WebSocket:', err);
|
||||
});
|
||||
}, RECONNECT_DELAY);
|
||||
}
|
||||
};
|
||||
} 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 || {},
|
||||
};
|
||||
};
|
@ -42,13 +42,13 @@ export const loginThunk = createAsyncThunk(
|
||||
export const signupThunk = createAsyncThunk('auth/signup', async (userData, { rejectWithValue, dispatch }) => {
|
||||
try {
|
||||
// 使用新的注册 API
|
||||
const { data, code } = await post('/auth/register/', userData);
|
||||
const response = await post('/auth/register/', userData);
|
||||
console.log('注册返回数据:', response);
|
||||
|
||||
// 处理新的返回格式
|
||||
if (code === 200) {
|
||||
if (response && response.code === 200) {
|
||||
// // 将 token 加密存储到 sessionStorage
|
||||
// const { token } = data;
|
||||
// const { token } = response.data;
|
||||
// if (token) {
|
||||
// const encryptedToken = CryptoJS.AES.encrypt(token, secretKey).toString();
|
||||
// sessionStorage.setItem('token', encryptedToken);
|
||||
@ -61,11 +61,10 @@ export const signupThunk = createAsyncThunk('auth/signup', async (userData, { re
|
||||
type: 'success',
|
||||
})
|
||||
);
|
||||
return null;
|
||||
// return userData;
|
||||
return response.data;
|
||||
}
|
||||
|
||||
return response.data;
|
||||
return rejectWithValue(response.message || '注册失败');
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.message || '注册失败,请稍后重试';
|
||||
dispatch(
|
||||
|
@ -1,44 +1,66 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { fetchChats, createChat, deleteChat, updateChat } from './chat.thunks';
|
||||
import {
|
||||
fetchAvailableDatasets,
|
||||
fetchChats,
|
||||
createChat,
|
||||
updateChat,
|
||||
deleteChat,
|
||||
createChatRecord,
|
||||
fetchConversationDetail,
|
||||
} from './chat.thunks';
|
||||
import { fetchMessages, sendMessage } from './chat.messages.thunks';
|
||||
|
||||
// 初始状态
|
||||
const initialState = {
|
||||
// 聊天列表
|
||||
list: {
|
||||
// Chat history state
|
||||
history: {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
|
||||
error: null,
|
||||
},
|
||||
|
||||
// 当前聊天
|
||||
currentChat: {
|
||||
data: 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
|
||||
@ -88,6 +110,7 @@ const chatSlice = createSlice({
|
||||
builder
|
||||
.addCase(fetchChats.pending, (state) => {
|
||||
state.list.status = 'loading';
|
||||
state.history.status = 'loading';
|
||||
})
|
||||
.addCase(fetchChats.fulfilled, (state, action) => {
|
||||
state.list.status = 'succeeded';
|
||||
@ -95,10 +118,19 @@ const chatSlice = createSlice({
|
||||
state.list.total = action.payload.total;
|
||||
state.list.page = action.payload.page;
|
||||
state.list.page_size = action.payload.page_size;
|
||||
|
||||
// 同时更新新的状态结构
|
||||
state.history.status = 'succeeded';
|
||||
state.history.items = action.payload.results;
|
||||
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;
|
||||
})
|
||||
|
||||
// 创建聊天
|
||||
@ -123,8 +155,15 @@ const chatSlice = createSlice({
|
||||
})
|
||||
.addCase(deleteChat.fulfilled, (state, action) => {
|
||||
state.operations.status = 'succeeded';
|
||||
// 更新旧的状态结构
|
||||
state.list.items = state.list.items.filter((chat) => chat.id !== action.payload);
|
||||
state.list.total -= 1;
|
||||
// 更新新的状态结构
|
||||
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;
|
||||
}
|
||||
@ -185,6 +224,104 @@ const chatSlice = createSlice({
|
||||
.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) => {
|
||||
state.sendMessage.status = 'succeeded';
|
||||
// 添加新的消息
|
||||
state.messages.items.push({
|
||||
id: action.payload.id,
|
||||
role: 'user',
|
||||
content: action.meta.arg.question,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 添加助手回复
|
||||
if (action.payload.role === 'assistant' && action.payload.content) {
|
||||
state.messages.items.push({
|
||||
id: action.payload.id,
|
||||
role: 'assistant',
|
||||
content: action.payload.content,
|
||||
created_at: action.payload.created_at,
|
||||
});
|
||||
}
|
||||
|
||||
// 更新聊天记录列表
|
||||
const chatExists = state.history.items.some(
|
||||
(chat) => chat.conversation_id === action.payload.conversation_id
|
||||
);
|
||||
|
||||
if (!chatExists) {
|
||||
const newChat = {
|
||||
conversation_id: action.payload.conversation_id,
|
||||
last_message: action.payload.content,
|
||||
last_time: action.payload.created_at,
|
||||
datasets: [
|
||||
{
|
||||
id: action.payload.dataset_id,
|
||||
name: action.payload.dataset_name,
|
||||
},
|
||||
],
|
||||
dataset_id_list: action.payload.dataset_id_list,
|
||||
message_count: 2, // 用户问题和助手回复
|
||||
};
|
||||
|
||||
state.history.items.unshift(newChat);
|
||||
} else {
|
||||
// 更新已存在聊天的最后消息和时间
|
||||
const chatIndex = state.history.items.findIndex(
|
||||
(chat) => chat.conversation_id === action.payload.conversation_id
|
||||
);
|
||||
|
||||
if (chatIndex !== -1) {
|
||||
state.history.items[chatIndex].last_message = action.payload.content;
|
||||
state.history.items[chatIndex].last_time = action.payload.created_at;
|
||||
state.history.items[chatIndex].message_count += 2; // 新增用户问题和助手回复
|
||||
}
|
||||
}
|
||||
})
|
||||
.addCase(createChatRecord.rejected, (state, action) => {
|
||||
state.sendMessage.status = 'failed';
|
||||
state.sendMessage.error = action.payload || '创建聊天记录失败';
|
||||
})
|
||||
|
||||
// 处理获取可用知识库
|
||||
.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;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { get, post, put, del } from '../../services/api';
|
||||
import { showNotification } from '../notification.slice';
|
||||
|
||||
/**
|
||||
* 获取聊天列表
|
||||
@ -7,23 +8,26 @@ import { get, post, put, del } from '../../services/api';
|
||||
* @param {number} params.page - 页码
|
||||
* @param {number} params.page_size - 每页数量
|
||||
*/
|
||||
export const fetchChats = createAsyncThunk(
|
||||
'chat/fetchChats',
|
||||
async (params = { page: 1, page_size: 10 }, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await get('/chat-history/', { params });
|
||||
export const fetchChats = createAsyncThunk('chat/fetchChats', async (params = {}, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await get('/chat-history/', { params });
|
||||
|
||||
// 处理返回格式
|
||||
if (response && response.code === 200) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
return response.data || { results: [], total: 0, page: 1, page_size: 10 };
|
||||
} catch (error) {
|
||||
return rejectWithValue(error.response?.data || 'Failed to fetch chats');
|
||||
// 处理返回格式
|
||||
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');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 创建新聊天
|
||||
@ -69,19 +73,105 @@ export const updateChat = createAsyncThunk('chat/updateChat', async ({ id, data
|
||||
|
||||
/**
|
||||
* 删除聊天
|
||||
* @param {string} id - 聊天ID
|
||||
* @param {string} conversationId - 聊天ID
|
||||
*/
|
||||
export const deleteChat = createAsyncThunk('chat/deleteChat', async (id, { rejectWithValue }) => {
|
||||
export const deleteChat = createAsyncThunk('chat/deleteChat', async (conversationId, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await del(`/chat-history/${id}/`);
|
||||
const response = await del(`/chat-history/conversation/${conversationId}/`);
|
||||
|
||||
// 处理返回格式
|
||||
if (response && response.code === 200) {
|
||||
return id;
|
||||
return conversationId;
|
||||
}
|
||||
|
||||
return id;
|
||||
return conversationId;
|
||||
} catch (error) {
|
||||
return rejectWithValue(error.response?.data || 'Failed to delete chat');
|
||||
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 (params, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await post('/chat-history/', {
|
||||
dataset_id_list: params.dataset_id_list,
|
||||
question: params.question,
|
||||
conversation_id: params.conversation_id,
|
||||
});
|
||||
|
||||
// 处理返回格式
|
||||
if (response && response.code === 200) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
return rejectWithValue(response.message || '创建聊天记录失败');
|
||||
} catch (error) {
|
||||
console.error('Error creating chat record:', error);
|
||||
return rejectWithValue(error.response?.data?.message || '创建聊天记录失败');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取会话详情
|
||||
* @param {string} conversationId - 会话ID
|
||||
*/
|
||||
export const fetchConversationDetail = createAsyncThunk(
|
||||
'chat/fetchConversationDetail',
|
||||
async (conversationId, { rejectWithValue, dispatch }) => {
|
||||
try {
|
||||
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) {
|
||||
// 如果是新聊天,API会返回404,此时不返回错误
|
||||
if (error.response && error.response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
console.error('Error fetching conversation detail:', error);
|
||||
return rejectWithValue(error.response?.data?.message || '获取会话详情失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@ -7,6 +7,7 @@ import {
|
||||
changeKnowledgeBaseType,
|
||||
searchKnowledgeBases,
|
||||
requestKnowledgeBaseAccess,
|
||||
getKnowledgeBaseById,
|
||||
} from './knowledgeBase.thunks';
|
||||
|
||||
const initialState = {
|
||||
@ -160,6 +161,20 @@ const knowledgeBaseSlice = createSlice({
|
||||
})
|
||||
.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';
|
||||
});
|
||||
},
|
||||
});
|
||||
|
@ -10,6 +10,7 @@ const initialState = {
|
||||
content: '张三请求访问销售数据集',
|
||||
time: '10分钟前',
|
||||
hasDetail: true,
|
||||
isRead: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
@ -19,6 +20,7 @@ const initialState = {
|
||||
content: '系统将在今晚23:00进行例行维护',
|
||||
time: '1小时前',
|
||||
hasDetail: false,
|
||||
isRead: false,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
@ -28,6 +30,7 @@ const initialState = {
|
||||
content: '李四请求访问用户数据集',
|
||||
time: '2小时前',
|
||||
hasDetail: true,
|
||||
isRead: false,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
@ -37,6 +40,7 @@ const initialState = {
|
||||
content: '检测到异常登录行为,请及时查看',
|
||||
time: '3小时前',
|
||||
hasDetail: true,
|
||||
isRead: false,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
@ -46,8 +50,11 @@ const initialState = {
|
||||
content: '管理员修改了您的数据访问权限',
|
||||
time: '1天前',
|
||||
hasDetail: true,
|
||||
isRead: false,
|
||||
},
|
||||
],
|
||||
unreadCount: 5,
|
||||
isConnected: false,
|
||||
};
|
||||
|
||||
const notificationCenterSlice = createSlice({
|
||||
@ -56,9 +63,61 @@ const notificationCenterSlice = createSlice({
|
||||
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 } = notificationCenterSlice.actions;
|
||||
export const {
|
||||
clearNotifications,
|
||||
addNotification,
|
||||
markNotificationAsRead,
|
||||
markAllNotificationsAsRead,
|
||||
setWebSocketConnected,
|
||||
removeNotification,
|
||||
} = notificationCenterSlice.actions;
|
||||
|
||||
export default notificationCenterSlice.reducer;
|
||||
|
@ -5,6 +5,7 @@ import {
|
||||
fetchPermissionsThunk,
|
||||
approvePermissionThunk,
|
||||
rejectPermissionThunk,
|
||||
fetchAllUserPermissions,
|
||||
} from './permissions.thunks';
|
||||
|
||||
const initialState = {
|
||||
@ -13,6 +14,14 @@ const initialState = {
|
||||
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,
|
||||
@ -118,6 +127,23 @@ const permissionsSlice = createSlice({
|
||||
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;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
@ -7,7 +7,7 @@ export const fetchPermissionsThunk = createAsyncThunk(
|
||||
'permissions/fetchPermissions',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const { data, message, code } = await get('/permissions/pending/');
|
||||
const { data, message, code } = await get('/permissions/');
|
||||
|
||||
if (code === 200) {
|
||||
return data.items || [];
|
||||
@ -136,3 +136,27 @@ export const updateUserPermissions = createAsyncThunk(
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 获取所有用户及其权限列表
|
||||
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 || '获取用户权限列表失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
Loading…
Reference in New Issue
Block a user