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 (
+
+
+
+
+
+ 通知中心
+
+
+
+ {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;