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.
This commit is contained in:
biss authored and biss committed 2026-10-03 21:38:14 +08:00
1 parent 850eb6b05c
commit 338006c51e
6 files changed
+232 -5

No files matched your search

+2
View File
@@ -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() {
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/account" element={<AccountPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
+12 -3
View File
@@ -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: <AppstoreOutlined />, label: isStudentHome ? '学习首页' : isTeacherHome ? '教学工作台' : '教务总览' },
{ key: '/account', icon: <IdcardOutlined />, label: '个人账户' },
{ key: '/notifications', icon: <BellOutlined />, label: '通知中心' },
...(user?.roles.includes('SuperAdmin')
? [{ key: '/users', icon: <SafetyCertificateOutlined />, label: '用户与权限' }]
: []),
@@ -87,7 +94,9 @@ export default function AppShell() {
<div className="shell-context"><span>当前工作区</span><strong>{pageTitle}</strong></div>
</div>
<div className="shell-user">
<Button type="text" aria-label="通知中心" icon={<BellOutlined />} onClick={() => window.location.assign(`${legacyBase}/notifications`)} />
<Badge count={unreadQuery.data ?? 0} size="small" overflowCount={99}>
<Button type="text" aria-label="通知中心" icon={<BellOutlined />} onClick={() => navigate('/notifications')} />
</Badge>
<Tag color="geekblue">预览</Tag>
<div className="shell-user__copy"><strong>{user?.displayName ?? '用户'}</strong><small>{user?.roles.join(' · ') || '正在读取权限'}</small></div>
<Avatar style={{ background: '#54cfbd', color: '#163f43' }}>{user?.displayName?.slice(0, 1) ?? '明'}</Avatar>
+159
View File
@@ -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<T> { items: T[]; hasMore: boolean; nextCursor?: string; unreadCount?: number }
const pageSize = 20
const senderRoles = ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Teacher']
const categories: Record<string, { label: string; color: string }> = {
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<Tab>('inbox')
const [category, setCategory] = useState<string>()
const [keywordInput, setKeywordInput] = useState('')
const [keyword, setKeyword] = useState('')
const [unreadOnly, setUnreadOnly] = useState(false)
const [inboxCursors, setInboxCursors] = useState<string[]>([])
const [sentCursors, setSentCursors] = useState<string[]>([])
const [selected, setSelected] = useState<NotificationItem | null>(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<CursorResult<NotificationItem>>('/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<CursorResult<SentItem>>('/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 (
<main className="page-container notifications-page">
<PageHeader kicker="通知中心" title="消息与待办" description="按时间汇总系统通知、课程变动和审核事项,未读状态与现有系统实时同步。" aside={<div className={`unread-ledger${unreadCount ? '' : ' is-quiet'}`}><span>UNREAD</span><strong>{unreadCount}</strong><small>条未读消息</small></div>} />
<section className="notification-switcher" aria-label="通知中心视图">
<button className={tab === 'inbox' ? 'is-active' : ''} onClick={() => setTab('inbox')}><BellOutlined /> 收件箱 {unreadCount > 0 && <b>{unreadCount}</b>}</button>
{canSend && <button className={tab === 'sent' ? 'is-active' : ''} onClick={() => setTab('sent')}><MessageOutlined /> 已发送</button>}
{canSend && <Button type="primary" icon={<SendOutlined />} onClick={() => openLegacy('/notifications')}>在现有系统发消息</Button>}
</section>
{tab === 'inbox' && <section className="notification-toolbar">
<Select allowClear value={category} placeholder="全部类型" options={Object.entries(categories).map(([value, meta]) => ({ value, label: meta.label }))} onChange={(value) => { setCategory(value); resetInbox() }} />
<Input value={keywordInput} prefix={<SearchOutlined />} allowClear placeholder="搜索标题或正文" onChange={(event) => setKeywordInput(event.target.value)} onPressEnter={submitSearch} onClear={() => { setKeyword(''); resetInbox() }} />
<Checkbox checked={unreadOnly} onChange={(event) => { setUnreadOnly(event.target.checked); resetInbox() }}>仅看未读</Checkbox>
<Button icon={<SearchOutlined />} onClick={submitSearch}>筛选</Button>
<Button icon={<CheckOutlined />} disabled={!unreadCount} loading={markAll.isPending} onClick={() => markAll.mutate()}>全部已读</Button>
<Button icon={<ReloadOutlined />} loading={activeQuery.isFetching} onClick={() => void activeQuery.refetch()}>刷新</Button>
</section>}
{activeQuery.isLoading ? <section className="notification-feed"><Skeleton active paragraph={{ rows: 10 }} /></section> : activeQuery.isError ? <Alert type="error" showIcon message="通知暂时无法加载" description={apiErrorMessage(activeQuery.error)} action={<Button onClick={() => void activeQuery.refetch()}>重新加载</Button>} /> : tab === 'inbox' ? (
<section className="notification-feed" aria-live="polite">
{groupedInbox.map((item) => <article key={item.id} className={`notification-entry notification-entry--${item.category.toLowerCase()}${item.isRead ? '' : ' is-unread'}`} tabIndex={0} role="button" onClick={() => void openNotification(item)} onKeyDown={(event) => { if (event.key === 'Enter') void openNotification(item) }}>
<div className="notification-entry__time"><time>{displayTime(item.createdAt)}</time><i /></div>
<div className="notification-entry__body"><header><div><Tag color={categories[item.category]?.color}>{categories[item.category]?.label ?? '系统消息'}</Tag>{item.isManual && <Tag>人工发送</Tag>}{!item.isRead && <span className="unread-mark">未读</span>}</div>{item.linkUrl && <span>查看详情 <RightOutlined /></span>}</header><h2>{item.title}</h2><p>{plainText(item.content)}</p><footer><span>来自 {item.senderName}</span>{item.audienceName && <span>接收范围:{item.audienceName}</span>}</footer></div>
</article>)}
{!groupedInbox.length && <Empty description={unreadOnly ? '当前筛选范围内没有未读消息' : '当前筛选范围内没有消息'} />}
</section>
) : (
<section className="notification-feed sent-feed">
{(sent.data?.items ?? []).map((item) => <article key={item.id} className="notification-entry notification-entry--sent"><div className="notification-entry__time"><time>{displayTime(item.createdAt)}</time><i /></div><div className="notification-entry__body"><header><Tag color={categories[item.category]?.color}>{categories[item.category]?.label ?? '一般通知'}</Tag><span>{item.recipientCount} 个接收账号</span></header><h2>{item.title}</h2><p>{plainText(item.content)}</p><footer><span>发送至 {item.audienceName}</span></footer></div></article>)}
{!sent.data?.items.length && <Empty description="当前还没有已发送消息" />}
</section>
)}
{((tab === 'inbox' && (inboxCursors.length || inbox.data?.hasMore)) || (tab === 'sent' && (sentCursors.length || sent.data?.hasMore))) && <nav className="cursor-pager" aria-label="消息分页">
<Button icon={<LeftOutlined />} disabled={tab === 'inbox' ? !inboxCursors.length : !sentCursors.length} onClick={() => tab === 'inbox' ? setInboxCursors((value) => value.slice(0, -1)) : setSentCursors((value) => value.slice(0, -1))}>上一批</Button>
<span>第 {(tab === 'inbox' ? inboxCursors.length : sentCursors.length) + 1} 批</span>
<Button type="primary" disabled={tab === 'inbox' ? !inbox.data?.hasMore : !sent.data?.hasMore} onClick={() => tab === 'inbox' ? setInboxCursors((value) => inbox.data?.nextCursor ? [...value, inbox.data.nextCursor] : value) : setSentCursors((value) => sent.data?.nextCursor ? [...value, sent.data.nextCursor] : value)}>下一批 <RightOutlined /></Button>
</nav>}
<Modal open={!!selected} title={selected?.title} footer={<Button type="primary" onClick={() => setSelected(null)}>关闭</Button>} onCancel={() => setSelected(null)}>
{selected && <div className="notification-detail"><div><Tag color={categories[selected.category]?.color}>{categories[selected.category]?.label ?? '系统消息'}</Tag><span>{displayTime(selected.createdAt)}</span></div><p>{plainText(selected.content)}</p><footer>来自 {selected.senderName}{selected.audienceName ? ` · ${selected.audienceName}` : ''}</footer></div>}
</Modal>
</main>
)
}
Loaded 3 of 6 files, more files were not shown because too many files have changed in this diff. Show more