[dev]knowledgebase & chat API test and changes

This commit is contained in:
susie-laptop 2025-03-24 20:04:35 -04:00
parent fbfff98123
commit 1ba460b4cf
24 changed files with 1631 additions and 513 deletions

View File

@ -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);

View File

@ -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
@ -21,14 +32,30 @@ const CreateKnowledgeBaseModal = ({
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}>

View File

@ -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>
{/* 使用滑动面板组件 */}

View File

@ -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'

View File

@ -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>

View File

@ -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 (

View File

@ -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>

View File

@ -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) => {
// API404
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(() => {
//
// URLID
})
.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>
))}

View File

@ -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>

View File

@ -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]);

View File

@ -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({

View File

@ -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 && (

View File

@ -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);
};

View File

@ -21,10 +21,11 @@ export default function PermissionsPage() {
<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>
);
}

View File

@ -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}>

View File

@ -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
View 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 || {},
};
};

View File

@ -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(

View File

@ -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;
});
},
});

View File

@ -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 || '获取会话详情失败');
}
}
);

View File

@ -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';
});
},
});

View File

@ -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;

View File

@ -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;
});
},
});

View File

@ -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 || '获取用户权限列表失败');
}
}
);