feat(web): migrate messaging and personal timetable

Complete scoped notification composition and recipient selection, and add the React personal timetable with week navigation, integrated exam and experiment entries, exports, and calendar subscription lifecycle management.
This commit is contained in:
biss committed 2026-10-01 13:50:31 +08:00
1 parent 68f74aec86
commit 0a64424035
10 files changed
+292 -7

No files matched your search

+2
View File
@@ -10,6 +10,7 @@ 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 MyTimetablePage = lazy(() => import('../pages/MyTimetablePage'))
const SsoCallbackPage = lazy(() => import('../pages/SsoCallbackPage'))
const SsoBindPage = lazy(() => import('../pages/SsoBindPage'))
const AppShell = lazy(() => import('../layout/AppShell'))
@@ -54,6 +55,7 @@ function AppRoutes() {
<Route path="/account" element={<AccountPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/my-timetable" element={<MyTimetablePage />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
+4
View File
@@ -7,6 +7,7 @@ import {
MenuFoldOutlined,
MenuUnfoldOutlined,
SafetyCertificateOutlined,
CalendarOutlined,
} from '@ant-design/icons'
import { Avatar, Badge, Button, Layout, Menu, Tag } from 'antd'
import { useQuery } from '@tanstack/react-query'
@@ -40,6 +41,9 @@ export default function AppShell() {
{ key: '/dashboard', icon: <AppstoreOutlined />, label: isStudentHome ? '学习首页' : isTeacherHome ? '教学工作台' : '教务总览' },
{ key: '/account', icon: <IdcardOutlined />, label: '个人账户' },
{ key: '/notifications', icon: <BellOutlined />, label: '通知中心' },
...((roles.includes('Student') || roles.includes('Teacher'))
? [{ key: '/my-timetable', icon: <CalendarOutlined />, label: '我的课表' }]
: []),
...(user?.roles.includes('SuperAdmin')
? [{ key: '/users', icon: <SafetyCertificateOutlined />, label: '用户与权限' }]
: []),
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useMemo, useState } from 'react'
import { CalendarOutlined, CopyOutlined, DownloadOutlined, LeftOutlined, ReloadOutlined, RightOutlined, SyncOutlined } from '@ant-design/icons'
import { Alert, App, Button, Empty, Modal, Select, Skeleton, Tag } from 'antd'
import { useMutation, useQuery } from '@tanstack/react-query'
import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile } from '../api/files'
import PageHeader from '../components/PageHeader'
import './my-timetable.css'
interface Term { id: string; name: string; startDate: string; endDate: string; isCurrent: boolean }
interface Slot { periodNumber: number; name: string; startsAt: string; endsAt: string }
interface Entry { id: string; courseCode: string; courseName: string; teacherNames: string[]; classNames: string[]; classroomName: string; buildingName?: string; campusName?: string; dayOfWeek: number; startPeriod: number; periodCount: number; startWeek: number; endWeek: number; weekPattern: 'All' | 'Odd' | 'Even'; isExam?: boolean; examDate?: string; isExperiment?: boolean; experimentDate?: string; experimentProjectName?: string }
interface FlexibleCourse { id: string; courseCode: string; courseName: string; credits: number; totalHours: number; weeklyHours: number; startWeek: number; endWeek: number; teacherNames: string[] }
interface Timetable { term: Term; subject: { name: string }; student?: { studentNumber: string; name: string }; slots: Slot[]; entries: Entry[]; examEntries: Entry[]; experimentEntries: Entry[]; flexibleCourses: FlexibleCourse[] }
interface Subscription { enabled: boolean; feedUrl?: string; feedPath?: string; webcalUrl?: string; createdAt?: string }
const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
function dateOnly(value?: string) { if (!value) return null; const date = new Date(`${value.slice(0, 10)}T00:00:00`); return Number.isNaN(date.getTime()) ? null : date }
function addDays(value: Date, days: number) { const result = new Date(value); result.setDate(result.getDate() + days); return result }
function md(value: Date) { return `${value.getMonth() + 1}月${value.getDate()}日` }
function occurs(entry: Entry, week: number) { return week >= entry.startWeek && week <= entry.endWeek && (entry.weekPattern === 'All' || (entry.weekPattern === 'Odd' && week % 2 === 1) || (entry.weekPattern === 'Even' && week % 2 === 0)) }
export default function MyTimetablePage() {
const { message, modal } = App.useApp()
const [week, setWeek] = useState(1)
const [calendarOpen, setCalendarOpen] = useState(false)
const timetable = useQuery({ queryKey: ['my-timetable'], queryFn: async () => (await http.get<Timetable>('/timetables/mine')).data })
const data = timetable.data
const firstMonday = useMemo(() => { const start = dateOnly(data?.term.startDate); if (!start) return null; return addDays(start, -(start.getDay() === 0 ? 6 : start.getDay() - 1)) }, [data?.term.startDate])
const totalWeeks = useMemo(() => { const end = dateOnly(data?.term.endDate); return firstMonday && end ? Math.max(1, Math.ceil((end.getTime() - firstMonday.getTime() + 86400000) / 604800000)) : 1 }, [data?.term.endDate, firstMonday])
const currentWeek = useMemo(() => { if (!firstMonday) return null; const today = new Date(); today.setHours(0, 0, 0, 0); const value = Math.floor((today.getTime() - firstMonday.getTime()) / 604800000) + 1; return value >= 1 && value <= totalWeeks ? value : null }, [firstMonday, totalWeeks])
useEffect(() => { if (currentWeek) setWeek(currentWeek) }, [currentWeek])
const allEntries = useMemo(() => {
if (!data || !firstMonday) return []
const dated = [...(data.examEntries ?? []), ...(data.experimentEntries ?? [])].map((entry) => {
const date = dateOnly(entry.examDate ?? entry.experimentDate)
const entryWeek = date ? Math.floor((date.getTime() - firstMonday.getTime()) / 604800000) + 1 : 0
return { ...entry, startWeek: entryWeek, endWeek: entryWeek, weekPattern: 'All' as const }
})
return [...data.entries, ...dated].filter((entry) => occurs(entry, week)).sort((a, b) => a.dayOfWeek - b.dayOfWeek || a.startPeriod - b.startPeriod)
}, [data, firstMonday, week])
const byDay = weekdays.map((_, index) => allEntries.filter((entry) => entry.dayOfWeek === index + 1))
const slotMap = useMemo(() => new Map(data?.slots.map((slot) => [slot.periodNumber, slot]) ?? []), [data?.slots])
const weekLabel = firstMonday ? `第 ${week} 周 · ${md(addDays(firstMonday, (week - 1) * 7))}—${md(addDays(firstMonday, week * 7 - 1))}` : `第 ${week} 周`
const subscription = useQuery({ queryKey: ['calendar-subscription'], queryFn: async () => (await http.get<Subscription>('/timetables/mine/calendar-subscription')).data, enabled: calendarOpen })
const updateSubscription = useMutation({
mutationFn: async (action: 'enable' | 'rotate' | 'disable') => action === 'disable' ? http.delete('/timetables/mine/calendar-subscription') : http.post<Subscription>(action === 'rotate' ? '/timetables/mine/calendar-subscription/rotate' : '/timetables/mine/calendar-subscription'),
onSuccess: () => { message.success('日历订阅已更新'); void subscription.refetch() },
onError: (error) => message.error(apiErrorMessage(error)),
})
async function exportFile(kind: 'xlsx' | 'pdf' | 'ics') { try { await downloadApiFile(kind === 'ics' ? '/timetables/mine/calendar.ics' : `/timetables/mine/export.${kind}`, kind === 'xlsx' ? '我的课表.xlsx' : kind === 'pdf' ? '我的课表.pdf' : '个人教学日历.ics') } catch (error) { message.error(apiErrorMessage(error)) } }
async function copyFeed() { if (!subscription.data?.feedUrl) return; await navigator.clipboard.writeText(subscription.data.feedUrl); message.success('订阅地址已复制') }
if (timetable.isLoading) return <main className="page-container"><Skeleton active paragraph={{ rows: 14 }} /></main>
if (timetable.isError || !data) return <main className="page-container"><Alert type="error" showIcon message="我的课表暂时无法加载" description={apiErrorMessage(timetable.error)} action={<Button icon={<ReloadOutlined />} onClick={() => void timetable.refetch()}>重新加载</Button>} /></main>
return <main className="page-container timetable-page">
<PageHeader kicker="个人教学日历" title="我的课表" description={`${data.term.name} · ${data.student ? `${data.student.name}(${data.student.studentNumber})` : data.subject.name}`} aside={<div className="term-ledger"><span>TERM RANGE</span><strong>{md(dateOnly(data.term.startDate)!)}—{md(dateOnly(data.term.endDate)!)}</strong><small>{totalWeeks} 个教学周</small></div>} />
<section className="timetable-toolbar"><Button icon={<LeftOutlined />} disabled={week <= 1} onClick={() => setWeek((value) => value - 1)}>上一周</Button><Select value={week} onChange={setWeek} options={Array.from({ length: totalWeeks }, (_, index) => ({ value: index + 1, label: `第 ${index + 1} 周${index + 1 === currentWeek ? ' · 本周' : ''}` }))} /><Button icon={<RightOutlined />} disabled={week >= totalWeeks} onClick={() => setWeek((value) => value + 1)}>下一周</Button>{currentWeek && week !== currentWeek && <Button onClick={() => setWeek(currentWeek)}>回到本周</Button>}<div className="timetable-actions"><Button icon={<DownloadOutlined />} onClick={() => void exportFile('xlsx')}>Excel</Button><Button icon={<DownloadOutlined />} onClick={() => void exportFile('pdf')}>PDF</Button><Button icon={<CalendarOutlined />} onClick={() => setCalendarOpen(true)}>日历订阅</Button></div></section>
<section className="week-heading"><div><span>WEEK VIEW</span><h2>{weekLabel}</h2></div><strong>{allEntries.length}</strong><small>项课程、考试与实验安排</small></section>
<section className="week-board">{weekdays.map((label, index) => { const date = firstMonday ? addDays(firstMonday, (week - 1) * 7 + index) : null; return <article key={label} className={date?.toDateString() === new Date().toDateString() ? 'is-today' : ''}><header><span>{label}</span><small>{date ? md(date) : ''}</small></header><div>{byDay[index].map((entry) => { const endPeriod = entry.startPeriod + entry.periodCount - 1; const start = slotMap.get(entry.startPeriod)?.startsAt.slice(0, 5); const end = slotMap.get(endPeriod)?.endsAt.slice(0, 5); return <section key={`${entry.isExam ? 'exam' : entry.isExperiment ? 'experiment' : 'course'}-${entry.id}`} className={entry.isExam ? 'is-exam' : entry.isExperiment ? 'is-experiment' : ''}><div><Tag>{entry.isExam ? '考试' : entry.isExperiment ? '实验' : `${entry.startPeriod}—${endPeriod} 节`}</Tag><time>{start && end ? `${start}—${end}` : `第 ${entry.startPeriod}—${endPeriod} 节`}</time></div><strong>{entry.isExperiment ? entry.experimentProjectName || entry.courseName : entry.courseName}</strong><span>{entry.courseCode}</span><p>{[entry.buildingName, entry.classroomName].filter(Boolean).join(' · ') || '地点待定'}</p><small>{entry.teacherNames?.join('、') || entry.classNames?.join('、')}</small></section>})}{!byDay[index].length && <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="无安排" />}</div></article> })}</section>
{!!data.flexibleCourses?.length && <section className="flexible-courses"><header><span>FLEXIBLE</span><h2>非固定时间课程</h2></header><div>{data.flexibleCourses.map((course) => <article key={course.id}><span>{course.courseCode}</span><strong>{course.courseName}</strong><p>{course.startWeek}—{course.endWeek} 周 · 每周 {course.weeklyHours} 学时 · 共 {course.totalHours} 学时</p><small>{course.teacherNames.join('、')} · {course.credits} 学分</small></article>)}</div></section>}
<Modal open={calendarOpen} title="个人日历订阅" width={620} footer={null} onCancel={() => setCalendarOpen(false)}>
{subscription.isLoading ? <Skeleton active /> : subscription.isError ? <Alert type="error" message="订阅状态无法加载" description={apiErrorMessage(subscription.error)} /> : subscription.data?.enabled ? <div className="calendar-subscription-panel"><Alert type="success" showIcon message="日历订阅已启用" description="复制地址到系统日历、Outlook 或其他支持 iCalendar 的应用。" /><div className="feed-address"><code>{subscription.data.feedUrl}</code><Button icon={<CopyOutlined />} onClick={() => void copyFeed()}>复制</Button></div><div className="calendar-actions"><Button icon={<DownloadOutlined />} onClick={() => void exportFile('ics')}>下载当前日历</Button><Button icon={<SyncOutlined />} loading={updateSubscription.isPending} onClick={() => modal.confirm({ title: '轮换订阅地址?', content: '旧地址会立即失效,已订阅设备需要更新。', onOk: () => updateSubscription.mutateAsync('rotate') })}>轮换地址</Button><Button danger loading={updateSubscription.isPending} onClick={() => modal.confirm({ title: '停用日历订阅?', content: '所有使用当前地址的设备将停止更新。', okButtonProps: { danger: true }, onOk: () => updateSubscription.mutateAsync('disable') })}>停用订阅</Button></div></div> : <div className="calendar-subscription-panel"><Alert type="info" showIcon message="尚未启用日历订阅" description="启用后会生成仅属于你的私密订阅地址,请勿公开分享。" /><Button type="primary" icon={<CalendarOutlined />} loading={updateSubscription.isPending} onClick={() => updateSubscription.mutate('enable')}>启用订阅</Button><Button icon={<DownloadOutlined />} onClick={() => void exportFile('ics')}>仅下载一次</Button></div>}
</Modal>
</main>
}
Loaded 3 of 10 files, more files were not shown because too many files have changed in this diff. Show more