knowledgebase_influencer/src/components/NotificationCenter.jsx

311 lines
14 KiB
React
Raw Normal View History

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