mirror of
https://github.com/Funkoala14/knowledgebase_law.git
synced 2025-06-08 05:28:15 +08:00
Compare commits
2 Commits
34250b3702
...
37b2bc1557
Author | SHA1 | Date | |
---|---|---|---|
37b2bc1557 | |||
b3cf2e366b |
2
.env
2
.env
@ -1,4 +1,4 @@
|
||||
VITE_PORT = 8080
|
||||
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"
|
@ -6,15 +6,22 @@ import {
|
||||
markNotificationAsRead,
|
||||
setWebSocketConnected,
|
||||
} from '../store/notificationCenter/notificationCenter.slice';
|
||||
import {
|
||||
fetchNotifications,
|
||||
markNotificationRead,
|
||||
markAllNotificationsRead,
|
||||
} from '../store/notificationCenter/notificationCenter.thunks';
|
||||
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';
|
||||
import { formatDate } from '../utils/dateUtils';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function NotificationCenter({ show, onClose }) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
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 [showSlideOver, setShowSlideOver] = useState(false);
|
||||
const [showResponseInput, setShowResponseInput] = useState(false);
|
||||
@ -22,21 +29,24 @@ export default function NotificationCenter({ show, onClose }) {
|
||||
const [isApproving, setIsApproving] = useState(false);
|
||||
const [responseMessage, setResponseMessage] = useState('');
|
||||
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 navigate = useNavigate();
|
||||
|
||||
// 初始化WebSocket连接
|
||||
useEffect(() => {
|
||||
// 只有在用户已登录的情况下才连接WebSocket
|
||||
// 只有在用户已登录且WebSocket未连接的情况下才连接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({
|
||||
@ -51,35 +61,71 @@ export default function NotificationCenter({ show, onClose }) {
|
||||
return () => {
|
||||
if (isConnected) {
|
||||
closeWebSocket();
|
||||
dispatch(setWebSocketConnected(false));
|
||||
}
|
||||
};
|
||||
}, [isAuthenticated, isConnected, dispatch]);
|
||||
|
||||
// 当通知中心显示时,获取最新通知
|
||||
useEffect(() => {
|
||||
if (show && isAuthenticated) {
|
||||
dispatch(fetchNotifications());
|
||||
}
|
||||
}, [show, isAuthenticated, dispatch]);
|
||||
|
||||
const handleClearAll = () => {
|
||||
dispatch(clearNotifications());
|
||||
setIsClearingAll(true);
|
||||
// 假设这个操作可能需要一点时间
|
||||
setTimeout(() => {
|
||||
dispatch(clearNotifications());
|
||||
setIsClearingAll(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleMarkAllAsRead = () => {
|
||||
dispatch(markAllNotificationsAsRead());
|
||||
// 设置正在处理标志
|
||||
setIsMarkingAllAsRead(true);
|
||||
|
||||
// 通过API将所有通知标记为已读,成功后更新本地状态
|
||||
dispatch(markAllNotificationsRead())
|
||||
.unwrap()
|
||||
.then(() => {
|
||||
dispatch(markAllNotificationsAsRead());
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to mark all notifications as read:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsMarkingAllAsRead(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleMarkAsRead = (notificationId) => {
|
||||
dispatch(markNotificationAsRead(notificationId));
|
||||
// 同时发送确认消息到服务器
|
||||
acknowledgeNotification(notificationId);
|
||||
// 设置正在加载的通知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);
|
||||
});
|
||||
};
|
||||
|
||||
const handleViewDetail = (notification) => {
|
||||
// 标记为已读
|
||||
if (!notification.isRead) {
|
||||
handleMarkAsRead(notification.id);
|
||||
}
|
||||
|
||||
if (notification.type === 'permission') {
|
||||
setSelectedRequest(notification);
|
||||
setShowSlideOver(true);
|
||||
}
|
||||
onClose();
|
||||
navigate('/permissions');
|
||||
// setSelectedRequest(notification);
|
||||
// setShowSlideOver(true);
|
||||
};
|
||||
|
||||
const handleCloseSlideOver = () => {
|
||||
@ -138,24 +184,23 @@ export default function NotificationCenter({ show, onClose }) {
|
||||
<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'>
|
||||
<div
|
||||
className={`d-flex gap-3 align-items-center${
|
||||
notifications.length === 0 ? ' d-none' : ' d-none'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
className='btn btn-link text-decoration-none p-0 text-dark'
|
||||
onClick={handleMarkAllAsRead}
|
||||
disabled={unreadCount === 0}
|
||||
disabled={unreadCount === 0 || isMarkingAllAsRead}
|
||||
>
|
||||
全部标为已读
|
||||
</button>
|
||||
<button
|
||||
className='btn btn-link text-decoration-none p-0 text-dark'
|
||||
onClick={handleClearAll}
|
||||
disabled={notifications.length === 0}
|
||||
disabled={notifications.length === 0 || isClearingAll}
|
||||
>
|
||||
清除所有
|
||||
</button>
|
||||
@ -173,43 +218,46 @@ export default function NotificationCenter({ show, onClose }) {
|
||||
<div
|
||||
key={notification.id}
|
||||
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 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.time)}</small>
|
||||
</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>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,10 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { logoutThunk } from '../store/auth/auth.thunk';
|
||||
import UserSettingsModal from '../components/UserSettingsModal';
|
||||
import NotificationCenter from '../components/NotificationCenter';
|
||||
import FullscreenLoading from '../components/FullscreenLoading';
|
||||
import SvgIcon from '../components/SvgIcon';
|
||||
import { fetchNotifications } from '../store/notificationCenter/notificationCenter.thunks';
|
||||
|
||||
export default function HeaderWithNav() {
|
||||
const dispatch = useDispatch();
|
||||
@ -23,7 +25,7 @@ export default function HeaderWithNav() {
|
||||
sessionStorage.removeItem('token');
|
||||
navigate('/login');
|
||||
} catch (error) {
|
||||
console.error("Logout failed:", error);
|
||||
console.error('Logout failed:', error);
|
||||
} finally {
|
||||
setIsLoggingOut(false);
|
||||
}
|
||||
@ -38,9 +40,13 @@ export default function HeaderWithNav() {
|
||||
// 检查用户是否有管理权限(leader 或 admin)
|
||||
const hasManagePermission = user && (user.role === 'leader' || user.role === 'admin');
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchNotifications());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FullscreenLoading show={isLoggingOut} message="正在退出登录..." />
|
||||
<FullscreenLoading show={isLoggingOut} message='正在退出登录...' />
|
||||
<header>
|
||||
<nav className='navbar navbar-expand-lg bg-white shadow-sm'>
|
||||
<div className='container-fluid'>
|
||||
@ -67,7 +73,9 @@ export default function HeaderWithNav() {
|
||||
<li className='nav-item'>
|
||||
<Link
|
||||
className={`nav-link text-main hover-bold ${
|
||||
isActive('/') && !isActive('/chat') && !isActive('/permissions') ? 'active' : ''
|
||||
isActive('/') && !isActive('/chat') && !isActive('/permissions')
|
||||
? 'active'
|
||||
: ''
|
||||
}`}
|
||||
aria-current='page'
|
||||
to='/'
|
||||
@ -96,13 +104,12 @@ export default function HeaderWithNav() {
|
||||
</ul>
|
||||
{!!user ? (
|
||||
<div className='d-flex align-items-center gap-3'>
|
||||
{/* <div className='position-relative'>
|
||||
<div className='position-relative'>
|
||||
<button
|
||||
className='btn btn-link text-dark p-0'
|
||||
onClick={() => setShowNotifications(!showNotifications)}
|
||||
title={isConnected ? '通知服务已连接' : '通知服务未连接'}
|
||||
>
|
||||
<SvgIcon className={'bell'} />
|
||||
<SvgIcon className={'bell'} color="#A32B23" />
|
||||
{unreadCount > 0 && (
|
||||
<span className='position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger'>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
@ -119,7 +126,7 @@ export default function HeaderWithNav() {
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div> */}
|
||||
</div>
|
||||
<div className='flex-shrink-0 dropdown'>
|
||||
<a
|
||||
href='#'
|
||||
|
@ -7,8 +7,8 @@ import { showNotification } from '../../store/notification.slice';
|
||||
export default function Login() {
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState('leader2');
|
||||
const [password, setPassword] = useState('leader123');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [errors, setErrors] = useState({});
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
@ -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 CryptoJS from 'crypto-js';
|
||||
|
||||
const secretKey = import.meta.env.VITE_SECRETKEY;
|
||||
|
||||
// 从环境变量获取 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
|
||||
const WS_BASE_URL = API_URL.replace(/^http/, 'ws').replace(/\/api\/?$/, '');
|
||||
|
||||
let socket = null;
|
||||
let reconnectTimer = null;
|
||||
let pingInterval = null;
|
||||
let reconnectAttempts = 0; // 添加重连尝试计数器
|
||||
const RECONNECT_DELAY = 5000; // 5秒后尝试重连
|
||||
const PING_INTERVAL = 30000; // 30秒发送一次ping
|
||||
const MAX_RECONNECT_ATTEMPTS = 3; // 最大重连尝试次数
|
||||
|
||||
/**
|
||||
* 初始化WebSocket连接
|
||||
@ -30,18 +39,27 @@ export const initWebSocket = () => {
|
||||
try {
|
||||
// 从sessionStorage获取token
|
||||
const encryptedToken = sessionStorage.getItem('token');
|
||||
let token = '';
|
||||
if (!encryptedToken) {
|
||||
console.error('No token found, cannot connect to notification service');
|
||||
store.dispatch(setWebSocketConnected(false));
|
||||
reject(new Error('No token found'));
|
||||
return;
|
||||
}
|
||||
|
||||
const wsUrl = `${WS_BASE_URL}/ws/notifications?token=${encryptedToken}`;
|
||||
if (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.onopen = () => {
|
||||
console.log('WebSocket connection established');
|
||||
reconnectAttempts = 0; // 连接成功后重置重连计数器
|
||||
|
||||
// 更新Redux中的连接状态
|
||||
store.dispatch(setWebSocketConnected(true));
|
||||
|
||||
// 订阅通知频道
|
||||
subscribeToNotifications();
|
||||
@ -69,6 +87,8 @@ export const initWebSocket = () => {
|
||||
// 错误处理
|
||||
socket.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
// 更新Redux中的连接状态
|
||||
store.dispatch(setWebSocketConnected(false));
|
||||
reject(error);
|
||||
};
|
||||
|
||||
@ -76,21 +96,37 @@ export const initWebSocket = () => {
|
||||
socket.onclose = (event) => {
|
||||
console.log(`WebSocket connection closed: ${event.code} ${event.reason}`);
|
||||
|
||||
// 更新Redux中的连接状态
|
||||
store.dispatch(setWebSocketConnected(false));
|
||||
|
||||
// 清除ping定时器
|
||||
if (pingInterval) clearInterval(pingInterval);
|
||||
|
||||
// 如果不是正常关闭,尝试重连
|
||||
if (event.code !== 1000) {
|
||||
reconnectTimer = setTimeout(() => {
|
||||
console.log('Attempting to reconnect WebSocket...');
|
||||
initWebSocket().catch((err) => {
|
||||
console.error('Failed to reconnect WebSocket:', err);
|
||||
});
|
||||
}, RECONNECT_DELAY);
|
||||
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
||||
reconnectAttempts++;
|
||||
console.log(`WebSocket reconnection attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`);
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
console.log('Attempting to reconnect WebSocket...');
|
||||
initWebSocket().catch((err) => {
|
||||
console.error('Failed to reconnect WebSocket:', err);
|
||||
// 重连失败时更新Redux中的连接状态
|
||||
store.dispatch(setWebSocketConnected(false));
|
||||
});
|
||||
}, RECONNECT_DELAY);
|
||||
} else {
|
||||
console.log('Maximum reconnection attempts reached. Giving up.');
|
||||
// 达到最大重连次数时更新Redux中的连接状态
|
||||
store.dispatch(setWebSocketConnected(false));
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error initializing WebSocket:', error);
|
||||
// 更新Redux中的连接状态
|
||||
store.dispatch(setWebSocketConnected(false));
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
@ -156,6 +192,9 @@ export const closeWebSocket = () => {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
}
|
||||
|
||||
// 更新Redux中的连接状态
|
||||
store.dispatch(setWebSocketConnected(false));
|
||||
};
|
||||
|
||||
/**
|
||||
@ -218,7 +257,7 @@ const processNotification = (data) => {
|
||||
} else {
|
||||
timeDisplay = `${diffDays}天前`;
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
id: notificationData.id,
|
||||
type: notificationData.category,
|
||||
|
@ -1,60 +1,12 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { fetchNotifications, markNotificationRead, markAllNotificationsRead } from './notificationCenter.thunks';
|
||||
|
||||
const initialState = {
|
||||
notifications: [
|
||||
{
|
||||
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,
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
isConnected: false,
|
||||
loading: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
const notificationCenterSlice = createSlice({
|
||||
@ -73,7 +25,7 @@ const notificationCenterSlice = createSlice({
|
||||
// 将新通知添加到列表的开头
|
||||
state.notifications.unshift(action.payload);
|
||||
// 如果通知未读,增加未读计数
|
||||
if (!action.payload.isRead) {
|
||||
if (!action.payload.is_read) {
|
||||
state.unreadCount += 1;
|
||||
}
|
||||
}
|
||||
@ -81,15 +33,15 @@ const notificationCenterSlice = createSlice({
|
||||
|
||||
markNotificationAsRead: (state, action) => {
|
||||
const notification = state.notifications.find((n) => n.id === action.payload);
|
||||
if (notification && !notification.isRead) {
|
||||
notification.isRead = true;
|
||||
if (notification && !notification.is_read) {
|
||||
notification.is_read = true;
|
||||
state.unreadCount = Math.max(0, state.unreadCount - 1);
|
||||
}
|
||||
},
|
||||
|
||||
markAllNotificationsAsRead: (state) => {
|
||||
state.notifications.forEach((notification) => {
|
||||
notification.isRead = true;
|
||||
notification.is_read = true;
|
||||
});
|
||||
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 {
|
||||
|
135
src/store/notificationCenter/notificationCenter.thunks.js
Normal file
135
src/store/notificationCenter/notificationCenter.thunks.js
Normal 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 || {},
|
||||
};
|
||||
};
|
Loading…
Reference in New Issue
Block a user