feat: 添加通知功能,包括UI页面、状态管理Hook和API服务集成。
This commit is contained in:
@@ -1,132 +1,84 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, createContext, useContext } from 'react';
|
||||||
import { useState, useEffect, useCallback, createContext, useContext } from 'react';
|
import { notificationService, Notification } from '../services/notificationService';
|
||||||
|
|
||||||
export type NotificationType = 'info' | 'success' | 'warning' | 'error' | 'system';
|
|
||||||
|
|
||||||
export interface Notification {
|
|
||||||
id: string;
|
|
||||||
type: NotificationType;
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
timestamp: number;
|
|
||||||
read: boolean;
|
|
||||||
link?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NotificationContextType {
|
interface NotificationContextType {
|
||||||
notifications: Notification[];
|
notifications: Notification[];
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
markAsRead: (id: string) => void;
|
loading: boolean;
|
||||||
markAllAsRead: () => void;
|
markAsRead: (id: number) => Promise<void>;
|
||||||
deleteNotification: (id: string) => void;
|
markAllAsRead: () => Promise<void>;
|
||||||
addNotification: (notification: Omit<Notification, 'id' | 'timestamp' | 'read'>) => void;
|
refreshNotifications: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NotificationContext = createContext<NotificationContextType | undefined>(undefined);
|
const NotificationContext = createContext<NotificationContextType | undefined>(undefined);
|
||||||
|
|
||||||
const STORAGE_KEY = 'novault_notifications';
|
|
||||||
|
|
||||||
const MOCK_NOTIFICATIONS: Notification[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
type: 'system',
|
|
||||||
title: '系统升级通知',
|
|
||||||
message: 'Novault 已成功升级至 v2.3.0 版本,新增了专注模式和高级报表功能。',
|
|
||||||
timestamp: Date.now() - 1000 * 60 * 30, // 30 mins ago
|
|
||||||
read: false,
|
|
||||||
link: '/settings'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
type: 'warning',
|
|
||||||
title: '预算预警',
|
|
||||||
message: '您的 "餐饮" 类别预算已使用 85%,请注意控制支出。',
|
|
||||||
timestamp: Date.now() - 1000 * 60 * 60 * 2, // 2 hours ago
|
|
||||||
read: false,
|
|
||||||
link: '/budget'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
type: 'success',
|
|
||||||
title: '数据同步完成',
|
|
||||||
message: '您的本地数据已成功备份至云端。',
|
|
||||||
timestamp: Date.now() - 1000 * 60 * 60 * 24, // 1 day ago
|
|
||||||
read: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
type: 'info',
|
|
||||||
title: '新功能推荐',
|
|
||||||
message: '试试新的 "专注模式",点击右上角头像即可切换。',
|
|
||||||
timestamp: Date.now() - 1000 * 60 * 60 * 48, // 2 days ago
|
|
||||||
read: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '5',
|
|
||||||
type: 'error',
|
|
||||||
title: '连接失败',
|
|
||||||
message: '无法连接到汇率服务器,请检查您的网络设置。',
|
|
||||||
timestamp: Date.now() - 1000 * 60 * 60 * 72, // 3 days ago
|
|
||||||
read: true,
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
|
const [unreadCount, setUnreadCount] = useState<number>(0);
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchNotifications = useCallback(async () => {
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
try {
|
||||||
if (stored) {
|
// Fetch unread count
|
||||||
try {
|
const countRes = await notificationService.getUnreadCount();
|
||||||
setNotifications(JSON.parse(stored));
|
if (countRes.success) {
|
||||||
} catch (e) {
|
setUnreadCount(countRes.data.count);
|
||||||
console.error('Failed to parse notifications', e);
|
|
||||||
setNotifications(MOCK_NOTIFICATIONS);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
setNotifications(MOCK_NOTIFICATIONS);
|
// Fetch latest notifications (page 1, 10 items)
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(MOCK_NOTIFICATIONS));
|
// You might want to load more on demand, but this is initial state
|
||||||
|
const listRes = await notificationService.getNotifications(1, 10);
|
||||||
|
if (listRes.success) {
|
||||||
|
setNotifications(listRes.data.notifications);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch notifications:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const refreshNotifications = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await fetchNotifications();
|
||||||
|
setLoading(false);
|
||||||
|
}, [fetchNotifications]);
|
||||||
|
|
||||||
|
// Initial fetch and polling
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (notifications.length > 0) {
|
fetchNotifications();
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(notifications));
|
const interval = setInterval(fetchNotifications, 60000); // Poll every minute
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchNotifications]);
|
||||||
|
|
||||||
|
const markAsRead = useCallback(async (id: number) => {
|
||||||
|
try {
|
||||||
|
await notificationService.markAsRead(id);
|
||||||
|
// Optimistic update
|
||||||
|
setNotifications(prev => prev.map(n => n.id === id ? { ...n, is_read: true } : n));
|
||||||
|
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to mark as read:', error);
|
||||||
}
|
}
|
||||||
}, [notifications]);
|
|
||||||
|
|
||||||
const unreadCount = notifications.filter(n => !n.read).length;
|
|
||||||
|
|
||||||
const markAsRead = useCallback((id: string) => {
|
|
||||||
setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const markAllAsRead = useCallback(() => {
|
const markAllAsRead = useCallback(async () => {
|
||||||
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
|
try {
|
||||||
}, []);
|
await notificationService.markAllAsRead();
|
||||||
|
// Optimistic update
|
||||||
const deleteNotification = useCallback((id: string) => {
|
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
||||||
setNotifications(prev => prev.filter(n => n.id !== id));
|
setUnreadCount(0);
|
||||||
}, []);
|
} catch (error) {
|
||||||
|
console.error('Failed to mark all as read:', error);
|
||||||
const addNotification = useCallback((n: Omit<Notification, 'id' | 'timestamp' | 'read'>) => {
|
}
|
||||||
const newNotification: Notification = {
|
|
||||||
...n,
|
|
||||||
id: Date.now().toString(),
|
|
||||||
timestamp: Date.now(),
|
|
||||||
read: false,
|
|
||||||
};
|
|
||||||
setNotifications(prev => [newNotification, ...prev]);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NotificationContext.Provider value={{
|
<NotificationContext.Provider value={{
|
||||||
notifications,
|
notifications,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
|
loading,
|
||||||
markAsRead,
|
markAsRead,
|
||||||
markAllAsRead,
|
markAllAsRead,
|
||||||
deleteNotification,
|
refreshNotifications
|
||||||
addNotification
|
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</NotificationContext.Provider>
|
</NotificationContext.Provider>
|
||||||
|
|||||||
@@ -254,4 +254,48 @@
|
|||||||
.empty-state p {
|
.empty-state p {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Added styles for refactored notification page */
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: var(--glass-panel-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import type { Variants } from 'framer-motion';
|
import type { Variants } from 'framer-motion';
|
||||||
@@ -9,13 +8,13 @@ import './Notifications.css';
|
|||||||
type FilterType = 'all' | 'unread' | 'system';
|
type FilterType = 'all' | 'unread' | 'system';
|
||||||
|
|
||||||
export const Notifications: React.FC = () => {
|
export const Notifications: React.FC = () => {
|
||||||
const { notifications, markAsRead, markAllAsRead, deleteNotification } = useNotifications();
|
const { notifications, markAsRead, markAllAsRead, refreshNotifications, loading } = useNotifications();
|
||||||
const [filter, setFilter] = useState<FilterType>('all');
|
const [filter, setFilter] = useState<FilterType>('all');
|
||||||
|
|
||||||
const filteredNotifications = useMemo(() => {
|
const filteredNotifications = useMemo(() => {
|
||||||
switch (filter) {
|
switch (filter) {
|
||||||
case 'unread':
|
case 'unread':
|
||||||
return notifications.filter(n => !n.read);
|
return notifications.filter(n => !n.is_read);
|
||||||
case 'system':
|
case 'system':
|
||||||
return notifications.filter(n => n.type === 'system');
|
return notifications.filter(n => n.type === 'system');
|
||||||
default:
|
default:
|
||||||
@@ -26,15 +25,19 @@ export const Notifications: React.FC = () => {
|
|||||||
const getIcon = (type: string) => {
|
const getIcon = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'system': return 'solar:settings-bold-duotone';
|
case 'system': return 'solar:settings-bold-duotone';
|
||||||
case 'warning': return 'solar:danger-triangle-bold-duotone';
|
case 'warning': case 'alert': return 'solar:danger-triangle-bold-duotone';
|
||||||
case 'success': return 'solar:check-circle-bold-duotone';
|
case 'success': return 'solar:check-circle-bold-duotone';
|
||||||
case 'error': return 'solar:close-circle-bold-duotone';
|
case 'error': return 'solar:close-circle-bold-duotone';
|
||||||
default: return 'solar:info-circle-bold-duotone';
|
case 'info': return 'solar:info-circle-bold-duotone';
|
||||||
|
default: return 'solar:bell-bold-duotone';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (timestamp: number) => {
|
const formatTime = (timeStr: string) => {
|
||||||
const diff = Date.now() - timestamp;
|
const date = new Date(timeStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - date.getTime();
|
||||||
|
|
||||||
const minutes = Math.floor(diff / 60000);
|
const minutes = Math.floor(diff / 60000);
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
const days = Math.floor(hours / 24);
|
const days = Math.floor(hours / 24);
|
||||||
@@ -43,7 +46,7 @@ export const Notifications: React.FC = () => {
|
|||||||
if (minutes < 60) return `${minutes}分钟前`;
|
if (minutes < 60) return `${minutes}分钟前`;
|
||||||
if (hours < 24) return `${hours}小时前`;
|
if (hours < 24) return `${hours}小时前`;
|
||||||
if (days < 7) return `${days}天前`;
|
if (days < 7) return `${days}天前`;
|
||||||
return new Date(timestamp).toLocaleDateString('zh-CN');
|
return date.toLocaleDateString('zh-CN');
|
||||||
};
|
};
|
||||||
|
|
||||||
const containerVariants: Variants = {
|
const containerVariants: Variants = {
|
||||||
@@ -71,7 +74,15 @@ export const Notifications: React.FC = () => {
|
|||||||
<header className="notifications-header">
|
<header className="notifications-header">
|
||||||
<h1 className="notifications-title">消息中心</h1>
|
<h1 className="notifications-title">消息中心</h1>
|
||||||
<div className="notifications-actions">
|
<div className="notifications-actions">
|
||||||
{notifications.some(n => !n.read) && (
|
<button
|
||||||
|
className="refresh-btn"
|
||||||
|
onClick={() => refreshNotifications()}
|
||||||
|
disabled={loading}
|
||||||
|
title="刷新"
|
||||||
|
>
|
||||||
|
<Icon icon="solar:refresh-bold-duotone" width="18" className={loading ? 'spin' : ''} />
|
||||||
|
</button>
|
||||||
|
{notifications.some(n => !n.is_read) && (
|
||||||
<button className="mark-all-read-btn" onClick={markAllAsRead}>
|
<button className="mark-all-read-btn" onClick={markAllAsRead}>
|
||||||
<Icon icon="solar:check-read-bold-duotone" width="18" />
|
<Icon icon="solar:check-read-bold-duotone" width="18" />
|
||||||
<span>全部已读</span>
|
<span>全部已读</span>
|
||||||
@@ -93,7 +104,7 @@ export const Notifications: React.FC = () => {
|
|||||||
onClick={() => setFilter('unread')}
|
onClick={() => setFilter('unread')}
|
||||||
>
|
>
|
||||||
未读
|
未读
|
||||||
<span className="tab-count">{notifications.filter(n => !n.read).length}</span>
|
<span className="tab-count">{notifications.filter(n => !n.is_read).length}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`notification-tab ${filter === 'system' ? 'active' : ''}`}
|
className={`notification-tab ${filter === 'system' ? 'active' : ''}`}
|
||||||
@@ -117,8 +128,8 @@ export const Notifications: React.FC = () => {
|
|||||||
{filteredNotifications.map((notification) => (
|
{filteredNotifications.map((notification) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={notification.id}
|
key={notification.id}
|
||||||
className={`notification-item ${notification.read ? 'read' : 'unread'}`}
|
className={`notification-item ${notification.is_read ? 'read' : 'unread'}`}
|
||||||
onClick={() => markAsRead(notification.id)}
|
onClick={() => !notification.is_read && markAsRead(notification.id)}
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
layout
|
layout
|
||||||
>
|
>
|
||||||
@@ -129,23 +140,17 @@ export const Notifications: React.FC = () => {
|
|||||||
<div className="notification-content">
|
<div className="notification-content">
|
||||||
<div className="notification-header-row">
|
<div className="notification-header-row">
|
||||||
<h3 className="notification-item-title">{notification.title}</h3>
|
<h3 className="notification-item-title">{notification.title}</h3>
|
||||||
<span className="notification-time">{formatTime(notification.timestamp)}</span>
|
<span className="notification-time">{formatTime(notification.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="notification-message">{notification.message}</p>
|
<p className="notification-message">{notification.content}</p>
|
||||||
|
{notification.link && (
|
||||||
|
<a href={notification.link} className="notification-link" onClick={e => e.stopPropagation()}>
|
||||||
|
查看详情 →
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!notification.read && <div className="new-indicator" />}
|
{!notification.is_read && <div className="new-indicator" />}
|
||||||
|
|
||||||
<button
|
|
||||||
className="delete-btn"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
deleteNotification(notification.id);
|
|
||||||
}}
|
|
||||||
title="删除"
|
|
||||||
>
|
|
||||||
<Icon icon="solar:trash-bin-trash-bold-duotone" width="18" />
|
|
||||||
</button>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -161,6 +166,44 @@ export const Notifications: React.FC = () => {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.refresh-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.refresh-btn:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.notification-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.notification-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
52
src/services/notificationService.ts
Normal file
52
src/services/notificationService.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import api from './api';
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
type: string;
|
||||||
|
is_read: boolean;
|
||||||
|
link?: string;
|
||||||
|
read_at?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationListResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
notifications: Notification[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnreadCountResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationService = {
|
||||||
|
getNotifications: (page: number = 1, limit: number = 10, isRead?: boolean) => {
|
||||||
|
const params: Record<string, string | number | boolean> = { page, limit };
|
||||||
|
if (isRead !== undefined) {
|
||||||
|
params.is_read = isRead;
|
||||||
|
}
|
||||||
|
return api.get<NotificationListResponse>('/notifications', params);
|
||||||
|
},
|
||||||
|
|
||||||
|
getUnreadCount: () => {
|
||||||
|
return api.get<UnreadCountResponse>('/notifications/unread-count');
|
||||||
|
},
|
||||||
|
|
||||||
|
markAsRead: (id: number) => {
|
||||||
|
return api.put<{ success: boolean }>(`/notifications/${id}/read`);
|
||||||
|
},
|
||||||
|
|
||||||
|
markAllAsRead: () => {
|
||||||
|
return api.put<{ success: boolean }>('/notifications/read-all');
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user