knowledgebase_influencer/src/components/NotificationCenter.jsx

361 lines
16 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';
2025-04-16 22:52:13 +08:00
import {
fetchNotifications,
markNotificationRead,
markAllNotificationsRead,
} from '../store/notificationCenter/notificationCenter.thunks';
2025-04-16 09:51:27 +08:00
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';
2025-04-16 22:52:13 +08:00
import { formatDate } from '../utils/dateUtils';
import { useNavigate } from 'react-router-dom';
2025-04-16 09:51:27 +08:00
export default function NotificationCenter({ show, onClose }) {
const [showAll, setShowAll] = useState(false);
const dispatch = useDispatch();
2025-04-16 22:52:13 +08:00
const { notifications, unreadCount, isConnected, loading } = useSelector((state) => state.notificationCenter);
2025-04-16 09:51:27 +08:00
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);
2025-04-16 22:52:13 +08:00
const [loadingNotificationId, setLoadingNotificationId] = useState(null);
const [isMarkingAllAsRead, setIsMarkingAllAsRead] = useState(false);
const [isClearingAll, setIsClearingAll] = useState(false);
2025-04-16 09:51:27 +08:00
const displayedNotifications = showAll ? notifications : notifications.slice(0, 5);
2025-04-16 22:52:13 +08:00
const navigate = useNavigate();
2025-04-16 09:51:27 +08:00
// 初始化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]);
2025-04-16 22:52:13 +08:00
// 当通知中心显示时,获取最新通知
useEffect(() => {
if (show && isAuthenticated) {
dispatch(fetchNotifications());
}
}, [show, isAuthenticated, dispatch]);
2025-04-16 09:51:27 +08:00
const handleClearAll = () => {
2025-04-16 22:52:13 +08:00
setIsClearingAll(true);
// 假设这个操作可能需要一点时间
setTimeout(() => {
dispatch(clearNotifications());
setIsClearingAll(false);
}, 300);
2025-04-16 09:51:27 +08:00
};
const handleMarkAllAsRead = () => {
2025-04-16 22:52:13 +08:00
// 设置正在处理标志
setIsMarkingAllAsRead(true);
// 通过API将所有通知标记为已读成功后更新本地状态
dispatch(markAllNotificationsRead())
.unwrap()
.then(() => {
dispatch(markAllNotificationsAsRead());
})
.catch((error) => {
console.error('Failed to mark all notifications as read:', error);
})
.finally(() => {
setIsMarkingAllAsRead(false);
});
2025-04-16 09:51:27 +08:00
};
const handleMarkAsRead = (notificationId) => {
2025-04-16 22:52:13 +08:00
// 设置正在加载的通知ID
setLoadingNotificationId(notificationId);
// 通过API更新已读状态成功后再更新本地状态
dispatch(markNotificationRead(notificationId))
.unwrap()
.then(() => {
// API调用成功后更新本地状态
dispatch(markNotificationAsRead(notificationId));
// 同时发送确认消息到服务器
acknowledgeNotification(notificationId);
})
.catch((error) => {
console.error('Failed to mark notification as read:', error);
})
.finally(() => {
// 清除加载状态
setLoadingNotificationId(null);
});
2025-04-16 09:51:27 +08:00
};
const handleViewDetail = (notification) => {
2025-04-16 22:52:13 +08:00
navigate('/permissions');
// setSelectedRequest(notification);
// setShowSlideOver(true);
2025-04-16 09:51:27 +08:00
};
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>}
</div>
2025-04-16 22:52:13 +08:00
<div
className={`d-flex gap-3 align-items-center${
notifications.length === 0 ? ' d-none' : ' d-none'
}`}
>
2025-04-16 09:51:27 +08:00
<button
className='btn btn-link text-decoration-none p-0 text-dark'
onClick={handleMarkAllAsRead}
2025-04-16 22:52:13 +08:00
disabled={unreadCount === 0 || isMarkingAllAsRead}
2025-04-16 09:51:27 +08:00
>
全部标为已读
</button>
<button
className='btn btn-link text-decoration-none p-0 text-dark'
onClick={handleClearAll}
2025-04-16 22:52:13 +08:00
disabled={notifications.length === 0 || isClearingAll}
2025-04-16 09:51:27 +08:00
>
清除所有
</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 ${
2025-04-16 22:52:13 +08:00
!notification.is_read ? 'bg-light' : ''
2025-04-16 09:51:27 +08:00
}`}
>
2025-04-16 22:52:13 +08:00
<div className='flex-grow-1'>
<div className='d-flex justify-content-between align-items-start'>
<h6 className={`mb-1 ${!notification.is_read ? 'fw-bold' : ''}`}>
{notification.title}
</h6>
<small className='text-muted'>{formatDate(notification.created_at)}</small>
2025-04-16 09:51:27 +08:00
</div>
2025-04-16 22:52:13 +08:00
<p className='mb-1 text-secondary'>{notification.content}</p>
<div className='d-flex gap-2'>
{notification.type === 'permission_request' && (
<button
className='btn btn-sm btn-outline-primary'
onClick={() => handleViewDetail(notification)}
>
查看详情
</button>
)}
{!notification.is_read && (
<button
className='btn btn-sm btn-outline-secondary'
onClick={() => handleMarkAsRead(notification.id)}
disabled={loadingNotificationId === notification.id}
>
{loadingNotificationId === notification.id ? (
<>
<span
className='spinner-border spinner-border-sm me-1'
role='status'
aria-hidden='true'
></span>
处理中...
</>
) : (
'标为已读'
)}
</button>
)}
2025-04-16 09:51:27 +08:00
</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>
)}
</>
);
}