From 68f74aec86dc50367826c8e297d72c6facb8d68f Mon Sep 17 00:00:00 2001 From: biss Date: Thu, 1 Oct 2026 13:39:42 +0800 Subject: [PATCH] feat(web): migrate notification inbox to React Add the React notification route with cursor-based inbox and sent-message views, read receipts, filters, unread badges, and safe fallback links for functionality that remains in the legacy frontend. --- web-react/MIGRATION_MATRIX.md | 1 + web-react/src/app/App.tsx | 2 + web-react/src/layout/AppShell.tsx | 15 +- web-react/src/pages/NotificationsPage.tsx | 159 ++++++++++++++++++ .../pages/dashboard/StudentDashboardPage.tsx | 4 +- web-react/src/pages/notifications.css | 56 ++++++ 6 files changed, 232 insertions(+), 5 deletions(-) create mode 100644 web-react/src/pages/NotificationsPage.tsx create mode 100644 web-react/src/pages/notifications.css 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}
-
} /> +
+ + {canSend && } + {canSend && } +
+ + {tab === 'inbox' &&
+ } allowClear placeholder="搜索标题或正文" onChange={(event) => setKeywordInput(event.target.value)} onPressEnter={submitSearch} onClear={() => { setKeyword(''); resetInbox() }} /> + { setUnreadOnly(event.target.checked); resetInbox() }}>仅看未读 + + + +
} + + {activeQuery.isLoading ?
: activeQuery.isError ? void activeQuery.refetch()}>重新加载} /> : tab === 'inbox' ? ( +
+ {groupedInbox.map((item) =>
void openNotification(item)} onKeyDown={(event) => { if (event.key === 'Enter') void openNotification(item) }}> +
+
{categories[item.category]?.label ?? '系统消息'}{item.isManual && 人工发送}{!item.isRead && 未读}
{item.linkUrl && 查看详情 }

{item.title}

{plainText(item.content)}

来自 {item.senderName}{item.audienceName && 接收范围:{item.audienceName}}
+
)} + {!groupedInbox.length && } +
+ ) : ( +
+ {(sent.data?.items ?? []).map((item) =>
{categories[item.category]?.label ?? '一般通知'}{item.recipientCount} 个接收账号

{item.title}

{plainText(item.content)}

发送至 {item.audienceName}
)} + {!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)}

来自 {selected.senderName}{selected.audienceName ? ` · ${selected.audienceName}` : ''}
} +
+ + ) +} 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; } +}