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:
1 parent
44f09cec1d
commit
68f74aec86
6 files changed
+232
-5
No files matched your search
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in new issue
Block a user