[dev]notification center

This commit is contained in:
susie-laptop 2025-04-16 09:55:20 -04:00
parent 8688db5cb4
commit 7db0c6fc26
8 changed files with 321 additions and 133 deletions

View File

@ -1,12 +0,0 @@
<!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

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" className="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -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,9 +29,14 @@ 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
@ -56,30 +68,66 @@ export default function NotificationCenter({ show, onClose }) {
};
}, [isAuthenticated, isConnected, dispatch]);
//
useEffect(() => {
if (show && isAuthenticated) {
dispatch(fetchNotifications());
}
}, [show, isAuthenticated, dispatch]);
const handleClearAll = () => {
setIsClearingAll(true);
//
setTimeout(() => {
dispatch(clearNotifications());
setIsClearingAll(false);
}, 300);
};
const handleMarkAllAsRead = () => {
//
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) => {
// 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);
}
navigate('/permissions');
// setSelectedRequest(notification);
// setShowSlideOver(true);
};
const handleCloseSlideOver = () => {
@ -138,24 +186,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,46 +220,49 @@ 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>
<div className='flex-grow-1'>
<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}
</h6>
<small className='text-muted'>{notification.time}</small>
<small className='text-muted'>{formatDate(notification.created_at)}</small>
</div>
<p className='mb-1 text-secondary'>{notification.content}</p>
<div className='d-flex gap-2'>
{notification.hasDetail && (
{notification.type === 'permission_request' && (
<button
className='btn btn-sm btn-dark'
className='btn btn-sm btn-outline-primary'
onClick={() => handleViewDetail(notification)}
>
查看详情
</button>
)}
{!notification.isRead && (
{!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>
</div>
))
)}
</div>

View File

@ -1,10 +1,11 @@
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 SvgIcon from '../components/SvgIcon';
import { fetchNotifications } from '../store/notificationCenter/notificationCenter.thunks';
export default function HeaderWithNav() {
const dispatch = useDispatch();
@ -32,6 +33,10 @@ export default function HeaderWithNav() {
// leader admin
const hasManagePermission = user && (user.role === 'leader' || user.role === 'admin');
useEffect(() => {
dispatch(fetchNotifications());
}, [dispatch]);
return (
<header>
<nav className='navbar navbar-expand-lg bg-white shadow-sm'>

View File

@ -521,6 +521,54 @@ const mockPermissionApi = {
},
};
// Mock notifications data
const mockNotifications = [
{
id: "7e383fe6-6776-4609-bfd2-76446593b3b8",
type: "permission_approved",
icon: "bi-shield-check",
title: "权限申请已通过",
content: "您对知识库 '管理员个人' 的权限申请已通过",
sender: "a9fa3c33-ca28-4ff1-b0ce-49adf0ec66f3",
receiver: "33cc280f-7bc6-4eff-b789-8434bb8c1f78",
is_read: false,
related_resource: "1",
created_at: "2025-04-12T04:49:51.724411",
hasDetail: true,
time: "1小时前"
},
{
id: "cad476f6-1b0c-49c3-b36f-5404debf9bc2",
type: "permission_updated",
icon: "bi-shield",
title: "知识库权限更新",
content: "管理员已更新您对知识库 '测试' 的权限",
sender: "a9fa3c33-ca28-4ff1-b0ce-49adf0ec66f3",
receiver: "33cc280f-7bc6-4eff-b789-8434bb8c1f78",
is_read: false,
related_resource: "29",
created_at: "2025-04-12T04:36:43.851494",
hasDetail: true,
time: "2小时前"
},
{
id: uuidv4(),
type: "system",
icon: "bi-info-circle",
title: "系统更新通知",
content: "系统将在今晚22:00-23:00进行维护更新请提前保存您的工作",
sender: "system",
receiver: "all",
is_read: true,
related_resource: null,
created_at: "2025-04-11T14:30:00.000000",
hasDetail: false,
time: "1天前"
}
];
let notifications = [...mockNotifications];
// Mock API functions
export const mockGet = async (url, params = {}) => {
console.log(`[MOCK API] GET ${url}`, params);
@ -717,7 +765,15 @@ export const mockGet = async (url, params = {}) => {
};
}
throw { response: { status: 404, data: { message: 'Not found' } } };
// Notifications API
if (url === '/notifications/') {
await mockDelay();
return [...notifications];
}
// Default case (if no other match)
console.warn(`Mock GET for ${url} not implemented`);
return { code: 404, message: 'Not found', data: null };
};
export const mockPost = async (url, data, isMultipart = false) => {
@ -942,11 +998,46 @@ export const mockPost = async (url, data, isMultipart = false) => {
};
}
// Mark all notifications as read
if (url === '/notifications/mark-all-as-read/') {
// Update all notifications to be read
notifications.forEach(notification => {
notification.is_read = true;
});
return {
code: 200,
message: 'All notifications marked as read successfully',
data: { success: true }
};
}
// Mark a notification as read
if (url.match(/\/notifications\/([^\/]+)\/mark-as-read\//)) {
const notificationId = url.match(/\/notifications\/([^\/]+)\/mark-as-read\//)[1];
const notificationIndex = notifications.findIndex(n => n.id === notificationId);
if (notificationIndex !== -1) {
notifications[notificationIndex] = {
...notifications[notificationIndex],
is_read: true
};
return {
code: 200,
message: 'Notification marked as read successfully',
data: { success: true, notification: notifications[notificationIndex] }
};
}
return { code: 404, message: 'Notification not found', data: null };
}
throw { response: { status: 404, data: { message: 'Not found' } } };
};
export const mockPut = async (url, data) => {
console.log(`[MOCK API] PUT ${url}`, data);
console.log('Mock PUT:', url, data);
await mockDelay();
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 600));
@ -1023,7 +1114,25 @@ export const mockPut = async (url, data) => {
};
}
throw { response: { status: 404, data: { message: 'Not found' } } };
// Mark notification as read
if (url.match(/\/notifications\/([^\/]+)\/read/)) {
const notificationId = url.match(/\/notifications\/([^\/]+)\/read/)[1];
const notificationIndex = notifications.findIndex(n => n.id === notificationId);
if (notificationIndex !== -1) {
notifications[notificationIndex] = {
...notifications[notificationIndex],
is_read: true
};
return { success: true, notification: notifications[notificationIndex] };
}
return { code: 404, message: 'Notification not found', data: null };
}
// Default case
console.warn(`Mock PUT for ${url} not implemented`);
return { code: 404, message: 'Not found', data: null };
};
export const mockDelete = async (url) => {

View File

@ -1,5 +1,8 @@
import { addNotification, markNotificationAsRead } 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 || 'http://81.69.223.133:8008';
@ -32,13 +35,16 @@ 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');
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);

View File

@ -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 {

View File

@ -0,0 +1,50 @@
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 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);
}
}
);