[dev]notificationcenter & setting

This commit is contained in:
susie-laptop 2025-03-19 22:01:09 -04:00
parent 523c474001
commit 6f48ff656b
18 changed files with 966 additions and 442 deletions

12
public/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OOIN 智能知识库</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,214 @@
import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { clearNotifications } 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';
export default function NotificationCenter({ show, onClose }) {
const [showAll, setShowAll] = useState(false);
const dispatch = useDispatch();
const { notifications } = 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 displayedNotifications = showAll ? notifications : notifications.slice(0, 2);
const handleClearAll = () => {
dispatch(clearNotifications());
};
const handleViewDetail = (notification) => {
if (notification.type === 'permission') {
setSelectedRequest(notification);
setShowSlideOver(true);
}
};
const handleCloseSlideOver = () => {
setShowSlideOver(false);
setTimeout(() => {
setSelectedRequest(null);
}, 300);
};
const handleOpenResponseInput = (requestId, approving) => {
setCurrentRequestId(requestId);
setIsApproving(approving);
setShowResponseInput(true);
};
const handleCloseResponseInput = () => {
setShowResponseInput(false);
setCurrentRequestId(null);
setResponseMessage('');
};
const handleProcessRequest = () => {
if (!currentRequestId) return;
const params = {
id: currentRequestId,
responseMessage,
};
if (isApproving) {
dispatch(approvePermissionThunk(params));
} else {
dispatch(rejectPermissionThunk(params));
}
};
if (!show) return null;
return (
<>
<div
className='notification-center card shadow-lg'
style={{
position: 'fixed',
top: '60px',
right: '20px',
width: '400px',
zIndex: 1050,
backgroundColor: 'white',
maxHeight: '80vh',
display: 'flex',
flexDirection: 'column',
}}
>
<div className='card-header bg-white border-0 d-flex justify-content-between align-items-center py-3'>
<h6 className='mb-0'>通知中心</h6>
<div className='d-flex gap-3 align-items-center'>
<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>
</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>
</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>
</div>
{/* 使用滑动面板组件 */}
<RequestDetailSlideOver
show={showSlideOver}
onClose={handleCloseSlideOver}
request={selectedRequest}
onApprove={(id) => handleOpenResponseInput(id, true)}
onReject={(id) => handleOpenResponseInput(id, false)}
processingId={currentRequestId}
approveRejectStatus={showResponseInput ? 'loading' : 'idle'}
isApproving={isApproving}
/>
{/* 回复输入弹窗 */}
{showResponseInput && (
<div className='modal fade show' style={{ display: 'block' }} tabIndex='-1'>
<div className='modal-dialog'>
<div className='modal-content'>
<div className='modal-header'>
<h5 className='modal-title'>{isApproving ? '批准' : '拒绝'}申请</h5>
<button
type='button'
className='btn-close'
onClick={handleCloseResponseInput}
disabled={showResponseInput}
></button>
</div>
<div className='modal-body'>
<div className='mb-3'>
<label htmlFor='responseMessage' className='form-label'>
审批意见
</label>
<textarea
className='form-control'
id='responseMessage'
rows='3'
value={responseMessage}
onChange={(e) => setResponseMessage(e.target.value)}
placeholder={isApproving ? '请输入批准意见(可选)' : '请输入拒绝理由(可选)'}
></textarea>
</div>
</div>
<div className='modal-footer'>
<button
type='button'
className='btn btn-secondary'
onClick={handleCloseResponseInput}
disabled={showResponseInput}
>
取消
</button>
<button
type='button'
className={`btn ${isApproving ? 'btn-success' : 'btn-danger'}`}
onClick={handleProcessRequest}
disabled={showResponseInput}
>
{showResponseInput ? (
<>
<span
className='spinner-border spinner-border-sm me-1'
role='status'
aria-hidden='true'
></span>
处理中...
</>
) : isApproving ? (
'确认批准'
) : (
'确认拒绝'
)}
</button>
</div>
</div>
</div>
<div className='modal-backdrop fade show'></div>
</div>
)}
</>
);
}

View File

@ -0,0 +1,101 @@
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import '../styles/style.scss';
export default function UserSettingsModal({ show, onClose }) {
const { user } = useSelector((state) => state.auth);
const [lastPasswordChange] = useState('30天前'); // This would come from backend in real app
if (!show) return null;
return (
<div className='modal show d-block' style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className='modal-dialog modal-dialog-centered'>
<div className='modal-content'>
<div className='modal-header border-0'>
<h5 className='modal-title'>管理员个人设置</h5>
<button type='button' className='btn-close' onClick={onClose}></button>
</div>
<div className='modal-body'>
<div className='mb-4'>
<h6 className='text-secondary mb-3'>个人信息</h6>
<div className='mb-3'>
<label className='form-label text-secondary'>姓名</label>
<input type='text' className='form-control' value={user?.username || ''} readOnly />
</div>
<div className='mb-3'>
<label className='form-label text-secondary'>邮箱</label>
<input
type='email'
className='form-control'
value={user?.email || 'admin@ooin.com'}
readOnly
/>
</div>
</div>
<div className='mb-4'>
<h6 className='text-secondary mb-3'>安全设置</h6>
<div className='d-flex justify-content-between align-items-center p-3 bg-light rounded'>
<div>
<div className='d-flex align-items-center gap-2'>
<i className='bi bi-key'></i>
<span>修改密码</span>
</div>
<small className='text-secondary'>上次修改{lastPasswordChange}</small>
</div>
<button className='btn btn-outline-dark btn-sm'>修改</button>
</div>
<div className='d-flex justify-content-between align-items-center p-3 bg-light rounded mt-3'>
<div>
<div className='d-flex align-items-center gap-2'>
<i className='bi bi-shield-check'></i>
<span>双重认证</span>
</div>
<small className='text-secondary'>增强账户安全性</small>
</div>
<button className='btn btn-outline-dark btn-sm'>设置</button>
</div>
</div>
<div>
<h6 className='text-secondary mb-3'>通知设置</h6>
<div className='form-check form-switch mb-3 dark-switch'>
<input
className='form-check-input'
type='checkbox'
id='notificationSwitch1'
defaultChecked
/>
<label className='form-check-label' htmlFor='notificationSwitch1'>
访问请求通知
</label>
<div className='text-secondary small'>新的数据集访问申请通知</div>
</div>
<div className='form-check form-switch dark-switch'>
<input
className='form-check-input'
type='checkbox'
id='notificationSwitch2'
defaultChecked
/>
<label className='form-check-label' htmlFor='notificationSwitch2'>
安全提醒
</label>
<div className='text-secondary small'>异常登录和权限变更提醒</div>
</div>
</div>
</div>
<div className='modal-footer border-0'>
<button type='button' className='btn btn-outline-dark' onClick={onClose}>
取消
</button>
<button type='button' className='btn btn-dark'>
保存更改
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -101,5 +101,7 @@ export const icons = {
chat: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16" height="16" fill='currentColor'><path d="M123.6 391.3c12.9-9.4 29.6-11.8 44.6-6.4c26.5 9.6 56.2 15.1 87.8 15.1c124.7 0 208-80.5 208-160s-83.3-160-208-160S48 160.5 48 240c0 32 12.4 62.8 35.7 89.2c8.6 9.7 12.8 22.5 11.8 35.5c-1.4 18.1-5.7 34.7-11.3 49.4c17-7.9 31.1-16.7 39.4-22.7zM21.2 431.9c1.8-2.7 3.5-5.4 5.1-8.1c10-16.6 19.5-38.4 21.4-62.9C17.7 326.8 0 285.1 0 240C0 125.1 114.6 32 256 32s256 93.1 256 208s-114.6 208-256 208c-37.1 0-72.3-6.4-104.1-17.9c-11.9 8.7-31.3 20.6-54.3 30.6c-15.1 6.6-32.3 12.6-50.1 16.1c-.8 .2-1.6 .3-2.4 .5c-4.4 .8-8.7 1.5-13.2 1.9c-.2 0-.5 .1-.7 .1c-5.1 .5-10.2 .8-15.3 .8c-6.5 0-12.3-3.9-14.8-9.9c-2.5-6-1.1-12.8 3.4-17.4c4.1-4.2 7.8-8.7 11.3-13.5c1.7-2.3 3.3-4.6 4.8-6.9l.3-.5z"/></svg>`, chat: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16" height="16" fill='currentColor'><path d="M123.6 391.3c12.9-9.4 29.6-11.8 44.6-6.4c26.5 9.6 56.2 15.1 87.8 15.1c124.7 0 208-80.5 208-160s-83.3-160-208-160S48 160.5 48 240c0 32 12.4 62.8 35.7 89.2c8.6 9.7 12.8 22.5 11.8 35.5c-1.4 18.1-5.7 34.7-11.3 49.4c17-7.9 31.1-16.7 39.4-22.7zM21.2 431.9c1.8-2.7 3.5-5.4 5.1-8.1c10-16.6 19.5-38.4 21.4-62.9C17.7 326.8 0 285.1 0 240C0 125.1 114.6 32 256 32s256 93.1 256 208s-114.6 208-256 208c-37.1 0-72.3-6.4-104.1-17.9c-11.9 8.7-31.3 20.6-54.3 30.6c-15.1 6.6-32.3 12.6-50.1 16.1c-.8 .2-1.6 .3-2.4 .5c-4.4 .8-8.7 1.5-13.2 1.9c-.2 0-.5 .1-.7 .1c-5.1 .5-10.2 .8-15.3 .8c-6.5 0-12.3-3.9-14.8-9.9c-2.5-6-1.1-12.8 3.4-17.4c4.1-4.2 7.8-8.7 11.3-13.5c1.7-2.3 3.3-4.6 4.8-6.9l.3-.5z"/></svg>`,
'arrowup-upload': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" width="16" height="16" fill='currentColor'><path d="M246.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-128 128c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 109.3 192 320c0 17.7 14.3 32 32 32s32-14.3 32-32l0-210.7 73.4 73.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-128-128zM64 352c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 64c0 53 43 96 96 96l256 0c53 0 96-43 96-96l0-64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 64c0 17.7-14.3 32-32 32L96 448c-17.7 0-32-14.3-32-32l0-64z"/></svg>`, 'arrowup-upload': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" width="16" height="16" fill='currentColor'><path d="M246.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-128 128c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 109.3 192 320c0 17.7 14.3 32 32 32s32-14.3 32-32l0-210.7 73.4 73.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-128-128zM64 352c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 64c0 53 43 96 96 96l256 0c53 0 96-43 96-96l0-64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 64c0 17.7-14.3 32-32 32L96 448c-17.7 0-32-14.3-32-32l0-64z"/></svg>`,
send: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16" height="16" fill='currentColor'><path d="M498.1 5.6c10.1 7 15.4 19.1 13.5 31.2l-64 416c-1.5 9.7-7.4 18.2-16 23s-18.9 5.4-28 1.6L284 427.7l-68.5 74.1c-8.9 9.7-22.9 12.9-35.2 8.1S160 493.2 160 480V396.4c0-4 1.5-7.8 4.2-10.7L331.8 202.8c5.8-6.3 5.6-16-.4-22s-15.7-6.4-22-.7L106 360.8 17.7 316.6C7.1 311.3 .3 300.7 0 288.9s5.9-22.8 16.1-28.7l448-256c10.7-6.1 23.9-5.5 34 1.4z"/></svg>`, send: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16" height="16" fill='currentColor'><path d="M498.1 5.6c10.1 7 15.4 19.1 13.5 31.2l-64 416c-1.5 9.7-7.4 18.2-16 23s-18.9 5.4-28 1.6L284 427.7l-68.5 74.1c-8.9 9.7-22.9 12.9-35.2 8.1S160 493.2 160 480V396.4c0-4 1.5-7.8 4.2-10.7L331.8 202.8c5.8-6.3 5.6-16-.4-22s-15.7-6.4-22-.7L106 360.8 17.7 316.6C7.1 311.3 .3 300.7 0 288.9s5.9-22.8 16.1-28.7l448-256c10.7-6.1 23.9-5.5 34 1.4z"/></svg>`,
search: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16" height="16" fill='currentColor'><path d="M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM208 352a144 144 0 1 0 0-288 144 144 0 1 0 0 288z"/></svg>` search: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16" height="16" fill='currentColor'><path d="M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM208 352a144 144 0 1 0 0-288 144 144 0 1 0 0 288z"/></svg>`,
bell: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" width="16" height="16" fill='currentColor'><path d="M224 0c-17.7 0-32 14.3-32 32l0 19.2C119 66 64 130.6 64 208l0 18.8c0 47-17.3 92.4-48.5 127.6l-7.4 8.3c-8.4 9.4-10.4 22.9-5.3 34.4S19.4 416 32 416l384 0c12.6 0 24-7.4 29.2-18.9s3.1-25-5.3-34.4l-7.4-8.3C401.3 319.2 384 273.9 384 226.8l0-18.8c0-77.4-55-142-128-156.8L256 32c0-17.7-14.3-32-32-32zm45.3 493.3c12-12 18.7-28.3 18.7-45.3l-64 0-64 0c0 17 6.7 33.3 18.7 45.3s28.3 18.7 45.3 18.7s33.3-6.7 45.3-18.7z"/></svg>`,
'magnifying-glass': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16" height="16" fill='currentColor'><path d="M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM208 352a144 144 0 1 0 0-288 144 144 0 1 0 0 288z"/></svg>`
}; };

View File

@ -1,13 +1,19 @@
import React from 'react'; import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { Link, useNavigate, useLocation } from 'react-router-dom'; import { Link, useNavigate, useLocation } from 'react-router-dom';
import { logoutThunk } from '../store/auth/auth.thunk'; import { logoutThunk } from '../store/auth/auth.thunk';
import UserSettingsModal from '../components/UserSettingsModal';
import NotificationCenter from '../components/NotificationCenter';
import SvgIcon from '../components/SvgIcon';
export default function HeaderWithNav() { export default function HeaderWithNav() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { user } = useSelector((state) => state.auth); const { user } = useSelector((state) => state.auth);
const [showSettings, setShowSettings] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const { notifications } = useSelector((state) => state.notificationCenter);
const handleLogout = async () => { const handleLogout = async () => {
try { try {
@ -27,8 +33,8 @@ export default function HeaderWithNav() {
const hasManagePermission = user && (user.role === 'leader' || user.role === 'admin'); const hasManagePermission = user && (user.role === 'leader' || user.role === 'admin');
return ( return (
<header className=' navbar navbar-expand-lg p-0'> <header>
<nav className='navbar navbar-expand-lg border-bottom p-3 w-100'> <nav className='navbar navbar-expand-lg bg-white shadow-sm'>
<div className='container-fluid'> <div className='container-fluid'>
<Link className='navbar-brand' to='/'> <Link className='navbar-brand' to='/'>
OOIN 智能知识库 OOIN 智能知识库
@ -74,43 +80,57 @@ export default function HeaderWithNav() {
)} )}
</ul> </ul>
{!!user ? ( {!!user ? (
<div className='flex-shrink-0 dropdown'> <div className='d-flex align-items-center gap-3'>
<a <div className='position-relative'>
href='#' <button
className='d-block link-dark text-decoration-none dropdown-toggle' className='btn btn-link text-dark p-0'
data-bs-toggle='dropdown' onClick={() => setShowNotifications(!showNotifications)}
aria-expanded='false' >
> <SvgIcon className={'bell'} />
Hi, {user.username} {notifications.length > 0 && (
</a> <span className='position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger'>
<ul {notifications.length}
className='dropdown-menu text-small shadow' </span>
style={{ )}
position: 'absolute', </button>
inset: '0px 0px auto auto', </div>
margin: '0px', <div className='flex-shrink-0 dropdown'>
transform: 'translate(0px, 34px)', <a
}} href='#'
> className='d-block link-dark text-decoration-none dropdown-toggle'
{/* <li> data-bs-toggle='dropdown'
<Link className='dropdown-item' to='#'> aria-expanded='false'
Settings >
</Link> Hi, {user.username}
</li> */} </a>
{/* <li> <ul
<Link className='dropdown-item' to='#'> className='dropdown-menu text-small shadow'
Profile style={{
</Link> position: 'absolute',
</li> */} inset: '0px 0px auto auto',
<li> margin: '0px',
<hr className='dropdown-divider' /> transform: 'translate(0px, 34px)',
</li> }}
<li> >
<Link className='dropdown-item' to='#' onClick={handleLogout}> <li>
Sign out <Link
</Link> className='dropdown-item'
</li> to='#'
</ul> onClick={() => setShowSettings(true)}
>
个人设置
</Link>
</li>
<li>
<hr className='dropdown-divider' />
</li>
<li>
<Link className='dropdown-item' to='#' onClick={handleLogout}>
退出登录
</Link>
</li>
</ul>
</div>
</div> </div>
) : ( ) : (
<> <>
@ -132,6 +152,8 @@ export default function HeaderWithNav() {
</div> </div>
</div> </div>
</nav> </nav>
<UserSettingsModal show={showSettings} onClose={() => setShowSettings(false)} />
<NotificationCenter show={showNotifications} onClose={() => setShowNotifications(false)} />
</header> </header>
); );
} }

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { fetchMessages, sendMessage } from '../../store/chat/chat.messages.thunks'; import { fetchMessages, sendMessage } from '../../store/chat/chat.messages.thunks';
import { resetMessages, resetSendMessageStatus } from '../../store/chat/chat.slice'; import { resetMessages, resetSendMessageStatus, addMessage } from '../../store/chat/chat.slice';
import { showNotification } from '../../store/notification.slice'; import { showNotification } from '../../store/notification.slice';
import SvgIcon from '../../components/SvgIcon'; import SvgIcon from '../../components/SvgIcon';
import { fetchKnowledgeBases } from '../../store/knowledgeBase/knowledgeBase.thunks'; import { fetchKnowledgeBases } from '../../store/knowledgeBase/knowledgeBase.thunks';
@ -65,9 +65,20 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
if (!inputMessage.trim() || sendStatus === 'loading') return; if (!inputMessage.trim() || sendStatus === 'loading') return;
// //
dispatch(sendMessage({ chatId, content: inputMessage })); const userMessage = {
id: Date.now(),
content: inputMessage,
sender: 'user',
timestamp: new Date().toISOString(),
};
dispatch(addMessage(userMessage));
//
setInputMessage(''); setInputMessage('');
//
dispatch(sendMessage({ chatId, content: inputMessage }));
}; };
// //
@ -125,19 +136,22 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
} mb-3`} } mb-3`}
> >
<div <div
className={`p-3 rounded-3 ${ className={`chat-message p-3 rounded-3 ${
message.sender === 'user' ? 'bg-primary text-white' : 'bg-white border' message.sender === 'user' ? 'bg-dark text-white' : 'bg-light'
}`} }`}
style={{ maxWidth: '75%' }} style={{
maxWidth: '75%',
position: 'relative',
}}
> >
{message.content} <div className='message-content'>{message.content}</div>
</div> </div>
</div> </div>
))} ))}
{sendStatus === 'loading' && ( {sendStatus === 'loading' && (
<div className='d-flex justify-content-start mb-3'> <div className='d-flex justify-content-start mb-3'>
<div className='p-3 rounded-3 bg-white border'> <div className='chat-message p-3 rounded-3 bg-light'>
<div className='spinner-border spinner-border-sm text-secondary' role='status'> <div className='spinner-border spinner-border-sm text-secondary' role='status'>
<span className='visually-hidden'>加载中...</span> <span className='visually-hidden'>加载中...</span>
</div> </div>

View File

@ -56,7 +56,7 @@ export default function NewChat() {
return ( return (
<div className='container-fluid px-4 py-5'> <div className='container-fluid px-4 py-5'>
<h4 className='mb-4'>选择知识库开始聊天</h4> <h4 className='mb-4'>选择知识库开始聊天</h4>
<div className='row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4'> <div className='row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4 justify-content-center'>
{readableKnowledgeBases.length > 0 ? ( {readableKnowledgeBases.length > 0 ? (
readableKnowledgeBases.map((kb) => ( readableKnowledgeBases.map((kb) => (
<div key={kb.id} className='col'> <div key={kb.id} className='col'>

View File

@ -19,9 +19,9 @@ export default function PermissionsPage() {
return ( return (
<div className='permissions-container'> <div className='permissions-container'>
<div className='api-mode-control mb-3'> {/* <div className='api-mode-control mb-3'>
<ApiModeSwitch /> <ApiModeSwitch />
</div> </div> */}
<div className='permissions-section mb-4'> <div className='permissions-section mb-4'>
<PendingRequests /> <PendingRequests />

View File

@ -1,20 +1,23 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { showNotification } from '../../../store/notification.slice'; import { showNotification } from '../../../store/notification.slice';
import { import {
fetchPermissionsThunk, fetchPermissionsThunk,
approvePermissionThunk, approvePermissionThunk,
rejectPermissionThunk, rejectPermissionThunk,
} from '../../../store/permissions/permissions.thunks'; } from '../../../store/permissions/permissions.thunks';
import { resetApproveRejectStatus } from '../../../store/permissions/permissions.slice'; import { resetOperationStatus } from '../../../store/permissions/permissions.slice';
import './PendingRequests.css'; // CSS import './PendingRequests.css'; // CSS
import SvgIcon from '../../../components/SvgIcon'; import SvgIcon from '../../../components/SvgIcon';
import RequestDetailSlideOver from './RequestDetailSlideOver';
// //
const PAGE_SIZE = 5; const PAGE_SIZE = 5;
export default function PendingRequests() { export default function PendingRequests() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const location = useLocation();
const [responseMessage, setResponseMessage] = useState(''); const [responseMessage, setResponseMessage] = useState('');
const [showResponseInput, setShowResponseInput] = useState(false); const [showResponseInput, setShowResponseInput] = useState(false);
const [currentRequestId, setCurrentRequestId] = useState(null); const [currentRequestId, setCurrentRequestId] = useState(null);
@ -59,6 +62,17 @@ export default function PendingRequests() {
fetchData(); fetchData();
}, [dispatch]); }, [dispatch]);
//
useEffect(() => {
if (location.state?.showRequestDetail) {
const { requestId, requestData } = location.state;
setSelectedRequest(requestData);
setShowSlideOver(true);
// location state
window.history.replaceState({}, document.title);
}
}, [location.state]);
// / // /
useEffect(() => { useEffect(() => {
if (approveRejectStatus === 'succeeded') { if (approveRejectStatus === 'succeeded') {
@ -87,7 +101,7 @@ export default function PendingRequests() {
} }
// //
dispatch(resetApproveRejectStatus()); dispatch(resetOperationStatus());
} else if (approveRejectStatus === 'failed') { } else if (approveRejectStatus === 'failed') {
dispatch( dispatch(
showNotification({ showNotification({
@ -96,7 +110,7 @@ export default function PendingRequests() {
}) })
); );
// //
dispatch(resetApproveRejectStatus()); dispatch(resetOperationStatus());
} }
}, [ }, [
approveRejectStatus, approveRejectStatus,
@ -286,7 +300,7 @@ export default function PendingRequests() {
</div> </div>
<div className='request-actions'> <div className='request-actions'>
<button <button
className='btn btn-outline-danger' className='btn btn-outline-danger btn-sm'
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDirectProcess(request.id, false); handleDirectProcess(request.id, false);
@ -298,7 +312,7 @@ export default function PendingRequests() {
: '拒绝'} : '拒绝'}
</button> </button>
<button <button
className='btn btn-success' className='btn btn-outline-success btn-sm'
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDirectProcess(request.id, true); handleDirectProcess(request.id, true);
@ -317,113 +331,17 @@ export default function PendingRequests() {
{/* 分页控件 */} {/* 分页控件 */}
{renderPagination()} {renderPagination()}
{/* 滑动面板 */} {/* 使用新的滑动面板组件 */}
<div className={`slide-over-backdrop ${showSlideOver ? 'show' : ''}`} onClick={handleCloseSlideOver}></div> <RequestDetailSlideOver
<div className={`slide-over ${showSlideOver ? 'show' : ''}`}> show={showSlideOver}
{selectedRequest && ( onClose={handleCloseSlideOver}
<div className='slide-over-content'> request={selectedRequest}
<div className='slide-over-header'> onApprove={(id) => handleOpenResponseInput(id, true)}
<h5 className='mb-0'>申请详情</h5> onReject={(id) => handleOpenResponseInput(id, false)}
<button type='button' className='btn-close' onClick={handleCloseSlideOver}></button> processingId={processingId}
</div> approveRejectStatus={approveRejectStatus}
<div className='slide-over-body'> isApproving={isApproving}
<div className='mb-4'> />
<h6 className='text-muted mb-2'>申请人信息</h6>
<div className='d-flex align-items-center mb-3'>
<div className='avatar-placeholder me-3 bg-dark'>
{selectedRequest.applicant.charAt(0)}
</div>
<div>
<h5 className='mb-1'>{selectedRequest.applicant}</h5>
</div>
</div>
</div>
<div className='mb-4'>
<h6 className='text-muted mb-2'>知识库信息</h6>
<p className='mb-1'>
<strong>ID</strong> {selectedRequest.knowledge_base}
</p>
</div>
<div className='mb-4'>
<h6 className='text-muted mb-2'>申请权限</h6>
<div className='d-flex flex-wrap gap-2 mb-3'>
{selectedRequest.permissions.can_read && !selectedRequest.permissions.can_edit && (
<span className='badge bg-warning-subtle text-warning d-flex align-items-center gap-1'>
只读
</span>
)}
{selectedRequest.permissions.can_edit && selectedRequest.permissions.can_delete && (
<span className='badge bg-success-subtle text-success d-flex align-items-center gap-1'>
完全访问
</span>
)}
</div>
</div>
<div className='mb-4'>
<h6 className='text-muted mb-2'>申请时间</h6>
<p className='mb-1'>{new Date(selectedRequest.created_at).toLocaleString()}</p>
</div>
{selectedRequest.expires_at && (
<div className='mb-4'>
<h6 className='text-muted mb-2'>到期时间</h6>
<p className='mb-1'>{new Date(selectedRequest.expires_at).toLocaleString()}</p>
</div>
)}
<div className='mb-4'>
<h6 className='text-muted mb-2'>申请理由</h6>
<div className='p-3 bg-light rounded'>{selectedRequest.reason || '无申请理由'}</div>
</div>
</div>
<div className='slide-over-footer'>
<button
className='btn btn-outline-danger me-2'
onClick={() => handleOpenResponseInput(selectedRequest.id, false)}
disabled={processingId === selectedRequest.id && approveRejectStatus === 'loading'}
>
{processingId === selectedRequest.id &&
approveRejectStatus === 'loading' &&
!isApproving ? (
<>
<span
className='spinner-border spinner-border-sm me-1'
role='status'
aria-hidden='true'
></span>
处理中...
</>
) : (
<>拒绝</>
)}
</button>
<button
className='btn btn-success'
onClick={() => handleOpenResponseInput(selectedRequest.id, true)}
disabled={processingId === selectedRequest.id && approveRejectStatus === 'loading'}
>
{processingId === selectedRequest.id &&
approveRejectStatus === 'loading' &&
isApproving ? (
<>
<span
className='spinner-border spinner-border-sm me-1'
role='status'
aria-hidden='true'
></span>
处理中...
</>
) : (
<>批准</>
)}
</button>
</div>
</div>
)}
</div>
{/* 回复输入弹窗 */} {/* 回复输入弹窗 */}
{showResponseInput && ( {showResponseInput && (

View File

@ -0,0 +1,124 @@
import React from 'react';
import SvgIcon from '../../../components/SvgIcon';
export default function RequestDetailSlideOver({
show,
onClose,
request,
onApprove,
onReject,
processingId,
approveRejectStatus,
isApproving,
}) {
if (!request) return null;
//
const applicant = request.applicant || request.title || '未知用户';
const applicantInitial = applicant.charAt(0);
return (
<>
<div className={`slide-over-backdrop ${show ? 'show' : ''}`} onClick={onClose}></div>
<div className={`slide-over ${show ? 'show' : ''}`}>
<div className='slide-over-content'>
<div className='slide-over-header'>
<h5 className='mb-0'>申请详情</h5>
<button type='button' className='btn-close' onClick={onClose}></button>
</div>
<div className='slide-over-body'>
<div className='mb-4'>
<h6 className='text-muted mb-2'>申请人信息</h6>
<div className='d-flex align-items-center mb-3'>
<div className='avatar-placeholder me-3 bg-dark'>{applicantInitial}</div>
<div>
<h5 className='mb-1'>{applicant}</h5>
</div>
</div>
</div>
<div className='mb-4'>
<h6 className='text-muted mb-2'>知识库信息</h6>
<p className='mb-1'>
<strong>ID</strong> {request.knowledge_base || request.content || '未知知识库'}
</p>
</div>
<div className='mb-4'>
<h6 className='text-muted mb-2'>申请权限</h6>
<div className='d-flex flex-wrap gap-2 mb-3'>
{request.permissions?.can_read && !request.permissions?.can_edit && (
<span className='badge bg-warning-subtle text-warning d-flex align-items-center gap-1'>
只读
</span>
)}
{request.permissions?.can_edit && request.permissions?.can_delete && (
<span className='badge bg-success-subtle text-success d-flex align-items-center gap-1'>
完全访问
</span>
)}
</div>
</div>
<div className='mb-4'>
<h6 className='text-muted mb-2'>申请时间</h6>
<p className='mb-1'>{new Date(request.created_at || Date.now()).toLocaleString()}</p>
</div>
{request.expires_at && (
<div className='mb-4'>
<h6 className='text-muted mb-2'>到期时间</h6>
<p className='mb-1'>{new Date(request.expires_at).toLocaleString()}</p>
</div>
)}
<div className='mb-4'>
<h6 className='text-muted mb-2'>申请理由</h6>
<div className='p-3 bg-light rounded'>
{request.reason || request.content || '无申请理由'}
</div>
</div>
</div>
<div className='slide-over-footer'>
<button
className='btn btn-outline-danger me-2'
onClick={() => onReject(request.id)}
disabled={processingId === request.id && approveRejectStatus === 'loading'}
>
{processingId === request.id && approveRejectStatus === 'loading' && !isApproving ? (
<>
<span
className='spinner-border spinner-border-sm me-1'
role='status'
aria-hidden='true'
></span>
处理中...
</>
) : (
<>拒绝</>
)}
</button>
<button
className='btn btn-outline-success'
onClick={() => onApprove(request.id)}
disabled={processingId === request.id && approveRejectStatus === 'loading'}
>
{processingId === request.id && approveRejectStatus === 'loading' && isApproving ? (
<>
<span
className='spinner-border spinner-border-sm me-1'
role='status'
aria-hidden='true'
></span>
处理中...
</>
) : (
<>批准</>
)}
</button>
</div>
</div>
</div>
</>
);
}

View File

@ -1,119 +1,15 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { get, put } from '../../../services/api'; import { fetchUserPermissions, updateUserPermissions } from '../../../store/permissions/permissions.thunks';
import { showNotification } from '../../../store/notification.slice';
import UserPermissionDetails from './UserPermissionDetails'; import UserPermissionDetails from './UserPermissionDetails';
import './UserPermissions.css'; import './UserPermissions.css';
import SvgIcon from '../../../components/SvgIcon'; import SvgIcon from '../../../components/SvgIcon';
//
const mockUsers = [
{
id: '1',
username: 'zhangsan',
name: '张三',
department: '达人组',
position: '达人对接',
permissions_count: {
read: 1,
edit: 0,
admin: 0,
},
},
{
id: '2',
username: 'lisi',
name: '李四',
department: '人力资源组',
position: 'HR',
permissions_count: {
read: 1,
edit: 0,
admin: 2,
},
},
{
id: '3',
username: 'wangwu',
name: '王五',
department: '市场部',
position: '市场专员',
permissions_count: {
read: 2,
edit: 1,
admin: 0,
},
},
{
id: '4',
username: 'zhaoliu',
name: '赵六',
department: '技术部',
position: '前端开发',
permissions_count: {
read: 3,
edit: 2,
admin: 1,
},
},
{
id: '5',
username: 'sunqi',
name: '孙七',
department: '产品部',
position: '产品经理',
permissions_count: {
read: 4,
edit: 2,
admin: 0,
},
},
{
id: '6',
username: 'zhouba',
name: '周八',
department: '设计部',
position: 'UI设计师',
permissions_count: {
read: 1,
edit: 1,
admin: 0,
},
},
{
id: '7',
username: 'wujiu',
name: '吴九',
department: '财务部',
position: '财务主管',
permissions_count: {
read: 2,
edit: 0,
admin: 3,
},
},
{
id: '8',
username: 'zhengshi',
name: '郑十',
department: '行政部',
position: '行政专员',
permissions_count: {
read: 1,
edit: 0,
admin: 0,
},
},
];
// //
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]; const PAGE_SIZE_OPTIONS = [5, 10, 15, 20];
export default function UserPermissions() { export default function UserPermissions() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedUser, setSelectedUser] = useState(null); const [selectedUser, setSelectedUser] = useState(null);
const [showDetailsModal, setShowDetailsModal] = useState(false); const [showDetailsModal, setShowDetailsModal] = useState(false);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
@ -123,40 +19,13 @@ export default function UserPermissions() {
const [pageSize, setPageSize] = useState(5); const [pageSize, setPageSize] = useState(5);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
// Redux store
const { items: users, status: loading, error } = useSelector((state) => state.permissions.users);
// //
useEffect(() => { useEffect(() => {
const fetchUsers = async () => { dispatch(fetchUserPermissions());
try { }, [dispatch]);
setLoading(true);
const response = await get('/users/permissions/');
if (response && response.code === 200) {
// API使
const apiUsers = response.data.users || [];
if (apiUsers.length > 0) {
setUsers(apiUsers);
} else {
console.log('API返回的用户列表为空使用模拟数据');
setUsers(mockUsers);
}
} else {
// API使
console.log('API请求失败使用模拟数据');
setUsers(mockUsers);
}
} catch (error) {
console.error('获取用户列表失败:', error);
setError('获取用户列表失败');
// API使
console.log('API请求出错使用模拟数据作为后备');
setUsers(mockUsers);
} finally {
setLoading(false);
}
};
fetchUsers();
}, []);
// //
useEffect(() => { useEffect(() => {
@ -183,52 +52,10 @@ export default function UserPermissions() {
// //
const handleSavePermissions = async (userId, updatedPermissions) => { const handleSavePermissions = async (userId, updatedPermissions) => {
try { try {
const response = await put(`/users/${userId}/permissions/`, { await dispatch(updateUserPermissions({ userId, permissions: updatedPermissions })).unwrap();
permissions: updatedPermissions, handleCloseDetailsModal();
});
if (response && response.code === 200) {
//
setUsers(
users.map((user) => {
if (user.id === userId) {
return {
...user,
permissions: {
...user.permissions,
...response.data.permissions,
},
};
}
return user;
})
);
dispatch(
showNotification({
message: '权限更新成功',
type: 'success',
})
);
//
handleCloseDetailsModal();
} else {
dispatch(
showNotification({
message: '权限更新失败',
type: 'danger',
})
);
}
} catch (error) { } catch (error) {
console.error('权限更新失败:', error); console.error('更新权限失败:', error);
dispatch(
showNotification({
message: '权限更新失败',
type: 'danger',
})
);
} }
}; };
@ -253,7 +80,6 @@ export default function UserPermissions() {
// //
const getFilteredUsers = () => { const getFilteredUsers = () => {
if (!searchTerm.trim()) return users; if (!searchTerm.trim()) return users;
return users.filter( return users.filter(
(user) => (user) =>
user.name.toLowerCase().includes(searchTerm.toLowerCase()) || user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
@ -278,7 +104,7 @@ export default function UserPermissions() {
return ( return (
<div className='d-flex justify-content-center align-items-center mt-4'> <div className='d-flex justify-content-center align-items-center mt-4'>
<nav aria-label='用户权限分页'> <nav aria-label='用户权限分页'>
<ul className='pagination'> <ul className='pagination dark-pagination'>
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}> <li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
<button <button
className='page-link' className='page-link'
@ -324,7 +150,7 @@ export default function UserPermissions() {
}; };
// //
if (loading && users.length === 0) { if (loading === 'loading' && users.length === 0) {
return ( return (
<div className='text-center py-5'> <div className='text-center py-5'>
<div className='spinner-border' role='status'> <div className='spinner-border' role='status'>
@ -373,7 +199,7 @@ export default function UserPermissions() {
onChange={handleSearchChange} onChange={handleSearchChange}
/> />
<span className='input-group-text'> <span className='input-group-text'>
<i className='bi bi-search'></i> <SvgIcon className='magnifying-glass' />
</span> </span>
</div> </div>
</div> </div>
@ -427,7 +253,7 @@ export default function UserPermissions() {
</span> </span>
)} )}
{user.permissions_count.admin > 0 && ( {user.permissions_count.admin > 0 && (
<span className='badge bg-danger d-flex align-items-center gap-1'> <span className='badge bg-dark-subtle d-flex align-items-center gap-1'>
无访问权限: {user.permissions_count.admin} 无访问权限: {user.permissions_count.admin}
</span> </span>
)} )}

View File

@ -610,12 +610,10 @@ export const mockGet = async (url, config = {}) => {
} }
return { return {
code: 200,
message: '获取成功',
data: { data: {
code: 200, messages: chatMessages[chatId] || [],
message: '获取成功',
data: {
messages: chatMessages[chatId] || [],
},
}, },
}; };
} }
@ -631,23 +629,19 @@ export const mockGet = async (url, config = {}) => {
); );
const result = paginate(filtered, page_size, page); const result = paginate(filtered, page_size, page);
return { return {
data: { code: 200,
code: 200, message: 'success',
message: 'success', data: result,
data: result,
},
}; };
} }
// 用户权限管理 - 获取用户列表 // 用户权限管理 - 获取用户列表
if (url === '/users/permissions/') { if (url === '/users/permissions/') {
return { return {
code: 200,
message: 'success',
data: { data: {
code: 200, users: mockUsers,
message: 'success',
data: {
users: mockUsers,
},
}, },
}; };
} }
@ -655,13 +649,11 @@ export const mockGet = async (url, config = {}) => {
// 用户权限管理 - 获取待处理申请 // 用户权限管理 - 获取待处理申请
if (url === '/permissions/pending/') { if (url === '/permissions/pending/') {
return { return {
code: 200,
message: 'success',
data: { data: {
code: 200, items: mockPendingRequests,
message: 'success', total: mockPendingRequests.length,
data: {
items: mockPendingRequests,
total: mockPendingRequests.length,
},
}, },
}; };
} }
@ -671,12 +663,10 @@ export const mockGet = async (url, config = {}) => {
const userId = url.match(/\/users\/(.+)\/permissions\//)[1]; const userId = url.match(/\/users\/(.+)\/permissions\//)[1];
return { return {
code: 200,
message: 'success',
data: { data: {
code: 200, permissions: mockUserPermissions[userId] || [],
message: 'success',
data: {
permissions: mockUserPermissions[userId] || [],
},
}, },
}; };
} }
@ -758,13 +748,11 @@ export const mockPost = async (url, data) => {
knowledgeBases.push(newKnowledgeBase); knowledgeBases.push(newKnowledgeBase);
return { return {
code: 200,
message: '知识库创建成功',
data: { data: {
code: 200, knowledge_base: newKnowledgeBase,
message: '知识库创建成功', external_id: uuidv4(),
data: {
knowledge_base: newKnowledgeBase,
external_id: uuidv4(),
},
}, },
}; };
} }
@ -773,12 +761,10 @@ export const mockPost = async (url, data) => {
if (url === '/chat-history/') { if (url === '/chat-history/') {
const newChat = mockCreateChat(data); const newChat = mockCreateChat(data);
return { return {
code: 200,
message: 'success',
data: { data: {
code: 200, chat: newChat,
message: 'success',
data: {
chat: newChat,
},
}, },
}; };
} }
@ -826,13 +812,11 @@ export const mockPost = async (url, data) => {
} }
return { return {
code: 200,
message: '发送成功',
data: { data: {
code: 200, user_message: userMessage,
message: '发送成功', bot_message: botMessage,
data: {
user_message: userMessage,
bot_message: botMessage,
},
}, },
}; };
} }

View File

@ -77,6 +77,11 @@ const chatSlice = createSlice({
state.sendMessage.status = 'idle'; state.sendMessage.status = 'idle';
state.sendMessage.error = null; state.sendMessage.error = null;
}, },
// 添加消息
addMessage: (state, action) => {
state.messages.items.push(action.payload);
},
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
// 获取聊天列表 // 获取聊天列表
@ -151,6 +156,7 @@ const chatSlice = createSlice({
// 获取聊天消息 // 获取聊天消息
.addCase(fetchMessages.pending, (state) => { .addCase(fetchMessages.pending, (state) => {
state.messages.status = 'loading'; state.messages.status = 'loading';
state.messages.error = null;
}) })
.addCase(fetchMessages.fulfilled, (state, action) => { .addCase(fetchMessages.fulfilled, (state, action) => {
state.messages.status = 'succeeded'; state.messages.status = 'succeeded';
@ -158,33 +164,40 @@ const chatSlice = createSlice({
}) })
.addCase(fetchMessages.rejected, (state, action) => { .addCase(fetchMessages.rejected, (state, action) => {
state.messages.status = 'failed'; state.messages.status = 'failed';
state.messages.error = action.payload || action.error.message; state.messages.error = action.error.message;
}) })
// 发送聊天消息 // 发送聊天消息
.addCase(sendMessage.pending, (state) => { .addCase(sendMessage.pending, (state) => {
state.sendMessage.status = 'loading'; state.sendMessage.status = 'loading';
state.sendMessage.error = null;
}) })
.addCase(sendMessage.fulfilled, (state, action) => { .addCase(sendMessage.fulfilled, (state, action) => {
state.sendMessage.status = 'succeeded'; state.sendMessage.status = 'succeeded';
// 添加用户消息和机器人回复 // 更新消息列表
if (action.payload.user_message) { const index = state.messages.items.findIndex(
state.messages.items.push(action.payload.user_message); (msg) => msg.content === action.payload.content && msg.sender === action.payload.sender
} );
if (action.payload.bot_message) { if (index === -1) {
state.messages.items.push(action.payload.bot_message); state.messages.items.push(action.payload);
} }
}) })
.addCase(sendMessage.rejected, (state, action) => { .addCase(sendMessage.rejected, (state, action) => {
state.sendMessage.status = 'failed'; state.sendMessage.status = 'failed';
state.sendMessage.error = action.payload || action.error.message; state.sendMessage.error = action.error.message;
}); });
}, },
}); });
// 导出 actions // 导出 actions
export const { resetOperationStatus, resetCurrentChat, setCurrentChat, resetMessages, resetSendMessageStatus } = export const {
chatSlice.actions; resetOperationStatus,
resetCurrentChat,
setCurrentChat,
resetMessages,
resetSendMessageStatus,
addMessage,
} = chatSlice.actions;
// 导出 reducer // 导出 reducer
export default chatSlice.reducer; export default chatSlice.reducer;

View File

@ -0,0 +1,64 @@
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
notifications: [
{
id: 1,
type: 'permission',
icon: 'bi-shield',
title: '新的权限请求',
content: '张三请求访问销售数据集',
time: '10分钟前',
hasDetail: true,
},
{
id: 2,
type: 'system',
icon: 'bi-info-circle',
title: '系统更新通知',
content: '系统将在今晚23:00进行例行维护',
time: '1小时前',
hasDetail: false,
},
{
id: 3,
type: 'permission',
icon: 'bi-shield',
title: '新的权限请求',
content: '李四请求访问用户数据集',
time: '2小时前',
hasDetail: true,
},
{
id: 4,
type: 'system',
icon: 'bi-exclamation-circle',
title: '安全提醒',
content: '检测到异常登录行为,请及时查看',
time: '3小时前',
hasDetail: true,
},
{
id: 5,
type: 'permission',
icon: 'bi-shield',
title: '权限变更通知',
content: '管理员修改了您的数据访问权限',
time: '1天前',
hasDetail: true,
},
],
};
const notificationCenterSlice = createSlice({
name: 'notificationCenter',
initialState,
reducers: {
clearNotifications: (state) => {
state.notifications = [];
},
},
});
export const { clearNotifications } = notificationCenterSlice.actions;
export default notificationCenterSlice.reducer;

View File

@ -1,14 +1,29 @@
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
import { fetchPermissionsThunk, approvePermissionThunk, rejectPermissionThunk } from './permissions.thunks'; import {
fetchUserPermissions,
updateUserPermissions,
fetchPermissionsThunk,
approvePermissionThunk,
rejectPermissionThunk,
} from './permissions.thunks';
const initialState = { const initialState = {
permissions: { users: {
items: [], items: [],
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed' status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null, error: null,
}, },
operations: {
status: 'idle',
error: null,
},
pending: {
items: [],
status: 'idle',
error: null,
},
approveReject: { approveReject: {
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed' status: 'idle',
error: null, error: null,
currentId: null, currentId: null,
}, },
@ -18,66 +33,95 @@ const permissionsSlice = createSlice({
name: 'permissions', name: 'permissions',
initialState, initialState,
reducers: { reducers: {
resetApproveRejectStatus: (state) => { resetOperationStatus: (state) => {
state.approveReject = { state.operations.status = 'idle';
status: 'idle', state.operations.error = null;
error: null,
currentId: null,
};
}, },
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
// 获取权限申请列表
builder builder
// 获取用户权限列表
.addCase(fetchUserPermissions.pending, (state) => {
state.users.status = 'loading';
state.users.error = null;
})
.addCase(fetchUserPermissions.fulfilled, (state, action) => {
state.users.status = 'succeeded';
state.users.items = action.payload;
})
.addCase(fetchUserPermissions.rejected, (state, action) => {
state.users.status = 'failed';
state.users.error = action.error.message;
})
// 更新用户权限
.addCase(updateUserPermissions.pending, (state) => {
state.operations.status = 'loading';
state.operations.error = null;
})
.addCase(updateUserPermissions.fulfilled, (state, action) => {
state.operations.status = 'succeeded';
// 更新用户列表中的权限信息
const index = state.users.items.findIndex((user) => user.id === action.payload.userId);
if (index !== -1) {
state.users.items[index] = {
...state.users.items[index],
permissions: action.payload.permissions,
};
}
})
.addCase(updateUserPermissions.rejected, (state, action) => {
state.operations.status = 'failed';
state.operations.error = action.error.message;
})
// 获取待处理申请列表
.addCase(fetchPermissionsThunk.pending, (state) => { .addCase(fetchPermissionsThunk.pending, (state) => {
state.permissions.status = 'loading'; state.pending.status = 'loading';
state.permissions.error = null; state.pending.error = null;
}) })
.addCase(fetchPermissionsThunk.fulfilled, (state, action) => { .addCase(fetchPermissionsThunk.fulfilled, (state, action) => {
state.permissions.status = 'succeeded'; state.pending.status = 'succeeded';
state.permissions.items = action.payload; state.pending.items = action.payload;
}) })
.addCase(fetchPermissionsThunk.rejected, (state, action) => { .addCase(fetchPermissionsThunk.rejected, (state, action) => {
state.permissions.status = 'failed'; state.pending.status = 'failed';
state.permissions.error = action.payload || '获取权限申请列表失败'; state.pending.error = action.error.message;
}); })
// 批准权限申请 // 批准/拒绝权限申请
builder
.addCase(approvePermissionThunk.pending, (state, action) => { .addCase(approvePermissionThunk.pending, (state, action) => {
state.approveReject.status = 'loading'; state.approveReject.status = 'loading';
state.approveReject.error = null; state.approveReject.error = null;
state.approveReject.currentId = action.meta.arg.id; state.approveReject.currentId = action.meta.arg.id;
}) })
.addCase(approvePermissionThunk.fulfilled, (state, action) => { .addCase(approvePermissionThunk.fulfilled, (state) => {
state.approveReject.status = 'succeeded'; state.approveReject.status = 'succeeded';
// 从列表中移除已批准的申请 state.approveReject.currentId = null;
state.permissions.items = state.permissions.items.filter((item) => item.id !== action.meta.arg.id);
}) })
.addCase(approvePermissionThunk.rejected, (state, action) => { .addCase(approvePermissionThunk.rejected, (state, action) => {
state.approveReject.status = 'failed'; state.approveReject.status = 'failed';
state.approveReject.error = action.payload || '批准权限申请失败'; state.approveReject.error = action.error.message;
}); state.approveReject.currentId = null;
})
// 拒绝权限申请
builder
.addCase(rejectPermissionThunk.pending, (state, action) => { .addCase(rejectPermissionThunk.pending, (state, action) => {
state.approveReject.status = 'loading'; state.approveReject.status = 'loading';
state.approveReject.error = null; state.approveReject.error = null;
state.approveReject.currentId = action.meta.arg.id; state.approveReject.currentId = action.meta.arg.id;
}) })
.addCase(rejectPermissionThunk.fulfilled, (state, action) => { .addCase(rejectPermissionThunk.fulfilled, (state) => {
state.approveReject.status = 'succeeded'; state.approveReject.status = 'succeeded';
// 从列表中移除已拒绝的申请 state.approveReject.currentId = null;
state.permissions.items = state.permissions.items.filter((item) => item.id !== action.meta.arg.id);
}) })
.addCase(rejectPermissionThunk.rejected, (state, action) => { .addCase(rejectPermissionThunk.rejected, (state, action) => {
state.approveReject.status = 'failed'; state.approveReject.status = 'failed';
state.approveReject.error = action.payload || '拒绝权限申请失败'; state.approveReject.error = action.error.message;
state.approveReject.currentId = null;
}); });
}, },
}); });
export const { resetApproveRejectStatus } = permissionsSlice.actions; export const { resetOperationStatus } = permissionsSlice.actions;
export default permissionsSlice.reducer; export default permissionsSlice.reducer;

View File

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

View File

@ -6,6 +6,7 @@ import authReducer from './auth/auth.slice.js';
import knowledgeBaseReducer from './knowledgeBase/knowledgeBase.slice.js'; import knowledgeBaseReducer from './knowledgeBase/knowledgeBase.slice.js';
import chatReducer from './chat/chat.slice.js'; import chatReducer from './chat/chat.slice.js';
import permissionsReducer from './permissions/permissions.slice.js'; import permissionsReducer from './permissions/permissions.slice.js';
import notificationCenterReducer from './notificationCenter/notificationCenter.slice.js';
const rootRducer = combineReducers({ const rootRducer = combineReducers({
auth: authReducer, auth: authReducer,
@ -13,6 +14,7 @@ const rootRducer = combineReducers({
knowledgeBase: knowledgeBaseReducer, knowledgeBase: knowledgeBaseReducer,
chat: chatReducer, chat: chatReducer,
permissions: permissionsReducer, permissions: permissionsReducer,
notificationCenter: notificationCenterReducer,
}); });
const persistConfig = { const persistConfig = {

View File

@ -47,3 +47,100 @@
min-width: 300px !important; min-width: 300px !important;
} }
} }
/* 自定义黑色系开关样式 */
.dark-switch .form-check-input {
border: 1px solid #dee2e6;
background-color: #fff; /* 关闭状态背景色 */
}
/* 关闭状态滑块 */
.dark-switch .form-check-input:not(:checked) {
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle fill='%23adb5bd' r='3'/></svg>");
}
/* 打开状态 */
.dark-switch .form-check-input:checked {
background-color: #000; /* 打开状态背景色 */
border-color: #000;
}
/* 打开状态滑块 */
.dark-switch .form-check-input:checked {
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle fill='%23fff' r='3'/></svg>");
}
/* 悬停效果 */
.dark-switch .form-check-input:hover {
filter: brightness(0.9);
}
/* 禁用状态 */
.dark-switch .form-check-input:disabled {
opacity: 0.5;
background-color: #e9ecef;
}
// 通知中心样式
.notification-item {
transition: background-color 0.2s ease;
&:hover {
background-color: $gray-100;
}
}
// 黑色主题的开关按钮
.form-check-input:checked {
background-color: $dark;
border-color: $dark;
}
/* 自定义分页样式 */
.dark-pagination {
margin: 0;
}
.dark-pagination .page-link {
color: #000; /* 默认文字颜色 */
background-color: #fff; /* 默认背景 */
border: 1px solid #dee2e6; /* 边框颜色 */
transition: all 0.3s ease; /* 平滑过渡效果 */
}
/* 激活状态 */
.dark-pagination .page-item.active .page-link {
background-color: #000 !important;
border-color: #000;
color: #fff !important;
}
/* 悬停状态 */
.dark-pagination .page-link:hover {
background-color: #f8f9fa; /* 浅灰背景 */
border-color: #adb5bd;
}
/* 禁用状态 */
.dark-pagination .page-item.disabled .page-link {
color: #6c757d !important;
background-color: #e9ecef !important;
border-color: #dee2e6;
pointer-events: none;
opacity: 0.7;
}
/* 自定义下拉框 */
.dark-select {
border: 1px solid #000 !important;
color: #000 !important;
}
.dark-select:focus {
box-shadow: 0 0 0 0.25rem rgba(0, 0, 0, 0.25); /* 黑色聚焦阴影 */
}
/* 下拉箭头颜色 */
.dark-select {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='%23000' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
}