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})}
} + } + } +
+
消息正文

填写发送内容

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