diff --git a/src/hooks/useNotifications.tsx b/src/hooks/useNotifications.tsx index 6810b68..407c04f 100644 --- a/src/hooks/useNotifications.tsx +++ b/src/hooks/useNotifications.tsx @@ -1,132 +1,84 @@ - -import { useState, useEffect, useCallback, createContext, useContext } from 'react'; - -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; -} +import React, { useState, useEffect, useCallback, createContext, useContext } from 'react'; +import { notificationService, Notification } from '../services/notificationService'; interface NotificationContextType { notifications: Notification[]; unreadCount: number; - markAsRead: (id: string) => void; - markAllAsRead: () => void; - deleteNotification: (id: string) => void; - addNotification: (notification: Omit) => void; + loading: boolean; + markAsRead: (id: number) => Promise; + markAllAsRead: () => Promise; + refreshNotifications: () => Promise; } const NotificationContext = createContext(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 }) => { const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + const [loading, setLoading] = useState(false); - useEffect(() => { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored) { - try { - setNotifications(JSON.parse(stored)); - } catch (e) { - console.error('Failed to parse notifications', e); - setNotifications(MOCK_NOTIFICATIONS); + const fetchNotifications = useCallback(async () => { + try { + // Fetch unread count + const countRes = await notificationService.getUnreadCount(); + if (countRes.success) { + setUnreadCount(countRes.data.count); } - } else { - setNotifications(MOCK_NOTIFICATIONS); - localStorage.setItem(STORAGE_KEY, JSON.stringify(MOCK_NOTIFICATIONS)); + + // Fetch latest notifications (page 1, 10 items) + // 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(() => { - if (notifications.length > 0) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(notifications)); + fetchNotifications(); + 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(() => { - setNotifications(prev => prev.map(n => ({ ...n, read: true }))); - }, []); - - const deleteNotification = useCallback((id: string) => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, []); - - const addNotification = useCallback((n: Omit) => { - const newNotification: Notification = { - ...n, - id: Date.now().toString(), - timestamp: Date.now(), - read: false, - }; - setNotifications(prev => [newNotification, ...prev]); + const markAllAsRead = useCallback(async () => { + try { + await notificationService.markAllAsRead(); + // Optimistic update + setNotifications(prev => prev.map(n => ({ ...n, is_read: true }))); + setUnreadCount(0); + } catch (error) { + console.error('Failed to mark all as read:', error); + } }, []); return ( {children} diff --git a/src/pages/Notifications/Notifications.css b/src/pages/Notifications/Notifications.css index 46c8f64..ee94313 100644 --- a/src/pages/Notifications/Notifications.css +++ b/src/pages/Notifications/Notifications.css @@ -254,4 +254,48 @@ .empty-state p { margin-top: 1rem; font-size: 1rem; -} \ No newline at end of file +} + +/* 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; +} diff --git a/src/pages/Notifications/Notifications.tsx b/src/pages/Notifications/Notifications.tsx index 42910d4..58fde2a 100644 --- a/src/pages/Notifications/Notifications.tsx +++ b/src/pages/Notifications/Notifications.tsx @@ -1,4 +1,3 @@ - import React, { useState, useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import type { Variants } from 'framer-motion'; @@ -9,13 +8,13 @@ import './Notifications.css'; type FilterType = 'all' | 'unread' | 'system'; export const Notifications: React.FC = () => { - const { notifications, markAsRead, markAllAsRead, deleteNotification } = useNotifications(); + const { notifications, markAsRead, markAllAsRead, refreshNotifications, loading } = useNotifications(); const [filter, setFilter] = useState('all'); const filteredNotifications = useMemo(() => { switch (filter) { case 'unread': - return notifications.filter(n => !n.read); + return notifications.filter(n => !n.is_read); case 'system': return notifications.filter(n => n.type === 'system'); default: @@ -26,15 +25,19 @@ export const Notifications: React.FC = () => { const getIcon = (type: string) => { switch (type) { 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 '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 diff = Date.now() - timestamp; + const formatTime = (timeStr: string) => { + const date = new Date(timeStr); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const minutes = Math.floor(diff / 60000); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); @@ -43,7 +46,7 @@ export const Notifications: React.FC = () => { if (minutes < 60) return `${minutes}分钟前`; if (hours < 24) return `${hours}小时前`; if (days < 7) return `${days}天前`; - return new Date(timestamp).toLocaleDateString('zh-CN'); + return date.toLocaleDateString('zh-CN'); }; const containerVariants: Variants = { @@ -71,7 +74,15 @@ export const Notifications: React.FC = () => {

消息中心

- {notifications.some(n => !n.read) && ( + + {notifications.some(n => !n.is_read) && ( + {!notification.is_read &&
} ))} @@ -161,6 +166,44 @@ export const Notifications: React.FC = () => { )} + +
); }; diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts new file mode 100644 index 0000000..af462a9 --- /dev/null +++ b/src/services/notificationService.ts @@ -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 = { page, limit }; + if (isRead !== undefined) { + params.is_read = isRead; + } + return api.get('/notifications', params); + }, + + getUnreadCount: () => { + return api.get('/notifications/unread-count'); + }, + + markAsRead: (id: number) => { + return api.put<{ success: boolean }>(`/notifications/${id}/read`); + }, + + markAllAsRead: () => { + return api.put<{ success: boolean }>('/notifications/read-all'); + } +};