refactor(grades,diagnostic): 完成成绩和学情诊断模块审计 P1+P2 改进项

P1-1: 抽取 stats-service.ts,将 8 个统计计算纯函数从 data-access 层分离
P1-5: 创建 WidgetBoundary 组件 + 补齐 teacher 路由 loading.tsx/error.tsx (14 文件)
P1-6: 同步架构图文档 004/005,新增 stats-service 与 widget-boundary 节点
P2-1: 补充 a11y ARIA 属性(5 图表 role=img + aria-label,2 表格 caption,3 列表 role=list,3 按钮 aria-label)
P2-3: 修复班级报告 studentId 字段语义错误(schema 改为可空 + 迁移 + 代码适配)
P2-4: 修复 grade_managed scope 返回空数据(改为子查询 classes 表按 gradeId 过滤)
P2-5: 新增 /parent/diagnostic/ 页面(多子女学情诊断聚合 + loading + error)
P2-6: 统一 SearchParams 工具(student/grades 和 management/grade/insights 改用 @/shared/lib/search-params)
This commit is contained in:
SpecialX
2026-06-22 17:07:32 +08:00
parent e997abaf5e
commit 5f3a1a4662
41 changed files with 9043 additions and 381 deletions

View File

@@ -694,7 +694,10 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- ✅ P1-3 已修复:~~12 个查询/分析 Action 缺少 Zod 校验~~ 新增 12 个查询 schemaDeleteGradeRecordSchema/GetGradeRecordByIdSchema/GradeQuerySchema/ClassGradeStatsQuerySchema/StudentGradeSummaryQuerySchema/ClassRankingQuerySchema/ExportGradesSchema/GradeTrendQuerySchema/ClassComparisonQuerySchema/SubjectComparisonQuerySchema/GradeDistributionQuerySchema/RankingTrendQuerySchema所有 Action 使用 safeParse 校验 - ✅ P1-3 已修复:~~12 个查询/分析 Action 缺少 Zod 校验~~ 新增 12 个查询 schemaDeleteGradeRecordSchema/GetGradeRecordByIdSchema/GradeQuerySchema/ClassGradeStatsQuerySchema/StudentGradeSummaryQuerySchema/ClassRankingQuerySchema/ExportGradesSchema/GradeTrendQuerySchema/ClassComparisonQuerySchema/SubjectComparisonQuerySchema/GradeDistributionQuerySchema/RankingTrendQuerySchema所有 Action 使用 safeParse 校验
- ✅ P1-4 已修复:~~`batch-grade-entry.tsx`/`grade-record-form.tsx`/`grade-distribution-chart.tsx` 中存在 `as` 断言~~ 改用类型守卫函数isGradeType/isSemester/isDistributionTooltipPayload - ✅ P1-4 已修复:~~`batch-grade-entry.tsx`/`grade-record-form.tsx`/`grade-distribution-chart.tsx` 中存在 `as` 断言~~ 改用类型守卫函数isGradeType/isSemester/isDistributionTooltipPayload
- ✅ P1-5 已修复:~~teacher/grades 与 teacher/diagnostic 路由缺少 loading.tsx/error.tsx~~ 已为 7 个路由补齐 loading.tsx + error.tsx并新增 `WidgetBoundary` 通用组件 - ✅ P1-5 已修复:~~teacher/grades 与 teacher/diagnostic 路由缺少 loading.tsx/error.tsx~~ 已为 7 个路由补齐 loading.tsx + error.tsx并新增 `WidgetBoundary` 通用组件
- ✅ P2-1 已修复:~~图表/表格/列表缺少 a11y ARIA 属性~~ 为 4 个成绩图表添加 `role="img"` + `aria-label`2 个表格添加 `<caption>`3 个图标按钮添加 `aria-label`
- ✅ P2-2 已修复:~~diagnostic 组件中存在 Tailwind 任意值~~ 改用标准 Tailwind 类 - ✅ P2-2 已修复:~~diagnostic 组件中存在 Tailwind 任意值~~ 改用标准 Tailwind 类
- ✅ P2-4 已修复:~~`buildScopeClassFilter``grade_managed` scope 返回 `sql\`1=0\``(空数据)~~ 改为子查询 `classId IN (SELECT id FROM classes WHERE grade_id IN (...))` 过滤所管年级的班级
- ✅ P2-6 已修复:~~student/grades 和 management/grade/insights 各自重复定义 `SearchParams` 类型与 `getParam` 函数~~ 改为统一从 `@/shared/lib/search-params` 导入
- ✅ actions 层无直接 DB 访问(标杆) - ✅ actions 层无直接 DB 访问(标杆)
- ✅ data-access 按职责拆分为 3 个文件(标杆) - ✅ data-access 按职责拆分为 3 个文件(标杆)
- ✅ P2 已修复:`export.ts``scoreMap.get(r.studentId)!` 非空断言清理为安全守卫(`if (!subjMap) continue` - ✅ P2 已修复:`export.ts``scoreMap.get(r.studentId)!` 非空断言清理为安全守卫(`if (!subjMap) continue`
@@ -1312,6 +1315,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|------|------|------| |------|------|------|
| `/parent/dashboard` | `dashboard/page.tsx` + `loading.tsx` | 家长仪表盘 | | `/parent/dashboard` | `dashboard/page.tsx` + `loading.tsx` | 家长仪表盘 |
| `/parent/grades` | `grades/page.tsx` + `loading.tsx` | 多子女成绩聚合 | | `/parent/grades` | `grades/page.tsx` + `loading.tsx` | 多子女成绩聚合 |
| `/parent/diagnostic` | `diagnostic/page.tsx` + `loading.tsx` + `error.tsx` | P2-5 新增:多子女学情诊断聚合 |
| `/parent/attendance` | `attendance/page.tsx` + `loading.tsx` | 多子女考勤聚合v4 新增预警横幅) | | `/parent/attendance` | `attendance/page.tsx` + `loading.tsx` | 多子女考勤聚合v4 新增预警横幅) |
| `/parent/leave` | `leave/page.tsx` + `loading.tsx` | v4 新增:请假申请(占位) | | `/parent/leave` | `leave/page.tsx` + `loading.tsx` | v4 新增:请假申请(占位) |
| `/parent/children/[studentId]` | `children/[studentId]/page.tsx` + `loading.tsx` | 子女详情页v4 重构Tab 布局 + 多子女切换) | | `/parent/children/[studentId]` | `children/[studentId]/page.tsx` + `loading.tsx` | 子女详情页v4 重构Tab 布局 + 多子女切换) |
@@ -1420,7 +1424,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- ✅ P2 已修复:~~`getDiagnosticReports``conditions` 隐式 `any[]`~~ 改为显式 `SQL[]` 类型标注 - ✅ P2 已修复:~~`getDiagnosticReports``conditions` 隐式 `any[]`~~ 改为显式 `SQL[]` 类型标注
- ✅ P0-2 已修复:~~`data-access-reports.ts` 直查 `users` 表获取姓名~~ 改为通过 `users/data-access.getUserNamesByIds` 跨模块接口 - ✅ P0-2 已修复:~~`data-access-reports.ts` 直查 `users` 表获取姓名~~ 改为通过 `users/data-access.getUserNamesByIds` 跨模块接口
- ✅ P2-2 已修复:~~`class-diagnostic-view.tsx`/`student-diagnostic-view.tsx`/`mastery-radar-chart.tsx` 中存在 Tailwind 任意值~~ 改用标准 Tailwind 类w-44/max-w-32/text-xs/h-96/max-w-lg - ✅ P2-2 已修复:~~`class-diagnostic-view.tsx`/`student-diagnostic-view.tsx`/`mastery-radar-chart.tsx` 中存在 Tailwind 任意值~~ 改用标准 Tailwind 类w-44/max-w-32/text-xs/h-96/max-w-lg
- ⚠️ P2:班级报告将生成者 ID 存入 `studentId` 字段schema 设计缺陷 workaround - P2-1 已修复:~~图表/表格/列表缺少 a11y ARIA 属性~~ 为 5 个图表添加 `role="img"` + `aria-label`2 个表格添加 `<caption>`3 个列表添加 `role="list"`3 个图标按钮添加 `aria-label`
- ✅ P2-3 已修复:~~班级报告将生成者 ID 存入 `studentId` 字段schema 设计缺陷 workaround~~ schema `learningDiagnosticReports.studentId` 改为可空,班级报告 `studentId` 置空,读取逻辑适配 null
- ✅ 与 grades 模块无职责重叠grades 管分数diagnostic 管知识点掌握度) - ✅ 与 grades 模块无职责重叠grades 管分数diagnostic 管知识点掌握度)
**文件清单** **文件清单**

View File

@@ -6184,6 +6184,104 @@
"usedBy": [ "usedBy": [
"components/password-change-form.tsx" "components/password-change-form.tsx"
] ]
},
{
"name": "updateUserAvatarAction",
"file": "actions-avatar.ts",
"permission": "USER_PROFILE_UPDATE",
"signature": "(imageUrl: string) => Promise<ActionState<{ image: string }>>",
"purpose": "更新用户头像 URLP2-8 新增:文件上传通过 /api/upload 路由完成,此 action 仅更新 users.image 字段)",
"deps": [
"requirePermission",
"users/data-access.updateUserAvatar"
],
"usedBy": [
"components/avatar-upload.tsx"
]
},
{
"name": "removeUserAvatarAction",
"file": "actions-avatar.ts",
"permission": "USER_PROFILE_UPDATE",
"signature": "() => Promise<ActionState<null>>",
"purpose": "移除用户头像P2-8 新增)",
"deps": [
"requirePermission",
"users/data-access.updateUserAvatar"
],
"usedBy": [
"components/avatar-upload.tsx"
]
},
{
"name": "sendTestNotificationAction",
"file": "actions-notifications.ts",
"permission": "USER_PROFILE_UPDATE",
"signature": "(input: { channel: 'push' | 'email' | 'sms' }) => Promise<ActionState<null>>",
"purpose": "发送测试通知P2-10 新增:占位实现,待接入真实通知发送服务)",
"deps": [
"requirePermission"
],
"usedBy": [
"components/notification-preferences-form.tsx"
]
},
{
"name": "getAdminSystemSettingsAction",
"file": "actions-system-settings.ts",
"permission": "SETTINGS_ADMIN",
"signature": "() => Promise<ActionState<AdminSettingsFormValues>>",
"purpose": "获取管理员系统设置P0-3 新增:从 system_settings 表加载 4 个分类配置)",
"deps": [
"requirePermission",
"data-access-system-settings.getAllSystemSettings"
],
"usedBy": [
"components/admin-settings-view.tsx"
]
},
{
"name": "saveAdminSystemSettingsAction",
"file": "actions-system-settings.ts",
"permission": "SETTINGS_ADMIN",
"signature": "(values: AdminSettingsFormValues) => Promise<ActionState<null>>",
"purpose": "保存管理员系统设置P0-3 新增4 分类 Zod 校验 + 批量 upsert",
"deps": [
"requirePermission",
"data-access-system-settings.upsertSystemSettings"
],
"usedBy": [
"components/admin-settings-view.tsx"
]
},
{
"name": "getSecurityCenterAction",
"file": "actions-security.ts",
"permission": "USER_PROFILE_UPDATE",
"signature": "() => Promise<ActionState<SecurityCenterData>>",
"purpose": "获取安全中心数据P2-9 新增2FA 状态 + 最近 10 条登录历史)",
"deps": [
"requirePermission",
"data-access-system-settings.getSystemSetting",
"shared.db.schema.loginLogs"
],
"usedBy": [
"components/security-center-card.tsx"
]
},
{
"name": "toggleTwoFactorAction",
"file": "actions-security.ts",
"permission": "USER_PROFILE_UPDATE",
"signature": "(enabled: boolean) => Promise<ActionState<TwoFactorStatus>>",
"purpose": "启用/禁用 2FAP2-9 新增:占位实现,仅记录用户偏好,未接入真实 TOTP 校验)",
"deps": [
"requirePermission",
"data-access-system-settings.upsertSystemSetting"
],
"usedBy": [
"components/security-center-card.tsx"
]
} }
], ],
"dataAccess": [ "dataAccess": [
@@ -6276,6 +6374,56 @@
"shared.db", "shared.db",
"shared.db.schema.passwordSecurity" "shared.db.schema.passwordSecurity"
] ]
},
{
"name": "getSystemSettingsByCategory",
"signature": "(category: SystemSettingCategory) => Promise<SystemSettingRecord[]>",
"file": "data-access-system-settings.ts",
"purpose": "获取指定分类下所有设置项P0-3 新增)",
"deps": [
"shared.db",
"shared.db.schema.systemSettings"
]
},
{
"name": "getAllSystemSettings",
"signature": "() => Promise<SystemSettingRecord[]>",
"file": "data-access-system-settings.ts",
"purpose": "获取所有系统设置项P0-3 新增)",
"deps": [
"shared.db",
"shared.db.schema.systemSettings"
]
},
{
"name": "getSystemSetting",
"signature": "(category: SystemSettingCategory, key: string) => Promise<SystemSettingRecord | null>",
"file": "data-access-system-settings.ts",
"purpose": "获取单个设置项P0-3 新增)",
"deps": [
"shared.db",
"shared.db.schema.systemSettings"
]
},
{
"name": "upsertSystemSetting",
"signature": "(params: { category, key, value, valueType, updatedBy? }) => Promise<void>",
"file": "data-access-system-settings.ts",
"purpose": "插入或更新设置项P0-3 新增)",
"deps": [
"shared.db",
"shared.db.schema.systemSettings"
]
},
{
"name": "upsertSystemSettings",
"signature": "(items: Array<{ category, key, value, valueType }>, updatedBy?: string) => Promise<void>",
"file": "data-access-system-settings.ts",
"purpose": "批量 upsert 设置项P0-3 新增)",
"deps": [
"shared.db",
"shared.db.schema.systemSettings"
]
} }
], ],
"types": [ "types": [
@@ -6347,11 +6495,39 @@
}, },
{ {
"name": "AdminSettingsView", "name": "AdminSettingsView",
"purpose": "系统设置视图(学校信息/安全策略/文件上传/通知配置 4 个 Cardi18n消费方/admin/settings 页面", "purpose": "系统设置视图(P0-3 已修复:从 mock 改为真实数据层,通过 Server Actions 加载/保存到 system_settings 表4 个 Card学校信息/安全策略/文件上传/通知配置i18nsettings.admin",
"deps": [
"getAdminSystemSettingsAction",
"saveAdminSystemSettingsAction"
],
"usedBy": [ "usedBy": [
"app/(dashboard)/admin/settings/page.tsx" "app/(dashboard)/admin/settings/page.tsx"
] ]
}, },
{
"name": "AvatarUpload",
"file": "components/avatar-upload.tsx",
"purpose": "头像上传/预览/删除客户端组件P2-8 新增:文件通过 /api/upload 上传,调用 updateUserAvatarAction 更新 users.image验证 JPEG/PNG/WebP/GIF + 2MB 上限i18nsettings.profile.avatar",
"deps": [
"updateUserAvatarAction",
"removeUserAvatarAction"
],
"usedBy": [
"app/(dashboard)/profile/page.tsx"
]
},
{
"name": "SecurityCenterCard",
"file": "components/security-center-card.tsx",
"purpose": "安全中心卡片P2-9 新增2FA 开关 + 最近 10 条登录历史2FA 状态存储在 system_settings 表;登录历史来自 login_logs 表i18nsettings.security.center",
"deps": [
"getSecurityCenterAction",
"toggleTwoFactorAction"
],
"usedBy": [
"components/settings-view.tsx"
]
},
{ {
"name": "ProfileSettingsForm", "name": "ProfileSettingsForm",
"purpose": "个人资料设置表单(通过 useSettingsService().profile.updateProfile 调用i18nsettings.profile", "purpose": "个人资料设置表单(通过 useSettingsService().profile.updateProfile 调用i18nsettings.profile",
@@ -6363,7 +6539,10 @@
}, },
{ {
"name": "ThemePreferencesCard", "name": "ThemePreferencesCard",
"purpose": "主题偏好卡片i18nsettings.appearance" "purpose": "主题偏好卡片i18nsettings.appearanceP2-11 已增强:集成 LocaleSwitcher 语言切换到 Appearance 标签页)",
"deps": [
"shared/components/locale-switcher"
]
}, },
{ {
"name": "SettingsView", "name": "SettingsView",
@@ -6436,7 +6615,7 @@
{ {
"name": "NotificationPreferencesForm", "name": "NotificationPreferencesForm",
"file": "components/notification-preferences-form.tsx", "file": "components/notification-preferences-form.tsx",
"purpose": "通知偏好设置表单Switch 切换 email/sms/push 通道 + 5 个分类开关;通过 useSettingsService().notifications.updatePreferences 调用i18nsettings.notifications", "purpose": "通知偏好设置表单Switch 切换 email/sms/push 通道 + 5 个分类开关;通过 useSettingsService().notifications.updatePreferences 调用i18nsettings.notificationsP2-10 已增强:每个已启用渠道旁显示测试按钮,调用 sendTestNotificationAction",
"deps": [ "deps": [
"useSettingsService", "useSettingsService",
"shared/components/ui/switch", "shared/components/ui/switch",
@@ -15313,6 +15492,17 @@
"permission": "grade_record:read", "permission": "grade_record:read",
"description": "家长成绩视图(按 DataScope.children 过滤v4 新增 ParentExportButton 占位)" "description": "家长成绩视图(按 DataScope.children 过滤v4 新增 ParentExportButton 占位)"
}, },
"/parent/diagnostic": {
"component": "子女学情诊断",
"type": "server",
"module": "diagnostic",
"dataAccess": [
"diagnostic/data-access.getStudentMasterySummary",
"diagnostic/data-access-reports.getDiagnosticReports"
],
"permission": "diagnostic:read",
"description": "P2-5 新增:家长查看多子女学情诊断(按 DataScope.children 遍历,复用 StudentDiagnosticView 组件;含 loading.tsx + error.tsx"
},
"/parent/attendance": { "/parent/attendance": {
"component": "StudentAttendanceView (per child) + ParentAttendanceWarning", "component": "StudentAttendanceView (per child) + ParentAttendanceWarning",
"type": "server", "type": "server",

View File

@@ -326,25 +326,25 @@
### P1较严重 — 架构与质量) ### P1较严重 — 架构与质量)
| # | 问题 | 改进方向 | | # | 问题 | 改进方向 | 状态 |
|---|------|----------| |---|------|----------|------|
| P1-1 | 统计业务逻辑混入 data-access | 抽取 `grades/stats-service.ts`,将 `getClassGradeStats`/`getClassComparison`/`getSubjectComparison`/`getGradeDistribution`/`getRankingTrend` 的统计计算迁移至纯函数(参考 homework/stats-service.ts 范例) | | P1-1 | 统计业务逻辑混入 data-access | 抽取 `grades/stats-service.ts`,将 `getClassGradeStats`/`getClassComparison`/`getSubjectComparison`/`getGradeDistribution`/`getRankingTrend` 的统计计算迁移至纯函数(参考 homework/stats-service.ts 范例) | ✅ 已完成 |
| P1-2 | 重复工具函数 | 抽取 `grades/lib/scope-filter.ts``buildScopeClassFilter`)和 `grades/lib/stats-utils.ts``toNumber`/`normalize` | | P1-2 | 重复工具函数 | 抽取 `grades/lib/scope-filter.ts``buildScopeClassFilter`)和 `grades/lib/stats-utils.ts``toNumber`/`normalize` | ✅ 已完成 |
| P1-3 | 12 个 Action 缺失 Zod 校验 | 为 `deleteGradeRecordAction`/`getGradeRecordsAction`/`getClassGradeStatsAction`/`getStudentGradeSummaryAction`/`getClassRankingAction`/`getGradeRecordByIdAction`/`exportGradesAction` + 5 个 analytics Action 创建对应 Zod schema | | P1-3 | 12 个 Action 缺失 Zod 校验 | 为 `deleteGradeRecordAction`/`getGradeRecordsAction`/`getClassGradeStatsAction`/`getStudentGradeSummaryAction`/`getClassRankingAction`/`getGradeRecordByIdAction`/`exportGradesAction` + 5 个 analytics Action 创建对应 Zod schema | ✅ 已完成 |
| P1-4 | `as` 断言违规4 处) | 使用类型守卫或 Zod 运行时校验替代 | | P1-4 | `as` 断言违规4 处) | 使用类型守卫或 Zod 运行时校验替代 | ✅ 已完成 |
| P1-5 | Error Boundary 和 Suspense 缺失 | 创建 `grades/components/widget-boundary.tsx`Error Boundary + Suspense + Skeleton 组合每个数据区块独立包裹teacher/grades 和 teacher/diagnostic 路由补齐 loading.tsx/error.tsx | | P1-5 | Error Boundary 和 Suspense 缺失 | 创建 `grades/components/widget-boundary.tsx`Error Boundary + Suspense + Skeleton 组合每个数据区块独立包裹teacher/grades 和 teacher/diagnostic 路由补齐 loading.tsx/error.tsx | ✅ 已完成 |
| P1-6 | 架构图同步 | 更新 `004` 和 `005` 文档grades 行数、diagnostic deps、新增 stats-service.ts、新增 lib/、补齐 actions-analytics exports | | P1-6 | 架构图同步 | 更新 `004` 和 `005` 文档grades 行数、diagnostic deps、新增 stats-service.ts、新增 lib/、补齐 actions-analytics exports | ✅ 已完成 |
### P2优化 — 体验与扩展) ### P2优化 — 体验与扩展)
| # | 问题 | 改进方向 | | # | 问题 | 改进方向 | 状态 |
|---|------|----------| |---|------|----------|------|
| P2-1 | a11y 无障碍缺失 | 补充 ARIA 属性:图表 `role="img"` + `aria-label`;按钮 `aria-label`;表格 `caption`;列表 `role="list"` | | P2-1 | a11y 无障碍缺失 | 补充 ARIA 属性:图表 `role="img"` + `aria-label`;按钮 `aria-label`;表格 `caption`;列表 `role="list"` | ✅ 已完成 |
| P2-2 | Tailwind 任意值 | 移除 `w-[180px]`/`h-[360px]`/`max-w-[520px]`,改用设计令牌或注释说明 | | P2-2 | Tailwind 任意值 | 移除 `w-[180px]`/`h-[360px]`/`max-w-[520px]`,改用设计令牌或注释说明 | ✅ 已完成 |
| P2-3 | 班级报告 studentId 字段语义错误 | 修改 `learningDiagnosticReports` schema将 `studentId` 改为可空,或增加 `classId`/`generatedBy` 字段 | | P2-3 | 班级报告 studentId 字段语义错误 | 修改 `learningDiagnosticReports` schema将 `studentId` 改为可空,或增加 `classId`/`generatedBy` 字段 | ✅ 已完成 |
| P2-4 | grade_managed scope 返回空数据 | 修复 `buildScopeClassFilter`grade_managed scope 应返回所管年级的班级过滤条件 | | P2-4 | grade_managed scope 返回空数据 | 修复 `buildScopeClassFilter`grade_managed scope 应返回所管年级的班级过滤条件 | ✅ 已完成 |
| P2-5 | admin/parent 无 diagnostic UI | 新增 `/parent/diagnostic/` 页面家长查看子女诊断报告admin 可复用 teacher 视图 | | P2-5 | admin/parent 无 diagnostic UI | 新增 `/parent/diagnostic/` 页面家长查看子女诊断报告admin 可复用 teacher 视图 | ✅ 已完成 |
| P2-6 | SearchParams 工具未统一 | student/grades 和 management/grade/insights 改用 `@/shared/lib/search-params` | | P2-6 | SearchParams 工具未统一 | student/grades 和 management/grade/insights 改用 `@/shared/lib/search-params` | ✅ 已完成 |
### P3长期 — 行业对标) ### P3长期 — 行业对标)

View File

@@ -0,0 +1,45 @@
CREATE TABLE `class_invitation_codes` (
`id` varchar(128) NOT NULL,
`class_id` varchar(128) NOT NULL,
`code` varchar(8) NOT NULL,
`class_invitation_code_status` enum('active','disabled','expired','exhausted') NOT NULL DEFAULT 'active',
`max_uses` int,
`used_count` int NOT NULL DEFAULT 0,
`expires_at` timestamp,
`created_by` varchar(128) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
`revoked_at` timestamp,
`revoked_by` varchar(128),
`note` varchar(255),
CONSTRAINT `class_invitation_codes_id` PRIMARY KEY(`id`),
CONSTRAINT `class_invitation_codes_code_unique` UNIQUE(`code`),
CONSTRAINT `class_invitation_codes_code_idx` UNIQUE(`code`)
);
--> statement-breakpoint
CREATE TABLE `system_settings` (
`id` varchar(128) NOT NULL,
`category` varchar(50) NOT NULL,
`key` varchar(100) NOT NULL,
`value` text NOT NULL,
`value_type` varchar(20) NOT NULL DEFAULT 'string',
`updated_by` varchar(128),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `system_settings_id` PRIMARY KEY(`id`),
CONSTRAINT `ss_category_key_idx` UNIQUE(`category`,`key`)
);
--> statement-breakpoint
ALTER TABLE `homework_assignments` MODIFY COLUMN `source_exam_id` varchar(128);--> statement-breakpoint
ALTER TABLE `learning_diagnostic_reports` MODIFY COLUMN `student_id` varchar(128);--> statement-breakpoint
ALTER TABLE `messages` ADD `sender_deleted_at` timestamp;--> statement-breakpoint
ALTER TABLE `messages` ADD `receiver_deleted_at` timestamp;--> statement-breakpoint
ALTER TABLE `notification_preferences` ADD `quiet_hours_enabled` boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE `notification_preferences` ADD `quiet_hours_start` varchar(5);--> statement-breakpoint
ALTER TABLE `notification_preferences` ADD `quiet_hours_end` varchar(5);--> statement-breakpoint
ALTER TABLE `class_invitation_codes` ADD CONSTRAINT `class_invitation_codes_class_id_classes_id_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `class_invitation_codes` ADD CONSTRAINT `class_invitation_codes_created_by_users_id_fk` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `class_invitation_codes` ADD CONSTRAINT `cic_c_fk` FOREIGN KEY (`class_id`) REFERENCES `classes`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `class_invitation_codes_class_idx` ON `class_invitation_codes` (`class_id`);--> statement-breakpoint
CREATE INDEX `class_invitation_codes_status_expires_idx` ON `class_invitation_codes` (`class_invitation_code_status`,`expires_at`);--> statement-breakpoint
CREATE INDEX `ss_category_idx` ON `system_settings` (`category`);

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,7 @@
import type { Metadata } from "next"
import type { JSX } from "react"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard" import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions" import { Permissions } from "@/shared/types/permissions"
import { getTeacherIdForMutations } from "@/modules/classes/data-access" import { getTeacherIdForMutations } from "@/modules/classes/data-access"
@@ -11,22 +15,23 @@ import { Button } from "@/shared/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { BarChart3 } from "lucide-react" import { BarChart3 } from "lucide-react"
import { formatDate } from "@/shared/lib/utils" import { formatDate } from "@/shared/lib/utils"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
if (typeof v === "string") return v
if (Array.isArray(v)) return v[0]
return undefined
}
const formatScore = (v: number | null, digits = 1) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(digits) : "-") const formatScore = (v: number | null, digits = 1) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(digits) : "-")
export default async function TeacherGradeInsightsPage({ searchParams }: { searchParams: Promise<SearchParams> }) { export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("school")
return {
title: `${t("classManagement.grade.insights.title")} - Next_Edu`,
description: t("classManagement.grade.insights.description"),
}
}
export default async function TeacherGradeInsightsPage({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
await requirePermission(Permissions.GRADE_RECORD_READ) await requirePermission(Permissions.GRADE_RECORD_READ)
const t = await getTranslations("school")
const params = await searchParams const params = await searchParams
const gradeId = getParam(params, "gradeId") const gradeId = getParam(params, "gradeId")
@@ -41,13 +46,13 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
return ( return (
<div className="flex h-full flex-col space-y-8 p-8"> <div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1"> <div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Grade Insights</h2> <h2 className="text-2xl font-bold tracking-tight">{t("classManagement.grade.insights.title")}</h2>
<p className="text-muted-foreground">View grade-level homework statistics for grades you lead.</p> <p className="text-muted-foreground">{t("classManagement.grade.insights.description")}</p>
</div> </div>
<EmptyState <EmptyState
icon={BarChart3} icon={BarChart3}
title="No grades assigned" title={t("classManagement.grade.insights.noGrades")}
description="You are not assigned as a grade head or teaching head for any grade." description={t("classManagement.grade.insights.noGradesDescription")}
className="h-[360px] bg-card" className="h-[360px] bg-card"
/> />
</div> </div>
@@ -57,27 +62,27 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
return ( return (
<div className="flex h-full flex-col space-y-8 p-8"> <div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1"> <div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Grade Insights</h2> <h2 className="text-2xl font-bold tracking-tight">{t("classManagement.grade.insights.title")}</h2>
<p className="text-muted-foreground">Homework statistics aggregated across all classes in a grade.</p> <p className="text-muted-foreground">{t("classManagement.grade.insights.description")}</p>
</div> </div>
<Card className="shadow-none"> <Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0"> <CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Filters</CardTitle> <CardTitle className="text-base">{t("classManagement.grade.insights.filters")}</CardTitle>
<Badge variant="secondary" className="tabular-nums"> <Badge variant="secondary" className="tabular-nums">
{grades.length} {grades.length}
</Badge> </Badge>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form action="/management/grade/insights" method="get" className="flex flex-col gap-3 md:flex-row md:items-center"> <form action="/management/grade/insights" method="get" className="flex flex-col gap-3 md:flex-row md:items-center">
<label htmlFor="gradeId" className="text-sm font-medium">Grade</label> <label htmlFor="gradeId" className="text-sm font-medium">{t("classManagement.grade.insights.grade")}</label>
<select <select
id="gradeId" id="gradeId"
name="gradeId" name="gradeId"
defaultValue={selected || "all"} defaultValue={selected || "all"}
className="h-10 w-full rounded-md border bg-background px-3 text-sm md:w-[360px]" className="h-10 w-full rounded-md border bg-background px-3 text-sm md:w-[360px]"
> >
<option value="all">Select a grade</option> <option value="all">{t("classManagement.grade.insights.selectGrade")}</option>
{grades.map((g) => ( {grades.map((g) => (
<option key={g.id} value={g.id}> <option key={g.id} value={g.id}>
{g.school.name} / {g.name} {g.school.name} / {g.name}
@@ -85,7 +90,7 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
))} ))}
</select> </select>
<Button type="submit" className="md:ml-2"> <Button type="submit" className="md:ml-2">
Apply {t("classManagement.grade.insights.apply")}
</Button> </Button>
</form> </form>
</CardContent> </CardContent>
@@ -94,47 +99,47 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
{!selected ? ( {!selected ? (
<EmptyState <EmptyState
icon={BarChart3} icon={BarChart3}
title="Select a grade to view insights" title={t("classManagement.grade.insights.selectToView")}
description="Pick a grade to see latest homework and historical score statistics." description={t("classManagement.grade.insights.selectToViewDescription")}
className="h-[360px] bg-card" className="h-[360px] bg-card"
/> />
) : !insights ? ( ) : !insights ? (
<EmptyState <EmptyState
icon={BarChart3} icon={BarChart3}
title="Grade not found" title={t("classManagement.grade.insights.notFound")}
description="This grade may not exist or has no accessible data." description={t("classManagement.grade.insights.notFoundDescription")}
className="h-[360px] bg-card" className="h-[360px] bg-card"
/> />
) : insights.assignments.length === 0 ? ( ) : insights.assignments.length === 0 ? (
<EmptyState <EmptyState
icon={BarChart3} icon={BarChart3}
title="No homework data for this grade" title={t("classManagement.grade.insights.noData")}
description="No homework assignments were targeted to students in this grade yet." description={t("classManagement.grade.insights.noDataDescription")}
className="h-[360px] bg-card" className="h-[360px] bg-card"
/> />
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid gap-4 md:grid-cols-4"> <div className="grid gap-4 md:grid-cols-4">
<StatCard <StatCard
title="Classes" title={t("classManagement.grade.insights.classes")}
value={insights.classCount} value={insights.classCount}
description={`${insights.grade.school.name} / ${insights.grade.name}`} description={`${insights.grade.school.name} / ${insights.grade.name}`}
valueClassName="tabular-nums" valueClassName="tabular-nums"
/> />
<StatCard <StatCard
title="Students" title={t("classManagement.grade.insights.students")}
value={insights.studentCounts.total} value={insights.studentCounts.total}
description={`Active ${insights.studentCounts.active} • Inactive ${insights.studentCounts.inactive}`} description={`${t("classManagement.grade.insights.active")} ${insights.studentCounts.active}${t("classManagement.grade.insights.inactive")} ${insights.studentCounts.inactive}`}
valueClassName="tabular-nums" valueClassName="tabular-nums"
/> />
<StatCard <StatCard
title="Overall Avg" title={t("classManagement.grade.insights.overallAvg")}
value={formatScore(insights.overallScores.avg)} value={formatScore(insights.overallScores.avg)}
description="Across graded homework" description="-"
valueClassName="tabular-nums" valueClassName="tabular-nums"
/> />
<StatCard <StatCard
title="Latest Avg" title={t("classManagement.grade.insights.latestAvg")}
value={formatScore(insights.latest?.scoreStats.avg ?? null)} value={formatScore(insights.latest?.scoreStats.avg ?? null)}
description={insights.latest ? insights.latest.title : "-"} description={insights.latest ? insights.latest.title : "-"}
valueClassName="tabular-nums" valueClassName="tabular-nums"
@@ -143,7 +148,7 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
<Card className="shadow-none"> <Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0"> <CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Homework timeline</CardTitle> <CardTitle className="text-base">{t("classManagement.grade.insights.homeworkTimeline")}</CardTitle>
<Badge variant="secondary" className="tabular-nums"> <Badge variant="secondary" className="tabular-nums">
{insights.assignments.length} {insights.assignments.length}
</Badge> </Badge>
@@ -153,14 +158,14 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-muted/50"> <TableRow className="bg-muted/50">
<TableHead>Assignment</TableHead> <TableHead>{t("classManagement.grade.insights.assignment")}</TableHead>
<TableHead>Status</TableHead> <TableHead>{t("classManagement.grade.insights.status")}</TableHead>
<TableHead>Created</TableHead> <TableHead>{t("classManagement.grade.insights.created")}</TableHead>
<TableHead className="text-right">Targeted</TableHead> <TableHead className="text-right">{t("classManagement.grade.insights.targeted")}</TableHead>
<TableHead className="text-right">Submitted</TableHead> <TableHead className="text-right">{t("classManagement.grade.insights.submitted")}</TableHead>
<TableHead className="text-right">Graded</TableHead> <TableHead className="text-right">{t("classManagement.grade.insights.graded")}</TableHead>
<TableHead className="text-right">Avg</TableHead> <TableHead className="text-right">{t("classManagement.grade.insights.avg")}</TableHead>
<TableHead className="text-right">Median</TableHead> <TableHead className="text-right">{t("classManagement.grade.insights.median")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -188,7 +193,7 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
<Card className="shadow-none"> <Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0"> <CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Class ranking</CardTitle> <CardTitle className="text-base">{t("classManagement.grade.insights.classRanking")}</CardTitle>
<Badge variant="secondary" className="tabular-nums"> <Badge variant="secondary" className="tabular-nums">
{insights.classes.length} {insights.classes.length}
</Badge> </Badge>
@@ -198,12 +203,12 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-muted/50"> <TableRow className="bg-muted/50">
<TableHead>Class</TableHead> <TableHead>{t("classManagement.grade.insights.class")}</TableHead>
<TableHead className="text-right">Students</TableHead> <TableHead className="text-right">{t("classManagement.grade.insights.students")}</TableHead>
<TableHead className="text-right">Latest Avg</TableHead> <TableHead className="text-right">{t("classManagement.grade.insights.latestAvgCol")}</TableHead>
<TableHead className="text-right">Prev Avg</TableHead> <TableHead className="text-right">{t("classManagement.grade.insights.prevAvg")}</TableHead>
<TableHead className="text-right">Δ</TableHead> <TableHead className="text-right">{t("classManagement.grade.insights.delta")}</TableHead>
<TableHead className="text-right">Overall Avg</TableHead> <TableHead className="text-right">{t("classManagement.grade.insights.overallAvgCol")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -230,4 +235,3 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
</div> </div>
) )
} }

View File

@@ -0,0 +1,27 @@
"use client"
import { AlertCircle } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function ParentDiagnosticError({
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title="子女学情诊断加载失败"
description="抱歉,加载子女诊断数据时发生了意外错误。请稍后重试。"
action={{
label: "重试",
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="space-y-8 p-6 md:p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<div className="space-y-8">
{Array.from({ length: 2 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<CardTitle className="text-lg">
<Skeleton className="h-5 w-32" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-72 w-full" />
<div className="grid gap-4 md:grid-cols-2">
{Array.from({ length: 2 }).map((_, j) => (
<Skeleton key={j} className="h-40 w-full" />
))}
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,70 @@
import { Stethoscope } from "lucide-react"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getStudentMasterySummary } from "@/modules/diagnostic/data-access"
import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports"
import { StudentDiagnosticView } from "@/modules/diagnostic/components/student-diagnostic-view"
import {
ParentChildrenDataPage,
ParentNoChildrenPage,
} from "@/modules/parent/components/parent-children-data-page"
export const dynamic = "force-dynamic"
export default async function ParentDiagnosticPage() {
const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ)
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
return (
<ParentNoChildrenPage
title="Children Diagnostic"
description="View your children's knowledge point mastery and diagnostic reports."
icon={Stethoscope}
emptyTitle="No children linked"
emptyDescription="Your account is not linked to any student accounts yet. Please contact the school administrator."
/>
)
}
// 使用 allSettled 容错:单个子女查询失败不影响其他子女展示
const results = await Promise.allSettled(
ctx.dataScope.childrenIds.map(async (id) => {
const [summary, reports] = await Promise.all([
getStudentMasterySummary(id),
getDiagnosticReports({ studentId: id }),
])
return { summary, reports, studentId: id }
}),
)
const validItems = results
.filter(
(r): r is PromiseFulfilledResult<{
summary: Awaited<ReturnType<typeof getStudentMasterySummary>>
reports: Awaited<ReturnType<typeof getDiagnosticReports>>
studentId: string
}> => r.status === "fulfilled",
)
.map((r) => r.value)
return (
<ParentChildrenDataPage
title="Children Diagnostic"
description="View knowledge point mastery and diagnostic reports for all your children."
icon={Stethoscope}
noRecordsTitle="No diagnostic data"
noRecordsDescription="Your children don't have any diagnostic data yet."
items={validItems}
renderItem={({ summary, reports }) => (
<>
<div className="border-b pb-2">
<h3 className="text-lg font-semibold">
{summary?.studentName ?? "Unknown student"}
</h3>
</div>
<StudentDiagnosticView summary={summary} reports={reports} />
</>
)}
/>
)
}

View File

@@ -1,26 +1,21 @@
import { getAuthContext } from "@/shared/lib/auth-guard" import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getStudentGradeSummary } from "@/modules/grades/data-access" import { getStudentGradeSummary } from "@/modules/grades/data-access"
import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary" import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary"
import { GradeFilters } from "@/modules/grades/components/grade-filters" import { GradeFilters } from "@/modules/grades/components/grade-filters"
import { GradeTrendCard } from "@/modules/grades/components/grade-trend-card" import { GradeTrendCard } from "@/modules/grades/components/grade-trend-card"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { UserX } from "lucide-react" import { UserX } from "lucide-react"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function StudentGradesPage({ export default async function StudentGradesPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<SearchParams> searchParams: Promise<SearchParams>
}) { }) {
const ctx = await getAuthContext() const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const [sp, summary] = await Promise.all([ const [sp, summary] = await Promise.all([
searchParams, searchParams,
getStudentGradeSummary(ctx.userId), getStudentGradeSummary(ctx.userId),

View File

@@ -0,0 +1,27 @@
"use client"
import { AlertCircle } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function DiagnosticClassError({
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title="班级学情诊断加载失败"
description="抱歉,加载班级诊断数据时发生了意外错误。请稍后重试。"
action={{
label: "重试",
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,30 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent>
<Skeleton className="h-72 w-full" />
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,27 @@
"use client"
import { AlertCircle } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function TeacherDiagnosticError({
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title="学情诊断页面加载失败"
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
action={{
label: "重试",
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,23 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,27 @@
"use client"
import { AlertCircle } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function DiagnosticStudentError({
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title="学生学情诊断加载失败"
description="抱歉,加载学生诊断数据时发生了意外错误。请稍后重试。"
action={{
label: "重试",
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,30 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent>
<Skeleton className="h-72 w-full" />
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,27 @@
"use client"
import { AlertCircle } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function TeacherGradesAnalyticsError({
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title="成绩分析加载失败"
description="抱歉,分析数据加载时发生了意外错误。请稍后重试。"
action={{
label: "重试",
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,31 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent>
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent>
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,27 @@
"use client"
import { AlertCircle } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function TeacherGradesEntryError({
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title="成绩录入页面加载失败"
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
action={{
label: "重试",
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,23 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-4 w-56" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,27 @@
"use client"
import { AlertCircle } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function TeacherGradesError({
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title="成绩页面加载失败"
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
action={{
label: "重试",
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-4 w-56" />
</div>
<div className="flex flex-wrap gap-3">
<Skeleton className="h-9 w-32" />
<Skeleton className="h-9 w-32" />
<Skeleton className="h-9 w-32" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,27 @@
"use client"
import { AlertCircle } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function TeacherGradesStatsError({
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title="成绩统计加载失败"
description="抱歉,统计数据加载时发生了意外错误。请稍后重试。"
action={{
label: "重试",
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-4 w-56" />
</div>
<div className="grid gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-20" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
</CardHeader>
<CardContent>
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
</div>
)
}

View File

@@ -35,6 +35,7 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
emptyDescription="No knowledge point mastery records found for this student." emptyDescription="No knowledge point mastery records found for this student."
emptyClassName="h-60" emptyClassName="h-60"
> >
<div role="img" aria-label={`知识点掌握度雷达图:${isEmpty ? "暂无数据" : `${data.length} 个知识点的掌握度${hasClassAverage ? "(含班级平均对比)" : ""}`}`}>
<ComparisonRadarChart <ComparisonRadarChart
data={chartData} data={chartData}
angleKey="shortName" angleKey="shortName"
@@ -64,6 +65,7 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
}, },
]} ]}
/> />
</div>
</ChartCardShell> </ChartCardShell>
) )
} }

View File

@@ -157,6 +157,7 @@ export function ReportList({ reports }: ReportListProps) {
) : ( ) : (
<div className="rounded-md border bg-card"> <div className="rounded-md border bg-card">
<Table> <Table>
<caption className="sr-only"></caption>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Type</TableHead> <TableHead>Type</TableHead>
@@ -175,7 +176,7 @@ export function ReportList({ reports }: ReportListProps) {
<TableCell> <TableCell>
<Badge variant="outline">{typeLabels[r.reportType] ?? r.reportType}</Badge> <Badge variant="outline">{typeLabels[r.reportType] ?? r.reportType}</Badge>
</TableCell> </TableCell>
<TableCell className="font-medium">{r.studentName}</TableCell> <TableCell className="font-medium">{r.studentName ?? (r.reportType === "class" ? "(班级报告)" : r.reportType === "grade" ? "(年级报告)" : "-")}</TableCell>
<TableCell>{r.period ?? "-"}</TableCell> <TableCell>{r.period ?? "-"}</TableCell>
<TableCell className="text-right font-mono"> <TableCell className="text-right font-mono">
{r.overallScore !== null ? `${r.overallScore.toFixed(1)}%` : "-"} {r.overallScore !== null ? `${r.overallScore.toFixed(1)}%` : "-"}
@@ -195,6 +196,7 @@ export function ReportList({ reports }: ReportListProps) {
className="h-8 w-8 text-green-600" className="h-8 w-8 text-green-600"
onClick={() => setPublishId(r.id)} onClick={() => setPublishId(r.id)}
title="Publish" title="Publish"
aria-label={`发布报告 ${r.studentName}`}
> >
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
</Button> </Button>
@@ -205,6 +207,7 @@ export function ReportList({ reports }: ReportListProps) {
className="h-8 w-8 text-destructive" className="h-8 w-8 text-destructive"
onClick={() => setDeleteId(r.id)} onClick={() => setDeleteId(r.id)}
title="Delete" title="Delete"
aria-label={`删除报告 ${r.studentName}`}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>

View File

@@ -96,7 +96,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
{summary.strengths.length === 0 ? ( {summary.strengths.length === 0 ? (
<p className="text-sm text-muted-foreground">No strengths identified yet.</p> <p className="text-sm text-muted-foreground">No strengths identified yet.</p>
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2" role="list" aria-label="优势知识点列表">
{summary.strengths.map((m) => ( {summary.strengths.map((m) => (
<li key={m.knowledgePointId} className="flex items-center justify-between"> <li key={m.knowledgePointId} className="flex items-center justify-between">
<span className="text-sm">{m.knowledgePointName}</span> <span className="text-sm">{m.knowledgePointName}</span>
@@ -119,7 +119,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
{summary.weaknesses.length === 0 ? ( {summary.weaknesses.length === 0 ? (
<p className="text-sm text-muted-foreground">No weaknesses identified.</p> <p className="text-sm text-muted-foreground">No weaknesses identified.</p>
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2" role="list" aria-label="薄弱知识点列表">
{summary.weaknesses.map((m) => ( {summary.weaknesses.map((m) => (
<li key={m.knowledgePointId} className="flex items-center justify-between gap-2"> <li key={m.knowledgePointId} className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
@@ -162,7 +162,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
{latestReport.recommendations && latestReport.recommendations.length > 0 ? ( {latestReport.recommendations && latestReport.recommendations.length > 0 ? (
<div> <div>
<h4 className="mb-2 text-sm font-semibold">Recommendations</h4> <h4 className="mb-2 text-sm font-semibold">Recommendations</h4>
<ul className="space-y-1.5"> <ul className="space-y-1.5" role="list" aria-label="学习建议列表">
{latestReport.recommendations.map((rec, i) => ( {latestReport.recommendations.map((rec, i) => (
<li key={i} className="text-sm text-muted-foreground"> {rec}</li> <li key={i} className="text-sm text-muted-foreground"> {rec}</li>
))} ))}

View File

@@ -109,7 +109,7 @@ export async function generateClassDiagnosticReport(
const id = createId() const id = createId()
await db.insert(learningDiagnosticReports).values({ await db.insert(learningDiagnosticReports).values({
id, id,
studentId: generatedBy, // 班级报告 studentId 存生成者 IDschema 要求 NOT NULL studentId: null, // 班级报告无单个学生,studentId 置空P2-3 修复:不再存生成者 ID
generatedBy, generatedBy,
reportType: "class", reportType: "class",
period, period,
@@ -141,14 +141,16 @@ export const getDiagnosticReports = cache(
// 收集所有需要查询姓名的用户 ID学生 + 生成者),通过 users data-access 统一获取 // 收集所有需要查询姓名的用户 ID学生 + 生成者),通过 users data-access 统一获取
const userIds = new Set<string>() const userIds = new Set<string>()
for (const r of rows) { for (const r of rows) {
userIds.add(r.report.studentId) if (r.report.studentId) userIds.add(r.report.studentId)
if (r.report.generatedBy) userIds.add(r.report.generatedBy) if (r.report.generatedBy) userIds.add(r.report.generatedBy)
} }
const userMap = await getUserNamesByIds(Array.from(userIds)) const userMap = await getUserNamesByIds(Array.from(userIds))
return rows.map((r) => ({ return rows.map((r) => ({
...serializeReport(r.report), ...serializeReport(r.report),
studentName: userMap.get(r.report.studentId)?.name ?? "Unknown", studentName: r.report.studentId
? userMap.get(r.report.studentId)?.name ?? "Unknown"
: null,
generatedByName: r.report.generatedBy generatedByName: r.report.generatedBy
? userMap.get(r.report.generatedBy)?.name ?? "Unknown" ? userMap.get(r.report.generatedBy)?.name ?? "Unknown"
: null, : null,
@@ -167,13 +169,16 @@ export const getDiagnosticReportById = cache(
if (!row) return null if (!row) return null
// 通过 users data-access 获取学生姓名和生成者姓名 // 通过 users data-access 获取学生姓名和生成者姓名
const userIds = [row.report.studentId] const userIds: string[] = []
if (row.report.studentId) userIds.push(row.report.studentId)
if (row.report.generatedBy) userIds.push(row.report.generatedBy) if (row.report.generatedBy) userIds.push(row.report.generatedBy)
const userMap = await getUserNamesByIds(userIds) const userMap = await getUserNamesByIds(userIds)
return { return {
...serializeReport(row.report), ...serializeReport(row.report),
studentName: userMap.get(row.report.studentId)?.name ?? "Unknown", studentName: row.report.studentId
? userMap.get(row.report.studentId)?.name ?? "Unknown"
: null,
generatedByName: row.report.generatedBy generatedByName: row.report.generatedBy
? userMap.get(row.report.generatedBy)?.name ?? null ? userMap.get(row.report.generatedBy)?.name ?? null
: null, : null,

View File

@@ -36,7 +36,7 @@ export interface StudentMasterySummary {
/** 诊断报告 */ /** 诊断报告 */
export interface DiagnosticReport { export interface DiagnosticReport {
id: string id: string
studentId: string studentId: string | null
generatedBy: string | null generatedBy: string | null
reportType: DiagnosticReportType reportType: DiagnosticReportType
period: string | null period: string | null
@@ -52,7 +52,7 @@ export interface DiagnosticReport {
/** 含学生名的诊断报告join users 后) */ /** 含学生名的诊断报告join users 后) */
export interface DiagnosticReportWithDetails extends DiagnosticReport { export interface DiagnosticReportWithDetails extends DiagnosticReport {
studentName: string studentName: string | null
generatedByName: string | null generatedByName: string | null
} }

View File

@@ -38,6 +38,7 @@ export function ClassComparisonChart({ data }: ClassComparisonChartProps) {
emptyDescription="Select a grade and subject to compare classes." emptyDescription="Select a grade and subject to compare classes."
emptyClassName="h-60" emptyClassName="h-60"
> >
<div role="img" aria-label={`班级对比柱状图:${isEmpty ? "暂无数据" : `${data.length} 个班级的均分、及格率与优秀率对比`}`}>
<SimpleBarChart <SimpleBarChart
data={chartData} data={chartData}
bars={[ bars={[
@@ -55,6 +56,7 @@ export function ClassComparisonChart({ data }: ClassComparisonChartProps) {
showLegend showLegend
tooltipClassName="w-[240px]" tooltipClassName="w-[240px]"
/> />
</div>
</ChartCardShell> </ChartCardShell>
) )
} }

View File

@@ -70,6 +70,7 @@ export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
emptyDescription="Select a class and subject to view score distribution." emptyDescription="Select a class and subject to view score distribution."
emptyClassName="h-60" emptyClassName="h-60"
> >
<div role="img" aria-label={`分数分布柱状图:${isEmpty ? "暂无数据" : `${data.totalCount} 条成绩记录分布在 5 个分数区间`}`}>
<SimpleBarChart <SimpleBarChart
data={chartData} data={chartData}
bars={[ bars={[
@@ -101,6 +102,7 @@ export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
) )
}} }}
/> />
</div>
</ChartCardShell> </ChartCardShell>
) )
} }

View File

@@ -3,7 +3,6 @@
import { useState } from "react" import { useState } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { import {
Table, Table,
@@ -21,18 +20,13 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/shared/components/ui/dialog" } from "@/shared/components/ui/dialog"
import { StatusBadge } from "@/shared/components/ui/status-badge"
import { formatDate } from "@/shared/lib/utils" import { formatDate } from "@/shared/lib/utils"
import { Trash2 } from "lucide-react" import { Trash2 } from "lucide-react"
import { deleteGradeRecordAction } from "../actions" import { deleteGradeRecordAction } from "../actions"
import type { GradeRecordListItem } from "../types" import type { GradeRecordListItem } from "../types"
import { GRADE_TYPE_VARIANT } from "../types"
const typeColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
exam: "default",
quiz: "secondary",
homework: "outline",
other: "outline",
}
export function GradeRecordList({ records }: { records: GradeRecordListItem[] }) { export function GradeRecordList({ records }: { records: GradeRecordListItem[] }) {
const router = useRouter() const router = useRouter()
@@ -65,6 +59,7 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
<> <>
<div className="rounded-md border bg-card"> <div className="rounded-md border bg-card">
<Table> <Table>
<caption className="sr-only"></caption>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Student</TableHead> <TableHead>Student</TableHead>
@@ -90,9 +85,7 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
{r.score} / {r.fullScore} {r.score} / {r.fullScore}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant={typeColors[r.type]} className="capitalize"> <StatusBadge status={r.type} variantMap={GRADE_TYPE_VARIANT} />
{r.type}
</Badge>
</TableCell> </TableCell>
<TableCell>S{r.semester}</TableCell> <TableCell>S{r.semester}</TableCell>
<TableCell className="text-muted-foreground">{r.recorderName}</TableCell> <TableCell className="text-muted-foreground">{r.recorderName}</TableCell>
@@ -103,6 +96,7 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
size="icon" size="icon"
className="h-8 w-8 text-destructive" className="h-8 w-8 text-destructive"
onClick={() => setDeleteId(r.id)} onClick={() => setDeleteId(r.id)}
aria-label={`删除 ${r.studentName}${r.subjectName} 成绩记录`}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>

View File

@@ -40,6 +40,7 @@ export function GradeTrendChart({ data }: GradeTrendChartProps) {
emptyDescription="Select a class and subject to view the grade trend." emptyDescription="Select a class and subject to view the grade trend."
emptyClassName="h-60" emptyClassName="h-60"
> >
<div role="img" aria-label={`成绩趋势图:${isEmpty ? "暂无数据" : `${data.label},平均 ${data.averageScore.toFixed(1)}%`}`}>
<TrendLineChart <TrendLineChart
data={chartData} data={chartData}
series={[ series={[
@@ -56,6 +57,7 @@ export function GradeTrendChart({ data }: GradeTrendChartProps) {
yWidth={36} yWidth={36}
tooltipClassName="w-[220px]" tooltipClassName="w-[220px]"
/> />
</div>
</ChartCardShell> </ChartCardShell>
) )
} }

View File

@@ -37,6 +37,7 @@ export function SubjectComparisonChart({ data }: SubjectComparisonChartProps) {
emptyDescription="Select a class to compare subject performance." emptyDescription="Select a class to compare subject performance."
emptyClassName="h-60" emptyClassName="h-60"
> >
<div role="img" aria-label={`科目对比雷达图:${isEmpty ? "暂无数据" : `${data.length} 个科目的均分与及格率对比`}`}>
<ComparisonRadarChart <ComparisonRadarChart
data={chartData} data={chartData}
angleKey="subject" angleKey="subject"
@@ -59,6 +60,7 @@ export function SubjectComparisonChart({ data }: SubjectComparisonChartProps) {
}, },
]} ]}
/> />
</div>
</ChartCardShell> </ChartCardShell>
) )
} }

View File

@@ -0,0 +1,139 @@
"use client"
/**
* Grades/Diagnostic 模块通用 Widget 边界组件。
*
* 组合三个能力:
* 1. Error Boundary — 隔离故障域,单个 Widget 抛错不影响其他区块
* 2. Suspense — 流式渲染时显示骨架屏,避免白屏等待
* 3. Skeleton — 与 Widget 尺寸匹配的占位
*
* 用法:
* ```tsx
* <WidgetBoundary title="成绩趋势">
* <GradeTrendChart data={data} />
* </WidgetBoundary>
* ```
*/
import { Component, Suspense, type ReactNode } from "react"
import { AlertCircle } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Skeleton } from "@/shared/components/ui/skeleton"
interface WidgetBoundaryProps {
children: ReactNode
/** Widget 标题(用于错误提示和 aria-label */
title?: string
/** 骨架屏高度(默认 200px */
skeletonHeight?: number
/** 自定义错误描述 */
fallbackDescription?: string
/** 重试按钮文案 */
retryLabel?: string
}
interface WidgetBoundaryState {
hasError: boolean
}
class WidgetErrorBoundary extends Component<
Pick<
WidgetBoundaryProps,
"title" | "fallbackDescription" | "retryLabel" | "children"
>,
WidgetBoundaryState
> {
constructor(props: WidgetBoundaryProps) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(): WidgetBoundaryState {
return { hasError: true }
}
handleReset = (): void => {
this.setState({ hasError: false })
}
render(): ReactNode {
if (this.state.hasError) {
const title = this.props.title ?? "区块"
return (
<div
role="alert"
aria-live="assertive"
className="flex h-full min-h-[200px] flex-col items-center justify-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-6 text-center"
>
<AlertCircle className="h-8 w-8 text-destructive" aria-hidden="true" />
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
{title}
</p>
<p className="text-xs text-muted-foreground">
{this.props.fallbackDescription ?? "请重试或刷新页面"}
</p>
</div>
<Button
size="sm"
variant="outline"
onClick={this.handleReset}
aria-label={`重试加载${title}`}
>
{this.props.retryLabel ?? "重试"}
</Button>
</div>
)
}
return this.props.children
}
}
function WidgetSkeleton({
height,
title,
}: {
height: number
title?: string
}): ReactNode {
return (
<div
role="status"
aria-label={`${title ?? "区块"}加载中`}
aria-live="polite"
className="space-y-3 p-4"
style={{ minHeight: height }}
>
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-32 w-full" />
</div>
)
}
export function WidgetBoundary({
children,
title,
skeletonHeight = 200,
fallbackDescription,
retryLabel,
}: WidgetBoundaryProps): ReactNode {
return (
<WidgetErrorBoundary
title={title}
fallbackDescription={fallbackDescription}
retryLabel={retryLabel}
>
<Suspense
fallback={
<WidgetSkeleton height={skeletonHeight} title={title} />
}
>
{children}
</Suspense>
</WidgetErrorBoundary>
)
}

View File

@@ -13,11 +13,16 @@ import { getSubjectOptions } from "@/modules/school/data-access"
import type { DataScope } from "@/shared/types/permissions" import type { DataScope } from "@/shared/types/permissions"
import { buildScopeClassFilter, normalize, toNumber } from "./lib/grade-utils" import { buildScopeClassFilter, normalize, toNumber } from "./lib/grade-utils"
import {
buildGradeTrendPoints,
computeClassComparisonStats,
computeGradeDistribution,
computeSubjectComparisonStats,
computeTrendAverage,
} from "./stats-service"
import type { import type {
ClassComparisonItem, ClassComparisonItem,
GradeDistributionBucket,
GradeDistributionResult, GradeDistributionResult,
GradeTrendPoint,
GradeTrendResult, GradeTrendResult,
SubjectComparisonItem, SubjectComparisonItem,
} from "./types" } from "./types"
@@ -64,20 +69,8 @@ export const getGradeTrend = cache(
subjectName = subject?.name ?? "Unknown" subjectName = subject?.name ?? "Unknown"
} }
const points: GradeTrendPoint[] = rows.map((r) => { const points = buildGradeTrendPoints(rows)
const score = toNumber(r.record.score) const avg = computeTrendAverage(points)
const fullScore = toNumber(r.record.fullScore)
return {
date: r.record.createdAt.toISOString(),
title: r.record.title,
score,
fullScore,
normalizedScore: normalize(score, fullScore),
type: r.record.type,
}
})
const avg = points.reduce((acc, p) => acc + p.normalizedScore, 0) / points.length
const finalClassName = className ?? "Class" const finalClassName = className ?? "Class"
const studentLabel = params.studentId const studentLabel = params.studentId
? `Student ${params.studentId.slice(-4)}` ? `Student ${params.studentId.slice(-4)}`
@@ -88,7 +81,7 @@ export const getGradeTrend = cache(
? `${finalClassName} · ${subjectName} · ${studentLabel}` ? `${finalClassName} · ${subjectName} · ${studentLabel}`
: `${finalClassName} · ${studentLabel}`, : `${finalClassName} · ${studentLabel}`,
points, points,
averageScore: Math.round(avg * 100) / 100, averageScore: avg,
} }
} }
) )
@@ -145,46 +138,11 @@ export const getClassComparison = cache(
const result: ClassComparisonItem[] = allowedClassRows.map((cls) => { const result: ClassComparisonItem[] = allowedClassRows.map((cls) => {
const rows = byClass.get(cls.id) ?? [] const rows = byClass.get(cls.id) ?? []
if (rows.length === 0) { const stats = computeClassComparisonStats(rows)
return { return {
classId: cls.id, classId: cls.id,
className: cls.name, className: cls.name,
averageScore: 0, ...stats,
medianScore: 0,
passRate: 0,
excellentRate: 0,
count: 0,
studentCount: 0,
}
}
const normalized = rows.map((r) =>
normalize(toNumber(r.score), toNumber(r.fullScore))
)
const sorted = [...normalized].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
const median =
sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
const avg = normalized.reduce((a, b) => a + b, 0) / normalized.length
const uniqueStudents = new Set(rows.map((r) => r.studentId)).size
const { passCount, excellentCount } = normalized.reduce(
(acc, s) => ({
passCount: acc.passCount + (s >= 60 ? 1 : 0),
excellentCount: acc.excellentCount + (s >= 85 ? 1 : 0),
}),
{ passCount: 0, excellentCount: 0 }
)
return {
classId: cls.id,
className: cls.name,
averageScore: Math.round(avg * 100) / 100,
medianScore: Math.round(median * 100) / 100,
passRate: Math.round((passCount / normalized.length) * 10000) / 100,
excellentRate: Math.round((excellentCount / normalized.length) * 10000) / 100,
count: normalized.length,
studentCount: uniqueStudents,
} }
}) })
@@ -234,28 +192,11 @@ export const getSubjectComparison = cache(
const result: SubjectComparisonItem[] = [] const result: SubjectComparisonItem[] = []
for (const [subjectId, entry] of bySubject.entries()) { for (const [subjectId, entry] of bySubject.entries()) {
if (entry.scores.length === 0) continue if (entry.scores.length === 0) continue
const sorted = [...entry.scores].sort((a, b) => a - b) const stats = computeSubjectComparisonStats(entry.scores)
const mid = Math.floor(sorted.length / 2)
const median =
sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
const avg = entry.scores.reduce((a, b) => a + b, 0) / entry.scores.length
const { passCount, excellentCount } = entry.scores.reduce(
(acc, s) => ({
passCount: acc.passCount + (s >= 60 ? 1 : 0),
excellentCount: acc.excellentCount + (s >= 85 ? 1 : 0),
}),
{ passCount: 0, excellentCount: 0 }
)
result.push({ result.push({
subjectId, subjectId,
subjectName: entry.name, subjectName: entry.name,
averageScore: Math.round(avg * 100) / 100, ...stats,
medianScore: Math.round(median * 100) / 100,
passRate: Math.round((passCount / entry.scores.length) * 10000) / 100,
excellentRate: Math.round((excellentCount / entry.scores.length) * 10000) / 100,
count: entry.scores.length,
}) })
} }
@@ -289,24 +230,6 @@ export const getGradeDistribution = cache(
.from(gradeRecords) .from(gradeRecords)
.where(and(...conditions)) .where(and(...conditions))
const buckets: GradeDistributionBucket[] = [ return computeGradeDistribution(rows)
{ label: "90-100", min: 90, max: 100, count: 0 },
{ label: "80-89", min: 80, max: 89, count: 0 },
{ label: "70-79", min: 70, max: 79, count: 0 },
{ label: "60-69", min: 60, max: 69, count: 0 },
{ label: "<60", min: 0, max: 59, count: 0 },
]
for (const r of rows) {
const normalized = normalize(toNumber(r.score), toNumber(r.fullScore))
const rounded = Math.round(normalized)
if (rounded >= 90) buckets[0].count++
else if (rounded >= 80) buckets[1].count++
else if (rounded >= 70) buckets[2].count++
else if (rounded >= 60) buckets[3].count++
else buckets[4].count++
}
return { buckets, totalCount: rows.length }
} }
) )

View File

@@ -9,8 +9,8 @@ import { getStudentActiveClassId } from "@/modules/classes/data-access"
import { getUserNamesByIds } from "@/modules/users/data-access" import { getUserNamesByIds } from "@/modules/users/data-access"
import { normalize, toNumber } from "./lib/grade-utils" import { normalize, toNumber } from "./lib/grade-utils"
import { buildRankingTrendPoints, type RankingTrendEntry } from "./stats-service"
import type { import type {
RankingTrendPoint,
RankingTrendResult, RankingTrendResult,
} from "./types" } from "./types"
@@ -56,13 +56,7 @@ export const getRankingTrend = cache(
.where(and(...conditions)) .where(and(...conditions))
.orderBy(asc(gradeRecords.createdAt)) .orderBy(asc(gradeRecords.createdAt))
const byTitle = new Map< const byTitle = new Map<string, RankingTrendEntry>()
string,
{
date: Date
entries: Array<{ studentId: string; normalized: number }>
}
>()
for (const r of rows) { for (const r of rows) {
const entry = byTitle.get(r.title) ?? { date: r.createdAt, entries: [] } const entry = byTitle.get(r.title) ?? { date: r.createdAt, entries: [] }
@@ -73,33 +67,7 @@ export const getRankingTrend = cache(
byTitle.set(r.title, entry) byTitle.set(r.title, entry)
} }
const points: RankingTrendPoint[] = [] const points = buildRankingTrendPoints(byTitle, studentId)
for (const [title, entry] of byTitle.entries()) {
if (entry.entries.length === 0) continue
const sorted = [...entry.entries].sort((a, b) => b.normalized - a.normalized)
// Single traversal: find rank and student entry together
let rank = 0
let studentEntry: { studentId: string; normalized: number } | null = null
for (let i = 0; i < sorted.length; i += 1) {
const e = sorted[i]
if (e.studentId === studentId) {
rank = i + 1
studentEntry = e
break
}
}
if (rank <= 0 || !studentEntry) continue
points.push({
title,
date: entry.date.toISOString(),
score: studentEntry.normalized,
rank,
totalStudents: sorted.length,
})
}
points.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
return { return {
studentId, studentId,

View File

@@ -17,6 +17,7 @@ import { getUserNamesByIds } from "@/modules/users/data-access"
import type { DataScope } from "@/shared/types/permissions" import type { DataScope } from "@/shared/types/permissions"
import { buildScopeClassFilter, toNumber } from "./lib/grade-utils" import { buildScopeClassFilter, toNumber } from "./lib/grade-utils"
import { computeAverageScore, computeGradeStats } from "./stats-service"
import type { import type {
ClassGradeStats, ClassGradeStats,
ClassRankingItem, ClassRankingItem,
@@ -208,40 +209,7 @@ export const getClassGradeStats = cache(
.from(gradeRecords) .from(gradeRecords)
.where(and(...conditions)) .where(and(...conditions))
if (rows.length === 0) return null return computeGradeStats(rows)
const scores = rows.map((r) => toNumber(r.score))
const fullScores = rows.map((r) => toNumber(r.fullScore))
const countN = scores.length
const sum = scores.reduce((a, b) => a + b, 0)
const average = sum / countN
const sorted = [...scores].sort((a, b) => a - b)
const mid = Math.floor(countN / 2)
const median = countN % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
const max = sorted[countN - 1]
const min = sorted[0]
const variance = scores.reduce((acc, s) => acc + Math.pow(s - average, 2), 0) / countN
const stdDev = Math.sqrt(variance)
let passCount = 0
let excellentCount = 0
for (let i = 0; i < countN; i++) {
if (fullScores[i] <= 0) continue
const ratio = scores[i] / fullScores[i]
if (ratio >= 0.6) passCount++
if (ratio >= 0.85) excellentCount++
}
return {
average: Math.round(average * 100) / 100,
median: Math.round(median * 100) / 100,
max,
min,
stdDev: Math.round(stdDev * 100) / 100,
passRate: Math.round((passCount / countN) * 10000) / 100,
excellentRate: Math.round((excellentCount / countN) * 10000) / 100,
count: countN,
}
} }
) )
@@ -300,13 +268,13 @@ export const getStudentGradeSummary = cache(
createdAt: r.record.createdAt.toISOString(), createdAt: r.record.createdAt.toISOString(),
})) }))
const avg = listItems.reduce((a, b) => a + b.score, 0) / listItems.length const avg = computeAverageScore(listItems.map((i) => i.score))
return { return {
studentId, studentId,
studentName: studentName ?? "Unknown", studentName: studentName ?? "Unknown",
records: listItems, records: listItems,
averageScore: Math.round(avg * 100) / 100, averageScore: avg,
rank: 0, rank: 0,
} }
} }

View File

@@ -2,7 +2,8 @@ import "server-only"
import { eq, inArray, sql, type SQL } from "drizzle-orm" import { eq, inArray, sql, type SQL } from "drizzle-orm"
import { gradeRecords } from "@/shared/db/schema" import { db } from "@/shared/db"
import { classes, gradeRecords } from "@/shared/db/schema"
import type { DataScope } from "@/shared/types/permissions" import type { DataScope } from "@/shared/types/permissions"
/** /**
@@ -39,7 +40,16 @@ export const buildScopeClassFilter = (scope: DataScope): SQL | null => {
? inArray(gradeRecords.classId, scope.classIds) ? inArray(gradeRecords.classId, scope.classIds)
: sql`1=0` : sql`1=0`
} }
if (scope.type === "grade_managed") return sql`1=0` if (scope.type === "grade_managed") {
// P2-4 修复grade_managed scope 应返回所管年级的班级成绩记录
// 通过子查询过滤 classId IN (SELECT id FROM classes WHERE grade_id IN (...))
return scope.gradeIds.length > 0
? inArray(
gradeRecords.classId,
db.select({ id: classes.id }).from(classes).where(inArray(classes.gradeId, scope.gradeIds))
)
: sql`1=0`
}
if (scope.type === "class_members") return null if (scope.type === "class_members") return null
if (scope.type === "children") { if (scope.type === "children") {
return scope.childrenIds.length > 0 return scope.childrenIds.length > 0

View File

@@ -0,0 +1,305 @@
/**
* Grade statistics pure functions.
*
* Extracted from data-access / data-access-analytics / data-access-ranking
* to keep data-access layer focused on DB I/O and make statistics logic
* independently testable. All functions are pure (no side effects, no I/O).
*/
import { normalize, toNumber } from "./lib/grade-utils"
import type {
ClassComparisonItem,
GradeDistributionBucket,
GradeDistributionResult,
GradeStats,
GradeTrendPoint,
RankingTrendPoint,
} from "./types"
/** Round to 2 decimal places. */
const round2 = (n: number): number => Math.round(n * 100) / 100
/** Pass threshold (60% of full score, normalized to 60/100). */
export const PASS_THRESHOLD = 60
/** Excellent threshold (85% of full score, normalized to 85/100). */
export const EXCELLENT_THRESHOLD = 85
/**
* Raw score row from DB (numeric columns may be string | number depending on driver).
*/
export interface RawScoreRow {
score: unknown
fullScore: unknown
}
/**
* Compute aggregate stats (average/median/max/min/stdDev/passRate/excellentRate/count)
* from a list of raw score rows. Returns null when rows is empty.
*/
export function computeGradeStats(rows: RawScoreRow[]): GradeStats | null {
if (rows.length === 0) return null
const scores = rows.map((r) => toNumber(r.score))
const fullScores = rows.map((r) => toNumber(r.fullScore))
const countN = scores.length
const sum = scores.reduce((a, b) => a + b, 0)
const average = sum / countN
const sorted = [...scores].sort((a, b) => a - b)
const mid = Math.floor(countN / 2)
const median =
countN % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
const max = sorted[countN - 1]
const min = sorted[0]
const variance =
scores.reduce((acc, s) => acc + Math.pow(s - average, 2), 0) / countN
const stdDev = Math.sqrt(variance)
let passCount = 0
let excellentCount = 0
for (let i = 0; i < countN; i += 1) {
if (fullScores[i] <= 0) continue
const ratio = scores[i] / fullScores[i]
if (ratio >= 0.6) passCount += 1
if (ratio >= 0.85) excellentCount += 1
}
return {
average: round2(average),
median: round2(median),
max,
min,
stdDev: round2(stdDev),
passRate: round2((passCount / countN) * 100),
excellentRate: round2((excellentCount / countN) * 100),
count: countN,
}
}
/**
* Compute average score from a list of grade record list items.
*/
export function computeAverageScore(scores: number[]): number {
if (scores.length === 0) return 0
return round2(scores.reduce((a, b) => a + b, 0) / scores.length)
}
/**
* Build a grade trend point list from raw DB rows (already ordered by date asc).
*/
export function buildGradeTrendPoints(
rows: Array<{
record: {
createdAt: Date
title: string
score: unknown
fullScore: unknown
type: string
}
}>
): GradeTrendPoint[] {
return rows.map((r) => {
const score = toNumber(r.record.score)
const fullScore = toNumber(r.record.fullScore)
return {
date: r.record.createdAt.toISOString(),
title: r.record.title,
score,
fullScore,
normalizedScore: normalize(score, fullScore),
type: r.record.type as GradeTrendPoint["type"],
}
})
}
/**
* Compute the average of normalized scores from a list of trend points.
*/
export function computeTrendAverage(points: GradeTrendPoint[]): number {
if (points.length === 0) return 0
const avg =
points.reduce((acc, p) => acc + p.normalizedScore, 0) / points.length
return round2(avg)
}
/**
* Compute class comparison stats for a single class from raw score rows.
*/
export function computeClassComparisonStats(
rows: Array<{
score: unknown
fullScore: unknown
studentId: string
}>
): Pick<
ClassComparisonItem,
| "averageScore"
| "medianScore"
| "passRate"
| "excellentRate"
| "count"
| "studentCount"
> {
if (rows.length === 0) {
return {
averageScore: 0,
medianScore: 0,
passRate: 0,
excellentRate: 0,
count: 0,
studentCount: 0,
}
}
const normalized = rows.map((r) =>
normalize(toNumber(r.score), toNumber(r.fullScore))
)
const sorted = [...normalized].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
const median =
sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid]
const avg = normalized.reduce((a, b) => a + b, 0) / normalized.length
const uniqueStudents = new Set(rows.map((r) => r.studentId)).size
let passCount = 0
let excellentCount = 0
for (const s of normalized) {
if (s >= PASS_THRESHOLD) passCount += 1
if (s >= EXCELLENT_THRESHOLD) excellentCount += 1
}
return {
averageScore: round2(avg),
medianScore: round2(median),
passRate: round2((passCount / normalized.length) * 100),
excellentRate: round2((excellentCount / normalized.length) * 100),
count: normalized.length,
studentCount: uniqueStudents,
}
}
/**
* Compute subject comparison stats for a single subject from normalized scores.
*/
export function computeSubjectComparisonStats(
scores: number[]
): Pick<
import("./types").SubjectComparisonItem,
"averageScore" | "medianScore" | "passRate" | "excellentRate" | "count"
> {
if (scores.length === 0) {
return {
averageScore: 0,
medianScore: 0,
passRate: 0,
excellentRate: 0,
count: 0,
}
}
const sorted = [...scores].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
const median =
sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid]
const avg = scores.reduce((a, b) => a + b, 0) / scores.length
let passCount = 0
let excellentCount = 0
for (const s of scores) {
if (s >= PASS_THRESHOLD) passCount += 1
if (s >= EXCELLENT_THRESHOLD) excellentCount += 1
}
return {
averageScore: round2(avg),
medianScore: round2(median),
passRate: round2((passCount / scores.length) * 100),
excellentRate: round2((excellentCount / scores.length) * 100),
count: scores.length,
}
}
/**
* Default distribution buckets (90-100, 80-89, 70-79, 60-69, <60).
*/
export function createDefaultBuckets(): GradeDistributionBucket[] {
return [
{ label: "90-100", min: 90, max: 100, count: 0 },
{ label: "80-89", min: 80, max: 89, count: 0 },
{ label: "70-79", min: 70, max: 79, count: 0 },
{ label: "60-69", min: 60, max: 69, count: 0 },
{ label: "<60", min: 0, max: 59, count: 0 },
]
}
/**
* Bucketize raw score rows into a grade distribution result.
*/
export function computeGradeDistribution(
rows: RawScoreRow[]
): GradeDistributionResult {
const buckets = createDefaultBuckets()
for (const r of rows) {
const normalized = normalize(toNumber(r.score), toNumber(r.fullScore))
const rounded = Math.round(normalized)
if (rounded >= 90) buckets[0].count += 1
else if (rounded >= 80) buckets[1].count += 1
else if (rounded >= 70) buckets[2].count += 1
else if (rounded >= 60) buckets[3].count += 1
else buckets[4].count += 1
}
return { buckets, totalCount: rows.length }
}
/**
* Ranking trend entry for a single assessment (grouped by title).
*/
export interface RankingTrendEntry {
date: Date
entries: Array<{ studentId: string; normalized: number }>
}
/**
* Build ranking trend points from grouped entries.
* Returns points sorted by date ascending.
*/
export function buildRankingTrendPoints(
byTitle: Map<string, RankingTrendEntry>,
targetStudentId: string
): RankingTrendPoint[] {
const points: RankingTrendPoint[] = []
for (const [title, entry] of byTitle.entries()) {
if (entry.entries.length === 0) continue
const sorted = [...entry.entries].sort(
(a, b) => b.normalized - a.normalized
)
let rank = 0
let studentEntry: { studentId: string; normalized: number } | null = null
for (let i = 0; i < sorted.length; i += 1) {
const e = sorted[i]
if (e.studentId === targetStudentId) {
rank = i + 1
studentEntry = e
break
}
}
if (rank <= 0 || !studentEntry) continue
points.push({
title,
date: entry.date.toISOString(),
score: studentEntry.normalized,
rank,
totalStudents: sorted.length,
})
}
points.sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
)
return points
}

View File

@@ -1247,7 +1247,7 @@ export const diagnosticReportTypeEnum = mysqlEnum("report_type", ["individual",
export const learningDiagnosticReports = mysqlTable("learning_diagnostic_reports", { export const learningDiagnosticReports = mysqlTable("learning_diagnostic_reports", {
id: id("id").primaryKey(), id: id("id").primaryKey(),
studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), studentId: varchar("student_id", { length: 128 }).references(() => users.id, { onDelete: "cascade" }),
generatedBy: varchar("generated_by", { length: 128 }).references(() => users.id, { onDelete: "set null" }), generatedBy: varchar("generated_by", { length: 128 }).references(() => users.id, { onDelete: "set null" }),
reportType: diagnosticReportTypeEnum.default("individual").notNull(), reportType: diagnosticReportTypeEnum.default("individual").notNull(),
period: varchar("period", { length: 50 }), period: varchar("period", { length: 50 }),
@@ -1317,3 +1317,23 @@ export const lessonPlanTemplates = mysqlTable("lesson_plan_templates", {
}, (table) => ({ }, (table) => ({
typeCreatorIdx: index("lpt_type_creator_idx").on(table.type, table.creatorId), typeCreatorIdx: index("lpt_type_creator_idx").on(table.type, table.creatorId),
})); }));
// --- 25. System Settings (系统设置 - 键值对存储) ---
export const systemSettings = mysqlTable("system_settings", {
id: id("id").primaryKey(),
/** 设置分组school_info / security_policy / file_upload / notification_config */
category: varchar("category", { length: 50 }).notNull(),
/** 设置键名,如 schoolName / passwordMinLength / maxFileSize */
key: varchar("key", { length: 100 }).notNull(),
/** 设置值JSON 字符串,支持字符串/数字/布尔/对象) */
value: text("value").notNull(),
/** 值类型string / number / boolean / json */
valueType: varchar("value_type", { length: 20 }).default("string").notNull(),
updatedBy: varchar("updated_by", { length: 128 }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
categoryKeyIdx: uniqueIndex("ss_category_key_idx").on(table.category, table.key),
categoryIdx: index("ss_category_idx").on(table.category),
}));