diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md
index 11bbed1..f003164 100644
--- a/web-react/MIGRATION_MATRIX.md
+++ b/web-react/MIGRATION_MATRIX.md
@@ -8,5 +8,6 @@
| `/dashboard` | 角色化首页 | 已登录用户 | 管理总览;学生课表/消息/考试/预警;教师教学班/成绩登记进度 | verification |
| `/account` | 个人账户 | 已登录用户 | TOTP、恢复码、SSO 绑定;Passkey 浏览器仪式尚未开放 | verification |
| `/users` | 用户与权限 | SuperAdmin | 列表、筛选、分页、创建、启停、重置密码、角色范围、Excel | verification |
+| `/notifications` | 通知中心 | 已登录用户 | 收件箱筛选、游标分页、已读回执;已发送;定向群发暂回旧系统 | building |
后续从 `web/src/router/index.ts` 逐条补全。只有关键合同和各状态验收完整后,状态才能更新为 `complete`。
diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx
index 7e5aa3f..7c98567 100644
--- a/web-react/src/app/App.tsx
+++ b/web-react/src/app/App.tsx
@@ -9,6 +9,7 @@ const LoginPage = lazy(() => import('../pages/LoginPage'))
const DashboardPage = lazy(() => import('../pages/DashboardPage'))
const AccountPage = lazy(() => import('../pages/AccountPage'))
const UsersPage = lazy(() => import('../pages/UsersPage'))
+const NotificationsPage = lazy(() => import('../pages/NotificationsPage'))
const SsoCallbackPage = lazy(() => import('../pages/SsoCallbackPage'))
const SsoBindPage = lazy(() => import('../pages/SsoBindPage'))
const AppShell = lazy(() => import('../layout/AppShell'))
@@ -52,6 +53,7 @@ function AppRoutes() {
} />
} />
} />
+ } />
} />
} />
diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx
index 3023d8e..7f1d4d6 100644
--- a/web-react/src/layout/AppShell.tsx
+++ b/web-react/src/layout/AppShell.tsx
@@ -8,8 +8,10 @@ import {
MenuUnfoldOutlined,
SafetyCertificateOutlined,
} from '@ant-design/icons'
-import { Avatar, Button, Layout, Menu, Tag } from 'antd'
+import { Avatar, Badge, Button, Layout, Menu, Tag } from 'antd'
+import { useQuery } from '@tanstack/react-query'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
+import http from '../api/http'
import { branding } from '../config/branding'
import { useAuthStore } from '../stores/auth'
import BrandMark from '../components/BrandMark'
@@ -26,13 +28,18 @@ export default function AppShell() {
const roles = user?.roles ?? []
const isStudentHome = roles.includes('Student') && !roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(role))
const isTeacherHome = roles.includes('Teacher') && !roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Student'].includes(role))
- const legacyBase = (import.meta.env.VITE_LEGACY_APP_URL ?? 'http://localhost:5255').replace(/\/$/, '')
+ const unreadQuery = useQuery({
+ queryKey: ['notification-unread-count'],
+ queryFn: async () => (await http.get<{ count: number }>('/notifications/unread-count')).data.count,
+ refetchInterval: 60_000,
+ })
useEffect(() => { void refreshUser().catch(() => undefined) }, [refreshUser])
const items = useMemo(() => [
{ key: '/dashboard', icon: , label: isStudentHome ? '学习首页' : isTeacherHome ? '教学工作台' : '教务总览' },
{ key: '/account', icon: , label: '个人账户' },
+ { key: '/notifications', icon: , label: '通知中心' },
...(user?.roles.includes('SuperAdmin')
? [{ key: '/users', icon: , label: '用户与权限' }]
: []),
@@ -87,7 +94,9 @@ export default function AppShell() {
当前工作区{pageTitle}
-
} onClick={() => window.location.assign(`${legacyBase}/notifications`)} />
+
+ } onClick={() => navigate('/notifications')} />
+
预览
{user?.displayName ?? '用户'}{user?.roles.join(' · ') || '正在读取权限'}
{user?.displayName?.slice(0, 1) ?? '明'}
diff --git a/web-react/src/pages/NotificationsPage.tsx b/web-react/src/pages/NotificationsPage.tsx
new file mode 100644
index 0000000..e093315
--- /dev/null
+++ b/web-react/src/pages/NotificationsPage.tsx
@@ -0,0 +1,159 @@
+import { useMemo, useState } from 'react'
+import {
+ BellOutlined,
+ CheckOutlined,
+ LeftOutlined,
+ MessageOutlined,
+ ReloadOutlined,
+ RightOutlined,
+ SearchOutlined,
+ SendOutlined,
+} from '@ant-design/icons'
+import { Alert, App, Button, Checkbox, Empty, Input, Modal, Select, Skeleton, Tag } from 'antd'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { useNavigate } from 'react-router-dom'
+import http, { apiErrorMessage } from '../api/http'
+import PageHeader from '../components/PageHeader'
+import { useAuthStore } from '../stores/auth'
+import { openLegacy } from './dashboard/legacy'
+import './notifications.css'
+
+type Tab = 'inbox' | 'sent'
+interface NotificationItem {
+ id: string; title: string; content: string; category: string; isRead: boolean; linkUrl?: string
+ senderName: string; audienceName?: string; isManual: boolean; createdAt: string
+}
+interface SentItem { id: string; title: string; content: string; category: string; audienceName: string; recipientCount: number; createdAt: string }
+interface CursorResult
{ items: T[]; hasMore: boolean; nextCursor?: string; unreadCount?: number }
+
+const pageSize = 20
+const senderRoles = ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Teacher']
+const categories: Record = {
+ General: { label: '一般通知', color: 'blue' }, Approval: { label: '审核待办', color: 'purple' },
+ Schedule: { label: '课程变动', color: 'cyan' }, Grade: { label: '成绩通知', color: 'green' },
+ Attendance: { label: '考勤消息', color: 'orange' }, CourseSelection: { label: '选课消息', color: 'geekblue' },
+ Warning: { label: '学业预警', color: 'red' },
+}
+
+function plainText(content: string) {
+ const holder = document.createElement('div')
+ holder.innerHTML = content
+ return (holder.textContent ?? '').replace(/\s+/g, ' ').trim()
+}
+
+function displayTime(value: string) {
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) return value
+ const now = new Date()
+ const sameDay = date.toDateString() === now.toDateString()
+ return new Intl.DateTimeFormat('zh-CN', sameDay
+ ? { hour: '2-digit', minute: '2-digit', hour12: false }
+ : { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }).format(date)
+}
+
+export default function NotificationsPage() {
+ const { message } = App.useApp()
+ const navigate = useNavigate()
+ const queryClient = useQueryClient()
+ const roles = useAuthStore((state) => state.user?.roles ?? [])
+ const canSend = roles.some((role) => senderRoles.includes(role))
+ const [tab, setTab] = useState('inbox')
+ const [category, setCategory] = useState()
+ const [keywordInput, setKeywordInput] = useState('')
+ const [keyword, setKeyword] = useState('')
+ const [unreadOnly, setUnreadOnly] = useState(false)
+ const [inboxCursors, setInboxCursors] = useState([])
+ const [sentCursors, setSentCursors] = useState([])
+ const [selected, setSelected] = useState(null)
+
+ const inboxCursor = inboxCursors.at(-1)
+ const sentCursor = sentCursors.at(-1)
+ const inbox = useQuery({
+ queryKey: ['notifications', 'inbox', inboxCursor, category, keyword, unreadOnly],
+ queryFn: async () => (await http.get>('/notifications', { params: { cursor: inboxCursor, pageSize, category, keyword: keyword || undefined, unreadOnly: unreadOnly || undefined } })).data,
+ enabled: tab === 'inbox',
+ })
+ const sent = useQuery({
+ queryKey: ['notifications', 'sent', sentCursor],
+ queryFn: async () => (await http.get>('/notifications/sent', { params: { cursor: sentCursor, pageSize } })).data,
+ enabled: tab === 'sent' && canSend,
+ })
+
+ const activeQuery = tab === 'inbox' ? inbox : sent
+ const unreadCount = inbox.data?.unreadCount ?? 0
+ const groupedInbox = useMemo(() => inbox.data?.items ?? [], [inbox.data?.items])
+
+ const markRead = useMutation({
+ mutationFn: (id: string) => http.post(`/notifications/${id}/read`),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ['notifications', 'inbox'] })
+ void queryClient.invalidateQueries({ queryKey: ['notification-unread-count'] })
+ },
+ })
+ const markAll = useMutation({
+ mutationFn: () => http.post('/notifications/read-all'),
+ onSuccess: () => {
+ message.success('全部消息已标为已读')
+ void queryClient.invalidateQueries({ queryKey: ['notifications', 'inbox'] })
+ void queryClient.invalidateQueries({ queryKey: ['notification-unread-count'] })
+ },
+ onError: (error) => message.error(apiErrorMessage(error)),
+ })
+
+ function resetInbox() { setInboxCursors([]) }
+ function submitSearch() { setKeyword(keywordInput.trim()); resetInbox() }
+ async function openNotification(item: NotificationItem) {
+ if (!item.isRead) await markRead.mutateAsync(item.id).catch(() => undefined)
+ if (!item.linkUrl) { setSelected({ ...item, isRead: true }); return }
+ const migrated = ['/dashboard', '/account', '/users', '/notifications']
+ const path = item.linkUrl.startsWith('/next/') ? item.linkUrl.slice(5) : item.linkUrl
+ if (migrated.some((route) => path === route || path.startsWith(`${route}?`))) navigate(path)
+ else if (path.startsWith('/')) openLegacy(path)
+ else window.location.assign(path)
+ }
+
+ return (
+
+ UNREAD{unreadCount}条未读消息 } />
+
+
+ {canSend && }
+ {canSend && } onClick={() => openLegacy('/notifications')}>在现有系统发消息}
+
+
+ {tab === 'inbox' && }
+
+ {activeQuery.isLoading ? : activeQuery.isError ? void activeQuery.refetch()}>重新加载} /> : tab === 'inbox' ? (
+
+ {groupedInbox.map((item) => void openNotification(item)} onKeyDown={(event) => { if (event.key === 'Enter') void openNotification(item) }}>
+
+ {item.title}
{plainText(item.content)}
+ )}
+ {!groupedInbox.length && }
+
+ ) : (
+
+ {(sent.data?.items ?? []).map((item) =>
{categories[item.category]?.label ?? '一般通知'}{item.recipientCount} 个接收账号{item.title}
{plainText(item.content)}
)}
+ {!sent.data?.items.length && }
+
+ )}
+
+ {((tab === 'inbox' && (inboxCursors.length || inbox.data?.hasMore)) || (tab === 'sent' && (sentCursors.length || sent.data?.hasMore))) && }
+
+ setSelected(null)}>关闭} onCancel={() => setSelected(null)}>
+ {selected && {categories[selected.category]?.label ?? '系统消息'}{displayTime(selected.createdAt)}
{plainText(selected.content)}
}
+
+
+ )
+}
diff --git a/web-react/src/pages/dashboard/StudentDashboardPage.tsx b/web-react/src/pages/dashboard/StudentDashboardPage.tsx
index 462d570..3c9da25 100644
--- a/web-react/src/pages/dashboard/StudentDashboardPage.tsx
+++ b/web-react/src/pages/dashboard/StudentDashboardPage.tsx
@@ -114,8 +114,8 @@ export default function StudentDashboardPage() {
{courses.length ? {courses.map((course) =>
)}
: } title="今天没有课程" detail={week ? '可以安排自习或处理待办。' : '当前不在教学周内。'} />}
- INBOX
消息中心
- {data.notices.length ? {data.notices.slice(0, 4).map((item) =>
)}
: } title="暂无消息" detail="新的教务通知会显示在这里。" />}
+ INBOX
消息中心
+ {data.notices.length ? {data.notices.slice(0, 4).map((item) =>
)}
: } title="暂无消息" detail="新的教务通知会显示在这里。" />}
UPCOMING
近期考试
diff --git a/web-react/src/pages/notifications.css b/web-react/src/pages/notifications.css
new file mode 100644
index 0000000..3d963d8
--- /dev/null
+++ b/web-react/src/pages/notifications.css
@@ -0,0 +1,56 @@
+.notifications-page { gap: 16px; }
+.unread-ledger { min-width: 174px; padding: 13px 16px; display: grid; grid-template-columns: 1fr auto; gap: 3px 18px; border-left: 3px solid var(--teal); background: var(--teal-soft); }
+.unread-ledger span { color: var(--teal); font: 700 9px var(--font-data); letter-spacing: .15em; }
+.unread-ledger strong { grid-row: 1 / 3; grid-column: 2; align-self: center; color: var(--teal); font: 700 31px/1 var(--font-data); }
+.unread-ledger small { color: #63756f; font-size: 10px; }
+.unread-ledger.is-quiet { border-color: var(--line-strong); background: #f7f8fa; }
+.unread-ledger.is-quiet span, .unread-ledger.is-quiet strong { color: #728094; }
+.notification-switcher { min-height: 58px; padding: 8px; display: flex; align-items: center; gap: 6px; border: 1px solid var(--line); background: var(--paper); }
+.notification-switcher > button:not(.ant-btn) { min-height: 40px; padding: 0 16px; display: inline-flex; align-items: center; gap: 8px; border: 0; color: #5d687a; background: transparent; cursor: pointer; font-size: 12px; }
+.notification-switcher > button:not(.ant-btn).is-active { color: white; background: var(--brand-deep); }
+.notification-switcher button b { min-width: 20px; padding: 2px 6px; border-radius: 10px; color: var(--brand-deep); background: var(--teal-bright); font: 700 10px var(--font-data); }
+.notification-switcher .ant-btn { margin-left: auto; }
+.notification-toolbar { padding: 12px 14px; display: grid; grid-template-columns: 180px minmax(240px, 1fr) auto auto auto auto; gap: 10px; align-items: center; border: 1px solid var(--line); background: #fafbfd; }
+.notification-feed { min-height: 300px; padding: 8px 24px 22px; border: 1px solid var(--line); background: var(--paper); }
+.notification-entry { display: grid; grid-template-columns: 94px minmax(0, 1fr); gap: 20px; cursor: pointer; }
+.notification-entry__time { min-height: 150px; display: grid; grid-template-columns: 1fr 14px; gap: 10px; align-items: start; color: #788599; position: relative; }
+.notification-entry__time time { padding-top: 25px; font: 10px/1.4 var(--font-data); text-align: right; }
+.notification-entry__time::after { content: ""; width: 1px; position: absolute; right: 6px; top: 39px; bottom: -25px; background: #dfe6eb; }
+.notification-entry:last-child .notification-entry__time::after { bottom: 18px; }
+.notification-entry__time i { width: 11px; height: 11px; margin-top: 27px; border: 2px solid #aeb9c7; border-radius: 50%; background: white; position: relative; z-index: 1; }
+.notification-entry.is-unread .notification-entry__time i { border-color: var(--teal); background: var(--teal-bright); box-shadow: 0 0 0 5px var(--teal-soft); }
+.notification-entry__body { min-width: 0; padding: 21px 4px 21px 0; border-bottom: 1px solid #edf0f3; }
+.notification-entry__body header, .notification-entry__body footer { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
+.notification-entry__body header > div { display: flex; flex-wrap: wrap; gap: 5px; }
+.notification-entry__body header > span { color: var(--teal); font-size: 10px; }
+.notification-entry__body h2 { margin: 11px 0 7px; color: #2a374b; font-size: 15px; line-height: 1.45; }
+.notification-entry.is-unread h2 { color: var(--brand-deep); font-weight: 700; }
+.notification-entry__body p { display: -webkit-box; margin: 0; overflow: hidden; color: #657184; font-size: 12px; line-height: 1.75; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
+.notification-entry__body footer { margin-top: 12px; justify-content: flex-start; flex-wrap: wrap; color: #8a94a3; font-size: 10px; }
+.unread-mark { padding: 2px 7px; color: var(--teal); background: var(--teal-soft); font-size: 9px; font-weight: 700; }
+.notification-entry--warning .notification-entry__time i { border-color: var(--amber); }
+.notification-entry--sent { cursor: default; }
+.cursor-pager { display: flex; justify-content: flex-end; align-items: center; gap: 12px; }
+.cursor-pager span { color: var(--ink-muted); font: 11px var(--font-data); }
+.notification-detail { display: grid; gap: 18px; }
+.notification-detail > div { display: flex; justify-content: space-between; align-items: center; color: var(--ink-muted); font-size: 11px; }
+.notification-detail p { margin: 0; white-space: pre-wrap; color: var(--ink); font-size: 13px; line-height: 1.85; }
+.notification-detail footer { padding-top: 12px; border-top: 1px solid var(--line); color: var(--ink-muted); font-size: 11px; }
+
+@media (max-width: 980px) {
+ .notification-toolbar { grid-template-columns: 1fr 1fr auto; }
+}
+@media (max-width: 640px) {
+ .notification-switcher { align-items: stretch; flex-wrap: wrap; }
+ .notification-switcher > button:not(.ant-btn) { flex: 1; justify-content: center; }
+ .notification-switcher .ant-btn { width: 100%; margin-left: 0; }
+ .notification-toolbar { grid-template-columns: 1fr 1fr; }
+ .notification-toolbar .ant-input-affix-wrapper { grid-column: 1 / -1; }
+ .notification-feed { padding-inline: 12px; }
+ .notification-entry { grid-template-columns: 1fr; gap: 0; }
+ .notification-entry__time { min-height: 0; display: block; padding-top: 18px; }
+ .notification-entry__time time { padding: 0; }
+ .notification-entry__time i, .notification-entry__time::after { display: none; }
+ .notification-entry__body { padding-top: 9px; }
+ .cursor-pager { justify-content: space-between; }
+}