[dev]update notificationcenter

This commit is contained in:
susie-laptop 2025-04-17 10:03:36 -04:00
parent 34250b3702
commit b3cf2e366b
7 changed files with 350 additions and 140 deletions

2
.env
View File

@ -1,4 +1,4 @@
VITE_PORT = 8080 VITE_PORT = 8080
VITE_PROD = false VITE_PROD = false
VITE_API_URL = "http://81.69.223.133:3000" VITE_API_URL = "http://81.69.223.133:8008"
VITE_SECRETKEY = "ooin-knowledge-base-key" VITE_SECRETKEY = "ooin-knowledge-base-key"

View File

@ -6,15 +6,22 @@ import {
markNotificationAsRead, markNotificationAsRead,
setWebSocketConnected, setWebSocketConnected,
} from '../store/notificationCenter/notificationCenter.slice'; } from '../store/notificationCenter/notificationCenter.slice';
import {
fetchNotifications,
markNotificationRead,
markAllNotificationsRead,
} from '../store/notificationCenter/notificationCenter.thunks';
import RequestDetailSlideOver from '../pages/Permissions/components/RequestDetailSlideOver'; import RequestDetailSlideOver from '../pages/Permissions/components/RequestDetailSlideOver';
import { approvePermissionThunk, rejectPermissionThunk } from '../store/permissions/permissions.thunks'; import { approvePermissionThunk, rejectPermissionThunk } from '../store/permissions/permissions.thunks';
import { showNotification } from '../store/notification.slice'; import { showNotification } from '../store/notification.slice';
import { initWebSocket, acknowledgeNotification, closeWebSocket } from '../services/websocket'; import { initWebSocket, acknowledgeNotification, closeWebSocket } from '../services/websocket';
import { formatDate } from '../utils/dateUtils';
import { useNavigate } from 'react-router-dom';
export default function NotificationCenter({ show, onClose }) { export default function NotificationCenter({ show, onClose }) {
const [showAll, setShowAll] = useState(false); const [showAll, setShowAll] = useState(false);
const dispatch = useDispatch(); const dispatch = useDispatch();
const { notifications, unreadCount, isConnected } = useSelector((state) => state.notificationCenter); const { notifications, unreadCount, isConnected, loading } = useSelector((state) => state.notificationCenter);
const [selectedRequest, setSelectedRequest] = useState(null); const [selectedRequest, setSelectedRequest] = useState(null);
const [showSlideOver, setShowSlideOver] = useState(false); const [showSlideOver, setShowSlideOver] = useState(false);
const [showResponseInput, setShowResponseInput] = useState(false); const [showResponseInput, setShowResponseInput] = useState(false);
@ -22,21 +29,24 @@ export default function NotificationCenter({ show, onClose }) {
const [isApproving, setIsApproving] = useState(false); const [isApproving, setIsApproving] = useState(false);
const [responseMessage, setResponseMessage] = useState(''); const [responseMessage, setResponseMessage] = useState('');
const { isAuthenticated } = useSelector((state) => state.auth); const { isAuthenticated } = useSelector((state) => state.auth);
const [loadingNotificationId, setLoadingNotificationId] = useState(null);
const [isMarkingAllAsRead, setIsMarkingAllAsRead] = useState(false);
const [isClearingAll, setIsClearingAll] = useState(false);
const displayedNotifications = showAll ? notifications : notifications.slice(0, 5); const displayedNotifications = showAll ? notifications : notifications.slice(0, 5);
const navigate = useNavigate();
// WebSocket // WebSocket
useEffect(() => { useEffect(() => {
// WebSocket // WebSocketWebSocket
if (isAuthenticated && !isConnected) { if (isAuthenticated && !isConnected) {
initWebSocket() initWebSocket()
.then(() => { .then(() => {
dispatch(setWebSocketConnected(true));
console.log('Successfully connected to notification WebSocket'); console.log('Successfully connected to notification WebSocket');
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to connect to notification WebSocket:', error); console.error('Failed to connect to notification WebSocket:', error);
dispatch(setWebSocketConnected(false));
// //
dispatch( dispatch(
showNotification({ showNotification({
@ -51,35 +61,70 @@ export default function NotificationCenter({ show, onClose }) {
return () => { return () => {
if (isConnected) { if (isConnected) {
closeWebSocket(); closeWebSocket();
dispatch(setWebSocketConnected(false));
} }
}; };
}, [isAuthenticated, isConnected, dispatch]); }, [isAuthenticated, isConnected, dispatch]);
//
useEffect(() => {
if (show && isAuthenticated) {
dispatch(fetchNotifications());
}
}, [show, isAuthenticated, dispatch]);
const handleClearAll = () => { const handleClearAll = () => {
setIsClearingAll(true);
//
setTimeout(() => {
dispatch(clearNotifications()); dispatch(clearNotifications());
setIsClearingAll(false);
}, 300);
}; };
const handleMarkAllAsRead = () => { const handleMarkAllAsRead = () => {
//
setIsMarkingAllAsRead(true);
// API
dispatch(markAllNotificationsRead())
.unwrap()
.then(() => {
dispatch(markAllNotificationsAsRead()); dispatch(markAllNotificationsAsRead());
})
.catch((error) => {
console.error('Failed to mark all notifications as read:', error);
})
.finally(() => {
setIsMarkingAllAsRead(false);
});
}; };
const handleMarkAsRead = (notificationId) => { const handleMarkAsRead = (notificationId) => {
// ID
setLoadingNotificationId(notificationId);
// API
dispatch(markNotificationRead(notificationId))
.unwrap()
.then(() => {
// API
dispatch(markNotificationAsRead(notificationId)); dispatch(markNotificationAsRead(notificationId));
// //
acknowledgeNotification(notificationId); acknowledgeNotification(notificationId);
})
.catch((error) => {
console.error('Failed to mark notification as read:', error);
})
.finally(() => {
//
setLoadingNotificationId(null);
});
}; };
const handleViewDetail = (notification) => { const handleViewDetail = (notification) => {
// navigate('/permissions');
if (!notification.isRead) { // setSelectedRequest(notification);
handleMarkAsRead(notification.id); // setShowSlideOver(true);
}
if (notification.type === 'permission') {
setSelectedRequest(notification);
setShowSlideOver(true);
}
}; };
const handleCloseSlideOver = () => { const handleCloseSlideOver = () => {
@ -138,24 +183,23 @@ export default function NotificationCenter({ show, onClose }) {
<div className='d-flex align-items-center'> <div className='d-flex align-items-center'>
<h6 className='mb-0 me-2'>通知中心</h6> <h6 className='mb-0 me-2'>通知中心</h6>
{unreadCount > 0 && <span className='badge bg-danger rounded-pill'>{unreadCount}</span>} {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>
<div className='d-flex gap-3 align-items-center'> <div
className={`d-flex gap-3 align-items-center${
notifications.length === 0 ? ' d-none' : ' d-none'
}`}
>
<button <button
className='btn btn-link text-decoration-none p-0 text-dark' className='btn btn-link text-decoration-none p-0 text-dark'
onClick={handleMarkAllAsRead} onClick={handleMarkAllAsRead}
disabled={unreadCount === 0} disabled={unreadCount === 0 || isMarkingAllAsRead}
> >
全部标为已读 全部标为已读
</button> </button>
<button <button
className='btn btn-link text-decoration-none p-0 text-dark' className='btn btn-link text-decoration-none p-0 text-dark'
onClick={handleClearAll} onClick={handleClearAll}
disabled={notifications.length === 0} disabled={notifications.length === 0 || isClearingAll}
> >
清除所有 清除所有
</button> </button>
@ -173,46 +217,49 @@ export default function NotificationCenter({ show, onClose }) {
<div <div
key={notification.id} key={notification.id}
className={`notification-item p-3 border-bottom hover-bg-light ${ className={`notification-item p-3 border-bottom hover-bg-light ${
!notification.isRead ? 'bg-light' : '' !notification.is_read ? '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='flex-grow-1'>
<div className='d-flex justify-content-between align-items-start'> <div className='d-flex justify-content-between align-items-start'>
<h6 className={`mb-1 ${!notification.isRead ? 'fw-bold' : ''}`}> <h6 className={`mb-1 ${!notification.is_read ? 'fw-bold' : ''}`}>
{notification.title} {notification.title}
</h6> </h6>
<small className='text-muted'>{notification.time}</small> <small className='text-muted'>{formatDate(notification.time)}</small>
</div> </div>
<p className='mb-1 text-secondary'>{notification.content}</p> <p className='mb-1 text-secondary'>{notification.content}</p>
<div className='d-flex gap-2'> <div className='d-flex gap-2'>
{notification.hasDetail && ( {notification.type === 'permission_request' && (
<button <button
className='btn btn-sm btn-dark' className='btn btn-sm btn-outline-primary'
onClick={() => handleViewDetail(notification)} onClick={() => handleViewDetail(notification)}
> >
查看详情 查看详情
</button> </button>
)} )}
{!notification.isRead && ( {!notification.is_read && (
<button <button
className='btn btn-sm btn-outline-secondary' className='btn btn-sm btn-outline-secondary'
onClick={() => handleMarkAsRead(notification.id)} 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> </button>
)} )}
</div> </div>
</div> </div>
</div> </div>
</div>
)) ))
)} )}
</div> </div>

View File

@ -1,10 +1,12 @@
import React, { useState } from 'react'; import React, { useEffect, 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 UserSettingsModal from '../components/UserSettingsModal';
import NotificationCenter from '../components/NotificationCenter'; import NotificationCenter from '../components/NotificationCenter';
import FullscreenLoading from '../components/FullscreenLoading'; import FullscreenLoading from '../components/FullscreenLoading';
import SvgIcon from '../components/SvgIcon';
import { fetchNotifications } from '../store/notificationCenter/notificationCenter.thunks';
export default function HeaderWithNav() { export default function HeaderWithNav() {
const dispatch = useDispatch(); const dispatch = useDispatch();
@ -23,7 +25,7 @@ export default function HeaderWithNav() {
sessionStorage.removeItem('token'); sessionStorage.removeItem('token');
navigate('/login'); navigate('/login');
} catch (error) { } catch (error) {
console.error("Logout failed:", error); console.error('Logout failed:', error);
} finally { } finally {
setIsLoggingOut(false); setIsLoggingOut(false);
} }
@ -38,9 +40,13 @@ export default function HeaderWithNav() {
// leader admin // leader admin
const hasManagePermission = user && (user.role === 'leader' || user.role === 'admin'); const hasManagePermission = user && (user.role === 'leader' || user.role === 'admin');
useEffect(() => {
dispatch(fetchNotifications());
}, [dispatch]);
return ( return (
<> <>
<FullscreenLoading show={isLoggingOut} message="正在退出登录..." /> <FullscreenLoading show={isLoggingOut} message='正在退出登录...' />
<header> <header>
<nav className='navbar navbar-expand-lg bg-white shadow-sm'> <nav className='navbar navbar-expand-lg bg-white shadow-sm'>
<div className='container-fluid'> <div className='container-fluid'>
@ -67,7 +73,9 @@ export default function HeaderWithNav() {
<li className='nav-item'> <li className='nav-item'>
<Link <Link
className={`nav-link text-main hover-bold ${ className={`nav-link text-main hover-bold ${
isActive('/') && !isActive('/chat') && !isActive('/permissions') ? 'active' : '' isActive('/') && !isActive('/chat') && !isActive('/permissions')
? 'active'
: ''
}`} }`}
aria-current='page' aria-current='page'
to='/' to='/'
@ -96,13 +104,12 @@ export default function HeaderWithNav() {
</ul> </ul>
{!!user ? ( {!!user ? (
<div className='d-flex align-items-center gap-3'> <div className='d-flex align-items-center gap-3'>
{/* <div className='position-relative'> <div className='position-relative'>
<button <button
className='btn btn-link text-dark p-0' className='btn btn-link text-dark p-0'
onClick={() => setShowNotifications(!showNotifications)} onClick={() => setShowNotifications(!showNotifications)}
title={isConnected ? '通知服务已连接' : '通知服务未连接'}
> >
<SvgIcon className={'bell'} /> <SvgIcon className={'bell'} color="#A32B23" />
{unreadCount > 0 && ( {unreadCount > 0 && (
<span className='position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger'> <span className='position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger'>
{unreadCount > 99 ? '99+' : unreadCount} {unreadCount > 99 ? '99+' : unreadCount}
@ -119,7 +126,7 @@ export default function HeaderWithNav() {
</span> </span>
)} )}
</button> </button>
</div> */} </div>
<div className='flex-shrink-0 dropdown'> <div className='flex-shrink-0 dropdown'>
<a <a
href='#' href='#'

View File

@ -7,8 +7,8 @@ import { showNotification } from '../../store/notification.slice';
export default function Login() { export default function Login() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const navigate = useNavigate(); const navigate = useNavigate();
const [username, setUsername] = useState('leader2'); const [username, setUsername] = useState('');
const [password, setPassword] = useState('leader123'); const [password, setPassword] = useState('');
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);

View File

@ -1,16 +1,25 @@
import { addNotification, markNotificationAsRead } from '../store/notificationCenter/notificationCenter.slice'; import {
addNotification,
markNotificationAsRead,
setWebSocketConnected,
} from '../store/notificationCenter/notificationCenter.slice';
import store from '../store/store'; // 修改为默认导出 import store from '../store/store'; // 修改为默认导出
import CryptoJS from 'crypto-js';
const secretKey = import.meta.env.VITE_SECRETKEY;
// 从环境变量获取 API URL // 从环境变量获取 API URL
const API_URL = import.meta.env.VITE_API_URL || ''; const API_URL = import.meta.env.VITE_API_URL || 'http://81.69.223.133:8008';
// 将 HTTP URL 转换为 WebSocket URL // 将 HTTP URL 转换为 WebSocket URL
const WS_BASE_URL = API_URL.replace(/^http/, 'ws').replace(/\/api\/?$/, ''); const WS_BASE_URL = API_URL.replace(/^http/, 'ws').replace(/\/api\/?$/, '');
let socket = null; let socket = null;
let reconnectTimer = null; let reconnectTimer = null;
let pingInterval = null; let pingInterval = null;
let reconnectAttempts = 0; // 添加重连尝试计数器
const RECONNECT_DELAY = 5000; // 5秒后尝试重连 const RECONNECT_DELAY = 5000; // 5秒后尝试重连
const PING_INTERVAL = 30000; // 30秒发送一次ping const PING_INTERVAL = 30000; // 30秒发送一次ping
const MAX_RECONNECT_ATTEMPTS = 3; // 最大重连尝试次数
/** /**
* 初始化WebSocket连接 * 初始化WebSocket连接
@ -30,18 +39,27 @@ export const initWebSocket = () => {
try { try {
// 从sessionStorage获取token // 从sessionStorage获取token
const encryptedToken = sessionStorage.getItem('token'); const encryptedToken = sessionStorage.getItem('token');
let token = '';
if (!encryptedToken) { if (!encryptedToken) {
console.error('No token found, cannot connect to notification service'); console.error('No token found, cannot connect to notification service');
store.dispatch(setWebSocketConnected(false));
reject(new Error('No token found')); reject(new Error('No token found'));
return; return;
} }
if (encryptedToken) {
const wsUrl = `${WS_BASE_URL}/ws/notifications?token=${encryptedToken}`; token = CryptoJS.AES.decrypt(encryptedToken, secretKey).toString(CryptoJS.enc.Utf8);
}
const wsUrl = `${WS_BASE_URL}/ws/notifications/?token=${token}`;
console.log('WebSocket URL:', wsUrl);
socket = new WebSocket(wsUrl); socket = new WebSocket(wsUrl);
// 连接建立时的处理 // 连接建立时的处理
socket.onopen = () => { socket.onopen = () => {
console.log('WebSocket connection established'); console.log('WebSocket connection established');
reconnectAttempts = 0; // 连接成功后重置重连计数器
// 更新Redux中的连接状态
store.dispatch(setWebSocketConnected(true));
// 订阅通知频道 // 订阅通知频道
subscribeToNotifications(); subscribeToNotifications();
@ -69,6 +87,8 @@ export const initWebSocket = () => {
// 错误处理 // 错误处理
socket.onerror = (error) => { socket.onerror = (error) => {
console.error('WebSocket error:', error); console.error('WebSocket error:', error);
// 更新Redux中的连接状态
store.dispatch(setWebSocketConnected(false));
reject(error); reject(error);
}; };
@ -76,21 +96,37 @@ export const initWebSocket = () => {
socket.onclose = (event) => { socket.onclose = (event) => {
console.log(`WebSocket connection closed: ${event.code} ${event.reason}`); console.log(`WebSocket connection closed: ${event.code} ${event.reason}`);
// 更新Redux中的连接状态
store.dispatch(setWebSocketConnected(false));
// 清除ping定时器 // 清除ping定时器
if (pingInterval) clearInterval(pingInterval); if (pingInterval) clearInterval(pingInterval);
// 如果不是正常关闭,尝试重连 // 如果不是正常关闭,尝试重连
if (event.code !== 1000) { if (event.code !== 1000) {
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
reconnectAttempts++;
console.log(`WebSocket reconnection attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`);
reconnectTimer = setTimeout(() => { reconnectTimer = setTimeout(() => {
console.log('Attempting to reconnect WebSocket...'); console.log('Attempting to reconnect WebSocket...');
initWebSocket().catch((err) => { initWebSocket().catch((err) => {
console.error('Failed to reconnect WebSocket:', err); console.error('Failed to reconnect WebSocket:', err);
// 重连失败时更新Redux中的连接状态
store.dispatch(setWebSocketConnected(false));
}); });
}, RECONNECT_DELAY); }, RECONNECT_DELAY);
} else {
console.log('Maximum reconnection attempts reached. Giving up.');
// 达到最大重连次数时更新Redux中的连接状态
store.dispatch(setWebSocketConnected(false));
}
} }
}; };
} catch (error) { } catch (error) {
console.error('Error initializing WebSocket:', error); console.error('Error initializing WebSocket:', error);
// 更新Redux中的连接状态
store.dispatch(setWebSocketConnected(false));
reject(error); reject(error);
} }
}); });
@ -156,6 +192,9 @@ export const closeWebSocket = () => {
clearInterval(pingInterval); clearInterval(pingInterval);
pingInterval = null; pingInterval = null;
} }
// 更新Redux中的连接状态
store.dispatch(setWebSocketConnected(false));
}; };
/** /**
@ -218,6 +257,7 @@ const processNotification = (data) => {
} else { } else {
timeDisplay = `${diffDays}天前`; timeDisplay = `${diffDays}天前`;
} }
console.log(timeDisplay, diffMins, diffHours, diffDays);
return { return {
id: notificationData.id, id: notificationData.id,

View File

@ -1,60 +1,12 @@
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
import { fetchNotifications, markNotificationRead, markAllNotificationsRead } from './notificationCenter.thunks';
const initialState = { const initialState = {
notifications: [ notifications: [],
{ unreadCount: 0,
id: 1,
type: 'permission',
icon: 'bi-shield',
title: '新的权限请求',
content: '张三请求访问销售数据集',
time: '10分钟前',
hasDetail: true,
isRead: false,
},
{
id: 2,
type: 'system',
icon: 'bi-info-circle',
title: '系统更新通知',
content: '系统将在今晚23:00进行例行维护',
time: '1小时前',
hasDetail: false,
isRead: false,
},
{
id: 3,
type: 'permission',
icon: 'bi-shield',
title: '新的权限请求',
content: '李四请求访问用户数据集',
time: '2小时前',
hasDetail: true,
isRead: false,
},
{
id: 4,
type: 'system',
icon: 'bi-exclamation-circle',
title: '安全提醒',
content: '检测到异常登录行为,请及时查看',
time: '3小时前',
hasDetail: true,
isRead: false,
},
{
id: 5,
type: 'permission',
icon: 'bi-shield',
title: '权限变更通知',
content: '管理员修改了您的数据访问权限',
time: '1天前',
hasDetail: true,
isRead: false,
},
],
unreadCount: 5,
isConnected: false, isConnected: false,
loading: 'idle',
error: null,
}; };
const notificationCenterSlice = createSlice({ const notificationCenterSlice = createSlice({
@ -73,7 +25,7 @@ const notificationCenterSlice = createSlice({
// 将新通知添加到列表的开头 // 将新通知添加到列表的开头
state.notifications.unshift(action.payload); state.notifications.unshift(action.payload);
// 如果通知未读,增加未读计数 // 如果通知未读,增加未读计数
if (!action.payload.isRead) { if (!action.payload.is_read) {
state.unreadCount += 1; state.unreadCount += 1;
} }
} }
@ -81,15 +33,15 @@ const notificationCenterSlice = createSlice({
markNotificationAsRead: (state, action) => { markNotificationAsRead: (state, action) => {
const notification = state.notifications.find((n) => n.id === action.payload); const notification = state.notifications.find((n) => n.id === action.payload);
if (notification && !notification.isRead) { if (notification && !notification.is_read) {
notification.isRead = true; notification.is_read = true;
state.unreadCount = Math.max(0, state.unreadCount - 1); state.unreadCount = Math.max(0, state.unreadCount - 1);
} }
}, },
markAllNotificationsAsRead: (state) => { markAllNotificationsAsRead: (state) => {
state.notifications.forEach((notification) => { state.notifications.forEach((notification) => {
notification.isRead = true; notification.is_read = true;
}); });
state.unreadCount = 0; state.unreadCount = 0;
}, },
@ -109,6 +61,35 @@ const notificationCenterSlice = createSlice({
} }
}, },
}, },
extraReducers: (builder) => {
builder
.addCase(fetchNotifications.pending, (state) => {
state.loading = 'loading';
})
.addCase(fetchNotifications.fulfilled, (state, action) => {
state.loading = 'succeeded';
state.notifications = action.payload;
// 计算未读通知数量
state.unreadCount = action.payload.filter((notification) => !notification.is_read).length;
})
.addCase(fetchNotifications.rejected, (state, action) => {
state.loading = 'failed';
state.error = action.payload?.message || 'Failed to fetch notifications';
})
.addCase(markNotificationRead.fulfilled, (state, action) => {
const notification = state.notifications.find((n) => n.id === action.payload.id);
if (notification && !notification.is_read) {
notification.is_read = true;
state.unreadCount = Math.max(0, state.unreadCount - 1);
}
})
.addCase(markAllNotificationsRead.fulfilled, (state) => {
state.notifications.forEach((notification) => {
notification.is_read = true;
});
state.unreadCount = 0;
});
},
}); });
export const { export const {

View File

@ -0,0 +1,135 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { get, post } from '../../services/api';
/**
* Fetch notifications from the API
*/
export const fetchNotifications = createAsyncThunk(
'notificationCenter/fetchNotifications',
async (_, { rejectWithValue }) => {
try {
const response = await get('/notifications/');
return processNotification(response);
} catch (error) {
const errorMessage = error.response?.data?.message || 'Failed to fetch notifications';
return rejectWithValue(errorMessage);
}
}
);
/**
* Mark a notification as read
*/
export const markNotificationRead = createAsyncThunk(
'notificationCenter/markNotificationRead',
async (notificationId, { rejectWithValue }) => {
try {
const response = await post(`/notifications/${notificationId}/mark-as-read/`);
return { id: notificationId, ...response };
} catch (error) {
const errorMessage = error.response?.data?.message || 'Failed to mark notification as read';
return rejectWithValue(errorMessage);
}
}
);
/**
* Mark all notifications as read
*/
export const markAllNotificationsRead = createAsyncThunk(
'notificationCenter/markAllNotificationsRead',
async (_, { rejectWithValue }) => {
try {
const response = await post('/notifications/mark-all-as-read/');
return response;
} catch (error) {
const errorMessage = error.response?.data?.message || 'Failed to mark all notifications as read';
return rejectWithValue(errorMessage);
}
}
);
/**
* 处理通知数据转换为应用内通知格式
* @param {Object|Array} data 通知数据或通知数组
* @returns {Object|Array} 处理后的通知数据
*/
export const processNotification = (data) => {
// 处理数组类型的通知数据
if (Array.isArray(data)) {
return data.map((item) => processNotificationItem(item));
}
// 处理WebSocket格式的通知数据 (带有data字段的对象)
if (data && data.data) {
return processNotificationItem(data.data);
}
// 处理单个通知对象
return processNotificationItem(data);
};
/**
* 处理单个通知项
* @param {Object} notification 单个通知数据
* @returns {Object} 处理后的通知数据
*/
const processNotificationItem = (notification) => {
// 确保我们有一个有效的通知对象
if (!notification) return null;
// 提取通知数据兼容不同的API格式
const notificationData = notification.data || notification;
// 设置图标
let icon = 'bi-info-circle';
const type = notificationData.category || notificationData.type;
if (type === 'system') {
icon = 'bi-info-circle';
} else if (type === 'permission' || type === 'permission_request') {
icon = 'bi-shield';
}
// 计算时间显示
const createdAt = new Date(notificationData.created_at);
const now = new Date();
// 检查是否是今天
const isToday =
createdAt.getDate() === now.getDate() &&
createdAt.getMonth() === now.getMonth() &&
createdAt.getFullYear() === now.getFullYear();
// 如果是今天,只显示时间;否则显示年月日和时间
let timeDisplay;
if (isToday) {
timeDisplay = createdAt.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
} else {
timeDisplay = createdAt.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
return {
id: notificationData.id,
type: type,
icon,
title: notificationData.title,
content: notificationData.content,
time: timeDisplay,
hasDetail: true,
isRead: notificationData.is_read,
created_at: notificationData.created_at,
sender: notificationData.sender,
receiver: notificationData.receiver,
related_resource: notificationData.related_resource,
metadata: notificationData.metadata || {},
};
};