From e926dd00f2bdc87a83963cd4761cdc4021fa73fb Mon Sep 17 00:00:00 2001 From: biss Date: Thu, 1 Oct 2026 13:50:31 +0800 Subject: [PATCH] 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. --- web-react/MIGRATION_MATRIX.md | 3 +- web-react/README.md | 4 +- web-react/src/app/App.tsx | 2 + web-react/src/layout/AppShell.tsx | 4 + web-react/src/pages/MyTimetablePage.tsx | 69 +++++++++++++ web-react/src/pages/NotificationsPage.tsx | 7 +- .../pages/dashboard/StudentDashboardPage.tsx | 4 +- web-react/src/pages/my-timetable.css | 57 +++++++++++ web-react/src/pages/notifications.css | 52 ++++++++++ .../notifications/NotificationComposer.tsx | 97 +++++++++++++++++++ 10 files changed, 292 insertions(+), 7 deletions(-) create mode 100644 web-react/src/pages/MyTimetablePage.tsx create mode 100644 web-react/src/pages/my-timetable.css create mode 100644 web-react/src/pages/notifications/NotificationComposer.tsx diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md index f003164..0511a11 100644 --- a/web-react/MIGRATION_MATRIX.md +++ b/web-react/MIGRATION_MATRIX.md @@ -8,6 +8,7 @@ | `/dashboard` | 角色化首页 | 已登录用户 | 管理总览;学生课表/消息/考试/预警;教师教学班/成绩登记进度 | verification | | `/account` | 个人账户 | 已登录用户 | TOTP、恢复码、SSO 绑定;Passkey 浏览器仪式尚未开放 | verification | | `/users` | 用户与权限 | SuperAdmin | 列表、筛选、分页、创建、启停、重置密码、角色范围、Excel | verification | -| `/notifications` | 通知中心 | 已登录用户 | 收件箱筛选、游标分页、已读回执;已发送;定向群发暂回旧系统 | building | +| `/notifications` | 通知中心 | 已登录用户 | 收件箱筛选、游标分页、已读回执、权限范围/筛选/指定收件人群发、已发送 | verification | +| `/my-timetable` | 我的课表 | Student、Teacher | 当前学期周视图、考试/实验融合、Excel/PDF/ICS、订阅启停与密钥轮换 | verification | 后续从 `web/src/router/index.ts` 逐条补全。只有关键合同和各状态验收完整后,状态才能更新为 `complete`。 diff --git a/web-react/README.md b/web-react/README.md index 450f714..e5cb3f1 100644 --- a/web-react/README.md +++ b/web-react/README.md @@ -29,6 +29,8 @@ npm.cmd run build - 读取真实 `/api/dashboard` 数据的首个页面; - 完整迁移 `/account` 的身份信息、TOTP 和统一身份绑定流程; - 完整迁移 `/users` 的分页筛选、Excel、账号、角色、状态和密码管理流程。 +- 迁移 `/notifications` 的收件箱、已读回执、权限范围/筛选/指定收件人群发和已发送记录; +- 迁移 `/my-timetable` 的周视图、考试与实验融合、Excel/PDF/ICS 导出和日历订阅管理。 ## 迁移纪律 @@ -43,7 +45,7 @@ npm.cmd run build | 批次 | 内容 | 状态 | | --- | --- | --- | | 0 | 工程、主题、会话、登录、应用外壳 | 进行中 | -| 1 | Dashboard、Users、Account、移动端代表页 | 前三项进入联调验收 | +| 1 | Dashboard、Users、Account、Notifications、个人课表、移动端代表页 | 已实现,进入联调验收 | | 2 | 标准 CRUD 与基础数据模块 | 未开始 | | 3 | 学生、教师与审批工作台 | 未开始 | | 4 | 选课、排课、成绩、实验等复杂模块 | 未开始 | diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx index 7c98567..bfe1fc7 100644 --- a/web-react/src/app/App.tsx +++ b/web-react/src/app/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx index 7f1d4d6..a9cb165 100644 --- a/web-react/src/layout/AppShell.tsx +++ b/web-react/src/layout/AppShell.tsx @@ -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: , label: isStudentHome ? '学习首页' : isTeacherHome ? '教学工作台' : '教务总览' }, { key: '/account', icon: , label: '个人账户' }, { key: '/notifications', icon: , label: '通知中心' }, + ...((roles.includes('Student') || roles.includes('Teacher')) + ? [{ key: '/my-timetable', icon: , label: '我的课表' }] + : []), ...(user?.roles.includes('SuperAdmin') ? [{ key: '/users', icon: , label: '用户与权限' }] : []), diff --git a/web-react/src/pages/MyTimetablePage.tsx b/web-react/src/pages/MyTimetablePage.tsx new file mode 100644 index 0000000..3851b27 --- /dev/null +++ b/web-react/src/pages/MyTimetablePage.tsx @@ -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('/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('/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(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
+ if (timetable.isError || !data) return
} onClick={() => void timetable.refetch()}>重新加载} />
+ + return
+ TERM RANGE{md(dateOnly(data.term.startDate)!)}—{md(dateOnly(data.term.endDate)!)}{totalWeeks} 个教学周} /> +
({ value: item.id, label: `${item.termName} · ${item.taskNumber} · ${item.courseName}(${item.recipientCount ?? 0} 人)` }))} /> : <> +
{(['Scope', 'Filtered', 'Selected'] as RecipientMode[]).map((value) => )}
+ {mode === 'Scope' ? : <> +
{ setRole(value); resetRecipients() }} options={data.roles} /> { setFilterTaskId(value); resetRecipients() }} options={visibleTasks.map((item) => ({ value: item.id, label: `${item.taskNumber} · ${item.courseName}` }))} />} allowClear value={keywordInput} placeholder="姓名、学号、工号或账号" onChange={(event) => setKeywordInput(event.target.value)} onPressEnter={() => { setKeyword(keywordInput.trim()); resetRecipients() }} />
+
{recipients.isLoading ? : recipients.isError ? : (recipients.data?.items ?? []).map((item) => )}
+ {(cursors.length > 0 || recipients.data?.hasMore) && } + {mode === 'Selected' && selected.length > 0 &&
已选 {selected.length} 人
{selected.map((item) => toggle(item)}>{item.displayName})}
} + } + } +
+
消息正文

填写发送内容

发送后不可撤回,请核对范围与正文。
+ + +}