From 155e3b3c96a19f5a0330c0a973ddaa642930cd04 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sun, 5 Jul 2026 23:21:08 +0800 Subject: [PATCH] feat: add Notifications full page with sidebar filter --- apps/admin/src/App.tsx | 9 + apps/admin/src/pages/Notifications/index.tsx | 183 +++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 apps/admin/src/pages/Notifications/index.tsx diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index f2a3005d..deb99957 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -25,6 +25,7 @@ import RolesPage from './pages/Roles'; import PermissionsPage from './pages/Permissions'; import AttendancePage from './pages/Attendance'; import TeacherWorkspacePage from './pages/TeacherWorkspace'; +import NotificationsPage from './pages/Notifications'; import PermissionRoute from './components/PermissionRoute'; const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { @@ -230,6 +231,14 @@ const App: React.FC = () => { } /> + + + + + }> + } /> + diff --git a/apps/admin/src/pages/Notifications/index.tsx b/apps/admin/src/pages/Notifications/index.tsx new file mode 100644 index 00000000..668e5603 --- /dev/null +++ b/apps/admin/src/pages/Notifications/index.tsx @@ -0,0 +1,183 @@ +import React, { useState, useEffect } from 'react'; +import { List, Typography, Menu, Layout, Button, Empty, Spin, Space } from 'antd'; +import { + BellOutlined, + DollarOutlined, + HomeOutlined, + TeamOutlined, + SettingOutlined, +} from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; +import api from '../../api'; + +const { Sider, Content } = Layout; + +interface NotificationItem { + id: number; + type: string; + title: string; + content: string; + link: string | null; + isRead: boolean; + createdAt: string; +} + +const typeMap: Record = { + bill_generated: { label: '账单', icon: }, + bill_paid: { label: '账单', icon: }, + check_in: { label: '入住', icon: }, + check_out: { label: '退宿', icon: }, + deposit_due: { label: '押金', icon: }, + deposit_refunded: { label: '押金', icon: }, + class_change: { label: '班级', icon: }, + schedule_conflict: { label: '排课', icon: }, + announcement: { label: '公告', icon: }, +}; + +function timeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return '刚刚'; + if (mins < 60) return `${mins}分钟前`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}小时前`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}天前`; + return new Date(dateStr).toLocaleDateString('zh-CN'); +} + +const NotificationsPage: React.FC = () => { + const [notifications, setNotifications] = useState([]); + const [filter, setFilter] = useState('all'); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + + const fetchData = async () => { + setLoading(true); + try { + const data = await api.get('/notifications?limit=50') as unknown as NotificationItem[]; + setNotifications(data); + } catch { /* ignore */ } + setLoading(false); + }; + + useEffect(() => { + fetchData(); + }, []); + + const handleClick = async (item: NotificationItem) => { + if (!item.isRead) { + try { + await api.put(`/notifications/${item.id}/read`); + setNotifications((prev) => + prev.map((n) => (n.id === item.id ? { ...n, isRead: true } : n)), + ); + } catch { /* ignore */ } + } + if (item.link) navigate(item.link); + }; + + const handleMarkAll = async () => { + try { + await api.put('/notifications/read-all'); + setNotifications((prev) => + prev.map((n) => ({ ...n, isRead: true })), + ); + } catch { /* ignore */ } + }; + + const filtered = filter === 'all' + ? notifications + : notifications.filter((n) => n.type === filter); + + return ( + + + setFilter(key)} + items={[ + { key: 'all', icon: , label: '全部' }, + { key: 'bill_generated', icon: , label: '账单' }, + { key: 'check_in', icon: , label: '入住' }, + { key: 'class_change', icon: , label: '班级' }, + { key: 'announcement', icon: , label: '公告' }, + ]} + /> + + +
+ 通知中心 + +
+ + {filtered.length === 0 ? ( + + ) : ( + { + const meta = typeMap[item.type] || { label: item.type, icon: }; + return ( + handleClick(item)} + style={{ + cursor: 'pointer', + padding: '16px 0', + backgroundColor: item.isRead ? 'transparent' : '#f0f7ff', + }} + > + + {meta.icon} + + } + title={ + + + {item.title} + + + {timeAgo(item.createdAt)} + + + } + description={ + item.content && ( + + {item.content} + + ) + } + /> + + ); + }} + /> + )} + +
+ + ); +}; + +export default NotificationsPage;