diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md index 5dfa2e6..11bbed1 100644 --- a/web-react/MIGRATION_MATRIX.md +++ b/web-react/MIGRATION_MATRIX.md @@ -5,7 +5,7 @@ | 路由 | 页面 | 角色/公开范围 | 关键合同 | 状态 | | --- | --- | --- | --- | --- | | `/login` | 登录 | 公开 | 验证码、密码、TOTP、SSO 设置、会话落盘 | building | -| `/dashboard` | 教务总览 | 已登录用户 | `GET /api/dashboard`、角色视图、待办和学期 | building | +| `/dashboard` | 角色化首页 | 已登录用户 | 管理总览;学生课表/消息/考试/预警;教师教学班/成绩登记进度 | verification | | `/account` | 个人账户 | 已登录用户 | TOTP、恢复码、SSO 绑定;Passkey 浏览器仪式尚未开放 | verification | | `/users` | 用户与权限 | SuperAdmin | 列表、筛选、分页、创建、启停、重置密码、角色范围、Excel | verification | diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx index d23b68c..3023d8e 100644 --- a/web-react/src/layout/AppShell.tsx +++ b/web-react/src/layout/AppShell.tsx @@ -23,17 +23,21 @@ export default function AppShell() { const logout = useAuthStore((state) => state.logout) const refreshUser = useAuthStore((state) => state.refreshUser) const [collapsed, setCollapsed] = useState(false) + 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(/\/$/, '') useEffect(() => { void refreshUser().catch(() => undefined) }, [refreshUser]) const items = useMemo(() => [ - { key: '/dashboard', icon: , label: '教务总览' }, + { key: '/dashboard', icon: , label: isStudentHome ? '学习首页' : isTeacherHome ? '教学工作台' : '教务总览' }, { key: '/account', icon: , label: '个人账户' }, ...(user?.roles.includes('SuperAdmin') ? [{ key: '/users', icon: , label: '用户与权限' }] : []), { key: 'legacy', icon: , label: '返回现有系统' }, - ], [user?.roles]) + ], [isStudentHome, isTeacherHome, user?.roles]) const pageTitle = items.find((item) => location.pathname.startsWith(item.key))?.label ?? '迁移预览' @@ -83,7 +87,7 @@ export default function AppShell() {
当前工作区{pageTitle}
- } /> + + const data = query.data + const week = weekOf(data.timetable?.term) + const weekday = new Date().getDay() || 7 + const courses = (data.timetable?.entries ?? []).filter((item) => item.dayOfWeek === weekday && !!week && week >= item.startWeek && week <= item.endWeek && (item.weekPattern === 'All' || (item.weekPattern === 'Odd' && week % 2 === 1) || (item.weekPattern === 'Even' && week % 2 === 0))).sort((a, b) => a.startPeriod - b.startPeriod) + const warnings = data.warnings.filter((item) => item.status === 1) + const exams = data.exams.filter((item) => (dateOnly(item.examDate)?.getTime() ?? 0) >= new Date().setHours(0, 0, 0, 0)).sort((a, b) => (dateOnly(a.examDate)?.getTime() ?? 0) - (dateOnly(b.examDate)?.getTime() ?? 0)).slice(0, 3) + const slot = (period: number, field: 'startsAt' | 'endsAt') => data.timetable?.slots.find((item) => item.periodNumber === period)?.[field]?.slice(0, 5) ?? '' + + return ( +
+
+
我的学习安排

{data.greeting?.title ?? `你好,${name}`}

{data.greeting?.subtitle ?? new Intl.DateTimeFormat('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' }).format(new Date())}

{data.greeting?.narrative}
+
今日课程{courses.length}{week ? `第 ${week} 教学周` : '当前不在教学周内'}
+
+ {data.failed.length > 0 && } +
+
学业风险雷达{warnings.length ? `有 ${warnings.length} 项学习风险需要关注` : '当前没有需要处理的学业风险'}{warnings[0]?.detail ?? '继续保持当前学习节奏。'}
+
+
+
+
TODAY

当日课表

+ {courses.length ?
{courses.map((course) => )}
: } title="今天没有课程" detail={week ? '可以安排自习或处理待办。' : '当前不在教学周内。'} />} +
+
+
INBOX

消息中心

+ {data.notices.length ?
{data.notices.slice(0, 4).map((item) => )}
: } title="暂无消息" detail="新的教务通知会显示在这里。" />} +
+
+
UPCOMING

近期考试

+ {exams.length ?
{exams.map((item) => )}
: } title="近期没有考试" detail="已发布的考试安排会显示在这里。" />} +
+
+
+ ) +} + +function Empty({ icon, title, detail }: { icon: React.ReactNode; title: string; detail: string }) { + return
{icon}{title}{detail}
+} diff --git a/web-react/src/pages/dashboard/TeacherDashboardPage.tsx b/web-react/src/pages/dashboard/TeacherDashboardPage.tsx new file mode 100644 index 0000000..67e6339 --- /dev/null +++ b/web-react/src/pages/dashboard/TeacherDashboardPage.tsx @@ -0,0 +1,41 @@ +import { CalendarOutlined, CheckCircleOutlined, ReloadOutlined, RightOutlined } from '@ant-design/icons' +import { Alert, Button, Progress, Skeleton } from 'antd' +import { useQuery } from '@tanstack/react-query' +import http from '../../api/http' +import { openLegacy } from './legacy' + +interface Greeting { title: string; subtitle: string; narrative: string; label: string; insights: Array<{ label: string; value: string; hint: string; tone: string }> } +interface Sheet { id: string; courseName: string; taskName: string; classNames: string[]; sheet?: { status: string; studentCount: number; completedCount: number } } +interface TeacherOverview { greeting: Greeting; sheets: Sheet[] } +const statusLabel: Record = { Draft: '待登记', Returned: '已退回', Submitted: '审核中', Approved: '已审核', Published: '已发布' } + +async function loadTeacherOverview(): Promise { + const dashboard = await http.get<{ currentTerm?: { id: string }; greeting: Greeting }>('/dashboard') + const sheets = await http.get<{ items: Sheet[] }>('/grades/sheets', { params: { academicTermId: dashboard.data.currentTerm?.id, pageSize: 50 } }) + return { greeting: dashboard.data.greeting, sheets: sheets.data.items } +} + +export default function TeacherDashboardPage() { + const query = useQuery({ queryKey: ['dashboard', 'teacher'], queryFn: loadTeacherOverview }) + if (query.isLoading) return
+ if (query.isError || !query.data) return
} onClick={() => void query.refetch()}>重试} />
+ const { greeting, sheets } = query.data + const pending = sheets.filter((item) => ['Draft', 'Returned'].includes(item.sheet?.status ?? '')) + const progress = (sheet?: Sheet['sheet']) => sheet?.studentCount ? Math.round(sheet.completedCount / sheet.studentCount * 100) : 0 + return ( +
+
教学工作台

{greeting.title}

{greeting.subtitle}

{greeting.narrative}
+ {!!greeting.insights.length &&
{greeting.insights.map((item) =>
{item.label}{item.value}{item.hint}
)}
} +
+
+
GRADE PROGRESS

成绩登记进度

+ {pending.length ?
{pending.slice(0, 5).map((item) => )}
:
当前没有待提交成绩成绩登记册会在这里按优先级显示。
} +
+
+
TEACHING CLASSES

本学期教学班

+ {sheets.length ?
{sheets.slice(0, 5).map((item) => )}
:
本学期暂未分配教学班教学任务发布后会自动汇总到这里。
} +
+
+
+ ) +} diff --git a/web-react/src/pages/dashboard/legacy.ts b/web-react/src/pages/dashboard/legacy.ts new file mode 100644 index 0000000..6320c75 --- /dev/null +++ b/web-react/src/pages/dashboard/legacy.ts @@ -0,0 +1,5 @@ +export function openLegacy(path: string) { + const configured = import.meta.env.VITE_LEGACY_APP_URL ?? 'http://localhost:5255' + const base = configured.endsWith('/') ? configured.slice(0, -1) : configured + window.location.assign(`${base}${path.startsWith('/') ? path : `/${path}`}`) +} diff --git a/web-react/src/pages/dashboard/role-dashboard.css b/web-react/src/pages/dashboard/role-dashboard.css new file mode 100644 index 0000000..5fc6926 --- /dev/null +++ b/web-react/src/pages/dashboard/role-dashboard.css @@ -0,0 +1,67 @@ +.role-dashboard { gap: 18px; } +.role-hero { min-height: 190px; padding: 30px 34px; display: flex; align-items: center; justify-content: space-between; gap: 28px; color: #fff; overflow: hidden; position: relative; background: linear-gradient(122deg, #162858, #284583 62%, #087f73 128%); } +.role-hero::after { content: ""; width: 260px; height: 260px; border: 1px solid rgba(84,207,189,.24); border-radius: 50%; position: absolute; right: -90px; top: -118px; box-shadow: 0 0 0 42px rgba(84,207,189,.05); } +.role-hero > div { position: relative; z-index: 1; } +.role-hero span, .role-panel header span { color: var(--teal-bright); font: 700 10px/1 var(--font-data); letter-spacing: .13em; } +.role-hero h1 { margin: 13px 0 8px; font: 600 clamp(27px, 3vw, 39px)/1.22 var(--font-display); letter-spacing: .03em; } +.role-hero p { margin: 0; color: #d2dcf2; font-size: 14px; } +.role-hero small { display: block; max-width: 700px; margin-top: 14px; color: #aebee0; font-size: 12px; line-height: 1.7; } +.role-hero__status { min-width: 150px; padding: 22px; border: 1px solid rgba(255,255,255,.2); background: rgba(10,27,62,.28); backdrop-filter: blur(8px); } +.role-hero__status span, .role-hero__status small { display: block; margin: 0; } +.role-hero__status strong { display: block; margin: 9px 0 7px; font: 700 42px/1 var(--font-data); } +.risk-strip { min-height: 84px; padding: 16px 20px; display: grid; grid-template-columns: auto 1fr auto; gap: 15px; align-items: center; border-left: 3px solid var(--teal); background: var(--teal-soft); } +.risk-strip > .anticon { color: var(--teal); font-size: 22px; } +.risk-strip div { display: grid; gap: 3px; } +.risk-strip span, .risk-strip small { color: #657488; font-size: 10px; } +.risk-strip strong { color: #24433f; font-size: 13px; } +.risk-strip--attention { border-color: var(--amber); background: var(--amber-soft); } +.risk-strip--attention > .anticon { color: var(--amber); } +.role-dashboard__grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(300px, .75fr); gap: 18px; align-items: start; } +.role-dashboard__grid--equal { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.role-panel { min-height: 290px; border: 1px solid var(--line); background: var(--paper); box-shadow: 0 10px 25px rgba(22,40,88,.045); } +.role-panel--wide { grid-row: span 2; } +.role-panel header { min-height: 74px; padding: 18px 21px; display: flex; justify-content: space-between; gap: 12px; align-items: center; border-bottom: 1px solid var(--line); } +.role-panel header h2 { margin: 7px 0 0; color: var(--brand-deep); font: 600 18px var(--font-display); } +.day-rail, .compact-list, .teacher-list { padding: 2px 21px 12px; } +.day-rail button, .compact-list button, .teacher-list button { width: 100%; padding: 16px 0; display: flex; gap: 14px; align-items: center; border: 0; border-bottom: 1px solid #edf0f3; text-align: left; background: transparent; cursor: pointer; } +.day-rail time { width: 70px; flex: 0 0 auto; } +.day-rail time b, .day-rail time small { display: block; } +.day-rail time b { color: var(--brand-deep); font: 700 14px var(--font-data); } +.day-rail time small { margin-top: 5px; color: var(--ink-muted); font: 10px var(--font-data); } +.day-rail i { width: 9px; height: 9px; flex: 0 0 auto; border: 2px solid var(--teal); border-radius: 50%; box-shadow: 0 0 0 5px var(--teal-soft); } +.day-rail button > div, .compact-list button > div, .teacher-list button > div { min-width: 0; flex: 1; } +.day-rail span, .compact-list span, .compact-list small, .teacher-list span, .teacher-list small { display: block; color: #768297; font-size: 10px; } +.day-rail strong, .compact-list strong, .teacher-list strong { display: block; margin: 4px 0; overflow: hidden; color: #27374d; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } +.day-rail small { color: #69778b; font-size: 10px; } +.compact-list button { position: relative; } +.compact-list button.is-unread::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--teal); position: absolute; left: -12px; top: 22px; } +.compact-list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.exam-list button { align-items: flex-start; } +.exam-list time { min-width: 58px; color: var(--teal); font: 700 13px var(--font-data); } +.role-empty { min-height: 210px; display: grid; place-content: center; justify-items: center; gap: 9px; padding: 24px; color: #929cab; text-align: center; } +.role-empty > .anticon { font-size: 26px; } +.role-empty strong { color: #5a687a; font-size: 13px; } +.role-empty span { font-size: 11px; } +.role-insights { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } +.role-insights article { padding: 17px 18px; display: grid; gap: 5px; border-left: 3px solid #a9bcc6; background: #f7faf9; } +.role-insights article.is-positive { border-color: var(--teal); } +.role-insights article.is-attention { border-color: var(--amber); } +.role-insights span, .role-insights small { color: #657488; font-size: 10px; } +.role-insights strong { color: var(--brand-deep); font: 700 22px var(--font-data); } +.teacher-list button { justify-content: space-between; } +.teacher-list__progress { flex: 0 0 auto !important; } + +@media (max-width: 900px) { + .role-dashboard__grid, .role-dashboard__grid--equal { grid-template-columns: 1fr; } + .role-panel--wide { grid-row: auto; } +} +@media (max-width: 640px) { + .role-hero { min-height: 0; padding: 24px 20px; align-items: stretch; flex-direction: column; } + .role-hero__status { width: 100%; min-width: 0; padding: 15px 17px; display: grid; grid-template-columns: 1fr auto; align-items: center; } + .role-hero__status strong { grid-row: 1 / 3; grid-column: 2; margin: 0; font-size: 34px; } + .role-insights { grid-template-columns: 1fr; } + .risk-strip { grid-template-columns: auto 1fr; } + .risk-strip .ant-btn { grid-column: 2; justify-self: start; padding: 0; } + .role-panel header { padding: 16px; } + .day-rail, .compact-list, .teacher-list { padding-inline: 16px; } +}