refactor(grades,diagnostic): 成绩和学情诊断模块审计修复
P0-1: 10 个页面补充 requirePermission 权限校验 P0-2: diagnostic/data-access-reports.ts 移除直查 users 表,改用 getUserNamesByIds P0-3: 新增 grade/grades/diagnostic 三组 i18n 翻译文件(zh-CN/en) P0-4: 新增 /management/grade 重定向页面 P1-2: 抽取 toNumber/normalize/buildScopeClassFilter 到 lib/grade-utils.ts P1-3: 为 12 个 Action 新增 Zod safeParse 校验(schema.ts +12 查询 schema) P1-4: 修复 as 断言违规,改用类型守卫函数 P2-2: 移除 diagnostic 组件中 Tailwind 任意值 同步更新架构图文档 004 和 005
This commit is contained in:
@@ -1316,6 +1316,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
- ✅ P2 已修复:~~`updateMasteryFromSubmission` 循环内串行 await upsert~~ 改为 `Promise.all` 并行执行所有 upsert
|
- ✅ P2 已修复:~~`updateMasteryFromSubmission` 循环内串行 await upsert~~ 改为 `Promise.all` 并行执行所有 upsert
|
||||||
- ✅ P2 已修复:~~`getClassMasterySummary` 串行查询(className → studentIds → userMap → masteryRows)~~ 改为两组 `Promise.all` 并行(className+studentIds,userMap+masteryRows)
|
- ✅ P2 已修复:~~`getClassMasterySummary` 串行查询(className → studentIds → userMap → masteryRows)~~ 改为两组 `Promise.all` 并行(className+studentIds,userMap+masteryRows)
|
||||||
- ✅ P2 已修复:~~`getDiagnosticReports` 中 `conditions` 隐式 `any[]`~~ 改为显式 `SQL[]` 类型标注
|
- ✅ P2 已修复:~~`getDiagnosticReports` 中 `conditions` 隐式 `any[]`~~ 改为显式 `SQL[]` 类型标注
|
||||||
|
- ✅ 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:班级报告将生成者 ID 存入 `studentId` 字段(schema 设计缺陷 workaround)
|
- ⚠️ P2:班级报告将生成者 ID 存入 `studentId` 字段(schema 设计缺陷 workaround)
|
||||||
- ✅ 与 grades 模块无职责重叠(grades 管分数,diagnostic 管知识点掌握度)
|
- ✅ 与 grades 模块无职责重叠(grades 管分数,diagnostic 管知识点掌握度)
|
||||||
|
|
||||||
|
|||||||
@@ -7941,6 +7941,102 @@
|
|||||||
"usedBy": [
|
"usedBy": [
|
||||||
"updateGradeRecordAction"
|
"updateGradeRecordAction"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DeleteGradeRecordSchema",
|
||||||
|
"type": "ZodSchema",
|
||||||
|
"file": "schema.ts",
|
||||||
|
"usedBy": [
|
||||||
|
"deleteGradeRecordAction"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "GetGradeRecordByIdSchema",
|
||||||
|
"type": "ZodSchema",
|
||||||
|
"file": "schema.ts",
|
||||||
|
"usedBy": [
|
||||||
|
"getGradeRecordByIdAction"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "GradeQuerySchema",
|
||||||
|
"type": "ZodSchema",
|
||||||
|
"file": "schema.ts",
|
||||||
|
"usedBy": [
|
||||||
|
"getGradeRecordsAction"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ClassGradeStatsQuerySchema",
|
||||||
|
"type": "ZodSchema",
|
||||||
|
"file": "schema.ts",
|
||||||
|
"usedBy": [
|
||||||
|
"getClassGradeStatsAction"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "StudentGradeSummaryQuerySchema",
|
||||||
|
"type": "ZodSchema",
|
||||||
|
"file": "schema.ts",
|
||||||
|
"usedBy": [
|
||||||
|
"getStudentGradeSummaryAction"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ClassRankingQuerySchema",
|
||||||
|
"type": "ZodSchema",
|
||||||
|
"file": "schema.ts",
|
||||||
|
"usedBy": [
|
||||||
|
"getClassRankingAction"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ExportGradesSchema",
|
||||||
|
"type": "ZodSchema",
|
||||||
|
"file": "schema.ts",
|
||||||
|
"usedBy": [
|
||||||
|
"exportGradesAction"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "GradeTrendQuerySchema",
|
||||||
|
"type": "ZodSchema",
|
||||||
|
"file": "schema.ts",
|
||||||
|
"usedBy": [
|
||||||
|
"getGradeTrendAction"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ClassComparisonQuerySchema",
|
||||||
|
"type": "ZodSchema",
|
||||||
|
"file": "schema.ts",
|
||||||
|
"usedBy": [
|
||||||
|
"getClassComparisonAction"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SubjectComparisonQuerySchema",
|
||||||
|
"type": "ZodSchema",
|
||||||
|
"file": "schema.ts",
|
||||||
|
"usedBy": [
|
||||||
|
"getSubjectComparisonAction"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "GradeDistributionQuerySchema",
|
||||||
|
"type": "ZodSchema",
|
||||||
|
"file": "schema.ts",
|
||||||
|
"usedBy": [
|
||||||
|
"getGradeDistributionAction"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "RankingTrendQuerySchema",
|
||||||
|
"type": "ZodSchema",
|
||||||
|
"file": "schema.ts",
|
||||||
|
"usedBy": [
|
||||||
|
"getRankingTrendAction"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"types": [
|
"types": [
|
||||||
@@ -8154,6 +8250,48 @@
|
|||||||
"migratedTo": "shared/lib/utils.formatDateForFile"
|
"migratedTo": "shared/lib/utils.formatDateForFile"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"lib": [
|
||||||
|
{
|
||||||
|
"name": "toNumber",
|
||||||
|
"signature": "(v: unknown) => number",
|
||||||
|
"file": "lib/grade-utils.ts",
|
||||||
|
"purpose": "安全将 unknown 值转换为有限数字,非有限值返回 0(P1-2 新增:从 data-access/data-access-analytics/data-access-ranking 抽取)",
|
||||||
|
"usedBy": [
|
||||||
|
"data-access.serializeRecord",
|
||||||
|
"data-access.getClassGradeStats",
|
||||||
|
"data-access-analytics.getGradeTrend",
|
||||||
|
"data-access-analytics.getClassComparison",
|
||||||
|
"data-access-analytics.getSubjectComparison",
|
||||||
|
"data-access-analytics.getGradeDistribution",
|
||||||
|
"data-access-ranking.getRankingTrend"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "normalize",
|
||||||
|
"signature": "(score: number, fullScore: number) => number",
|
||||||
|
"file": "lib/grade-utils.ts",
|
||||||
|
"purpose": "将原始分数归一化到 0-100 分制(P1-2 新增:从 data-access-analytics/data-access-ranking 抽取)",
|
||||||
|
"usedBy": [
|
||||||
|
"data-access-analytics.getGradeTrend",
|
||||||
|
"data-access-analytics.getSubjectComparison",
|
||||||
|
"data-access-analytics.getGradeDistribution",
|
||||||
|
"data-access-ranking.getRankingTrend"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "buildScopeClassFilter",
|
||||||
|
"signature": "(scope: DataScope) => SQL | null",
|
||||||
|
"file": "lib/grade-utils.ts",
|
||||||
|
"purpose": "根据 DataScope 构建 gradeRecords 表的行级权限过滤条件(P1-2 新增:从 data-access/data-access-analytics 抽取)",
|
||||||
|
"usedBy": [
|
||||||
|
"data-access.getGradeRecords",
|
||||||
|
"data-access-analytics.getGradeTrend",
|
||||||
|
"data-access-analytics.getClassComparison",
|
||||||
|
"data-access-analytics.getSubjectComparison",
|
||||||
|
"data-access-analytics.getGradeDistribution"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"components": [
|
"components": [
|
||||||
{
|
{
|
||||||
"name": "GradeRecordForm",
|
"name": "GradeRecordForm",
|
||||||
|
|||||||
672
docs/architecture/audit/grades-diagnostic-audit-report.md
Normal file
672
docs/architecture/audit/grades-diagnostic-audit-report.md
Normal file
@@ -0,0 +1,672 @@
|
|||||||
|
# 成绩和学情诊断模块审计报告
|
||||||
|
|
||||||
|
> 审查日期:2026-06-22
|
||||||
|
> 审查范围:`src/modules/grades/**`(成绩模块)、`src/modules/diagnostic/**`(学情诊断模块)、`src/app/(dashboard)/{admin,teacher,student,parent}/grades/**`、`src/app/(dashboard)/{teacher,student}/diagnostic/**`、`src/app/(dashboard)/management/grade/**`、相关 i18n 翻译文件
|
||||||
|
> 架构图参考:`docs/architecture/004_architecture_impact_map.md` §2.6(grades)、§2.22(diagnostic)、`docs/architecture/005_architecture_data.json` L7362(grades)、L10927(diagnostic)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、现有实现概要
|
||||||
|
|
||||||
|
### 1.1 文件分布
|
||||||
|
|
||||||
|
#### grades 模块(成绩分析)
|
||||||
|
|
||||||
|
| 层 | 路径 | 文件数 | 行数 | 说明 |
|
||||||
|
|----|------|--------|------|------|
|
||||||
|
| Actions | `src/modules/grades/actions.ts` | 1 | 312 | 10 个 Server Action(CRUD + 查询 + 导出) |
|
||||||
|
| Actions | `src/modules/grades/actions-analytics.ts` | 1 | 133 | 5 个分析 Server Action(趋势/对比/分布/排名) |
|
||||||
|
| Data-access | `src/modules/grades/data-access.ts` | 1 | 433 | 成绩 CRUD + 统计(含统计业务逻辑) |
|
||||||
|
| Data-access | `src/modules/grades/data-access-analytics.ts` | 1 | 337 | 趋势/对比/分布分析(含统计业务逻辑) |
|
||||||
|
| Data-access | `src/modules/grades/data-access-ranking.ts` | 1 | 119 | 排名查询(含 normalize 逻辑) |
|
||||||
|
| Export | `src/modules/grades/export.ts` | 1 | 200 | Excel 导出(明细 + 班级汇总) |
|
||||||
|
| Schema | `src/modules/grades/schema.ts` | 1 | 52 | 4 个 Zod schema |
|
||||||
|
| Types | `src/modules/grades/types.ts` | 1 | 186 | 14 个类型定义 |
|
||||||
|
| Components | `src/modules/grades/components/*` | 16 | 41~442 | 16 个组件(含 batch-grade-entry 442 行) |
|
||||||
|
|
||||||
|
#### diagnostic 模块(学情诊断)
|
||||||
|
|
||||||
|
| 层 | 路径 | 文件数 | 行数 | 说明 |
|
||||||
|
|----|------|--------|------|------|
|
||||||
|
| Actions | `src/modules/diagnostic/actions.ts` | 1 | 172 | 6 个 Server Action(生成/发布/删除/查询) |
|
||||||
|
| Data-access | `src/modules/diagnostic/data-access.ts` | 1 | 257 | 知识点掌握度查询 + 更新 |
|
||||||
|
| Data-access | `src/modules/diagnostic/data-access-reports.ts` | 1 | 203 | 诊断报告 CRUD(**直查 users 表**) |
|
||||||
|
| Schema | `src/modules/diagnostic/schema.ts` | 1 | 48 | 6 个 Zod schema |
|
||||||
|
| Types | `src/modules/diagnostic/types.ts` | 1 | 97 | 11 个类型定义 |
|
||||||
|
| Components | `src/modules/diagnostic/components/*` | 4 | 69~267 | 4 个组件(含 class-diagnostic-view 267 行) |
|
||||||
|
|
||||||
|
#### 路由层
|
||||||
|
|
||||||
|
| 角色 | 路由 | 文件数 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| admin | `/admin/school/grades/`、`/admin/school/grades/insights/` | 4 | 含 loading.tsx + error.tsx |
|
||||||
|
| teacher | `/teacher/grades/`、`/teacher/grades/analytics/`、`/teacher/grades/entry/`、`/teacher/grades/stats/` | 4 | **无 loading.tsx / error.tsx** |
|
||||||
|
| teacher | `/teacher/diagnostic/`、`/teacher/diagnostic/class/[classId]/`、`/teacher/diagnostic/student/[studentId]/` | 3 | **无 loading.tsx / error.tsx** |
|
||||||
|
| student | `/student/grades/`、`/student/diagnostic/` | 4 | 含 loading.tsx,**无 error.tsx** |
|
||||||
|
| parent | `/parent/grades/` | 2 | 含 loading.tsx,**无 error.tsx** |
|
||||||
|
| management | `/management/grade/`、`/management/grade/classes/`、`/management/grade/insights/` | 5 | **`/management/grade/page.tsx` 缺失**(孤儿 loading/error) |
|
||||||
|
|
||||||
|
### 1.2 主要数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
[成绩录入] teacher/grades/entry
|
||||||
|
└─▶ grades/actions.batchCreateGradeRecordsAction
|
||||||
|
├─▶ requirePermission(GRADE_RECORD_MANAGE)
|
||||||
|
└─▶ data-access.batchCreateGradeRecords → db.insert(gradeRecords)
|
||||||
|
|
||||||
|
[成绩查询] teacher/grades / student/grades / parent/grades
|
||||||
|
└─▶ grades/actions.getGradeRecordsAction
|
||||||
|
├─▶ requirePermission(GRADE_RECORD_READ)
|
||||||
|
├─▶ data-access.getGradeRecords(含 scope 行级过滤)
|
||||||
|
└─▶ 跨模块:classes/school/users data-access
|
||||||
|
|
||||||
|
[成绩分析] teacher/grades/analytics
|
||||||
|
└─▶ grades/actions-analytics.getGradeTrendAction / getClassComparisonAction / ...
|
||||||
|
├─▶ requirePermission(GRADE_RECORD_READ)
|
||||||
|
└─▶ data-access-analytics(含统计计算逻辑)
|
||||||
|
|
||||||
|
[学情诊断-学生] teacher/diagnostic/student/[id] / student/diagnostic
|
||||||
|
└─▶ diagnostic/data-access.getStudentMasterySummary
|
||||||
|
└─▶ 跨模块:users data-access(getUserNamesByIds)
|
||||||
|
|
||||||
|
[学情诊断-班级] teacher/diagnostic/class/[id]
|
||||||
|
└─▶ diagnostic/data-access.getClassMasterySummary
|
||||||
|
└─▶ 跨模块:classes/exams/questions/users data-access
|
||||||
|
|
||||||
|
[诊断报告生成] teacher/diagnostic
|
||||||
|
└─▶ diagnostic/actions.generateStudentReportAction / generateClassReportAction
|
||||||
|
├─▶ requirePermission(DIAGNOSTIC_MANAGE)
|
||||||
|
└─▶ data-access-reports.createDiagnosticReport
|
||||||
|
└─▶ ⚠️ 直查 users 表(违反三层架构)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 架构图记录情况
|
||||||
|
|
||||||
|
`004_architecture_impact_map.md` §2.6(grades)和 §2.22(diagnostic)已记录两个模块的导出函数、依赖关系、已知问题和文件清单。架构图信息基本完整,但存在以下遗漏:
|
||||||
|
|
||||||
|
- **grades 模块行数过时**:架构图 L681 标注 `data-access.ts` 419 行(实际 433 行)、L682 `data-access-analytics.ts` 293 行(实际 337 行)
|
||||||
|
- **diagnostic 模块 deps 过时**:`005_architecture_data.json` L10922/L10937-10941/L10954-10958/L10972-10975 仍记录 diagnostic 直查对方表,实际代码已通过 data-access 接口访问(P1-1 已修复但文档未同步)
|
||||||
|
- **diagnostic `data-access-reports.ts` 直查 users 表未记录**:架构图未标注此违规
|
||||||
|
- **grades 模块 actions-analytics.ts 的 5 个 Action 未完整列入 exports 清单**
|
||||||
|
- **`/management/grade/page.tsx` 缺失**未在路由清单中标注
|
||||||
|
- **teacher 端 grades/diagnostic 路由普遍缺少 loading.tsx/error.tsx** 未标注
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、现存问题与原因分析
|
||||||
|
|
||||||
|
### 2.1 安全性:权限校验缺失或不一致(P0)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [teacher/grades/entry/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/grades/entry/page.tsx) | **无任何权限校验**(既无 `requirePermission` 也无 `getAuthContext`) | "所有 Server Action 必须调用 `requirePermission()` 进行权限校验" |
|
||||||
|
| [teacher/grades/stats/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/grades/stats/page.tsx) | **无任何权限校验** | 同上 |
|
||||||
|
| [teacher/grades/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/grades/page.tsx) | 仅 `getAuthContext()`,无 `requirePermission(GRADE_RECORD_READ)` | 同上 |
|
||||||
|
| [teacher/grades/analytics/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/grades/analytics/page.tsx) | 仅 `getAuthContext()`,无 `requirePermission(GRADE_RECORD_READ)` | 同上 |
|
||||||
|
| [teacher/diagnostic/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/diagnostic/page.tsx) | 仅 `getAuthContext()`,无 `requirePermission(DIAGNOSTIC_READ)` | 同上 |
|
||||||
|
| [teacher/diagnostic/class/[classId]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/diagnostic/class/[classId]/page.tsx) | 仅 `getAuthContext()`(有 dataScope 校验),无 `requirePermission(DIAGNOSTIC_READ)` | 同上 |
|
||||||
|
| [teacher/diagnostic/student/[studentId]/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/diagnostic/student/[studentId]/page.tsx) | 仅 `getAuthContext()`(有 dataScope 校验),无 `requirePermission(DIAGNOSTIC_READ)` | 同上 |
|
||||||
|
| [student/grades/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/student/grades/page.tsx) | 仅 `getAuthContext()`,无 `requirePermission(GRADE_RECORD_READ)` | 同上 |
|
||||||
|
| [student/diagnostic/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/student/diagnostic/page.tsx) | 仅 `getAuthContext()`,无 `requirePermission(DIAGNOSTIC_READ)` | 同上 |
|
||||||
|
| [parent/grades/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/parent/grades/page.tsx) | 仅 `getAuthContext()`(有 dataScope 校验),无 `requirePermission(GRADE_RECORD_READ)` | 同上 |
|
||||||
|
|
||||||
|
**后果**:成绩录入页面(`/teacher/grades/entry`)和成绩统计页面(`/teacher/grades/stats`)完全无权限校验,依赖路由中间件做粗粒度角色路由。若中间件配置错误或绕过,任意已登录用户可访问成绩录入页面并调用 `batchCreateGradeRecordsAction`(虽然 Action 层有 `requirePermission`,但页面层缺少二次校验不符合"Server Action 二次校验"要求)。
|
||||||
|
|
||||||
|
### 2.2 架构分层:跨模块直接查询 users 表(P0)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [diagnostic/data-access-reports.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/data-access-reports.ts) L8 | `import { learningDiagnosticReports, users } from "@/shared/db/schema"` | "modules/ 之间通过对方 data-access 通信,不直接查询对方 DB 表" |
|
||||||
|
| [diagnostic/data-access-reports.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/data-access-reports.ts) L137-140 | `getDiagnosticReports` 直接 `leftJoin(users, ...)` 查询学生姓名 | 同上 |
|
||||||
|
| [diagnostic/data-access-reports.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/data-access-reports.ts) L149-153 | 直接 `db.select({ id: users.id, name: users.name }).from(users)` 查询生成者姓名 | 同上 |
|
||||||
|
| [diagnostic/data-access-reports.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/data-access-reports.ts) L168-170 | `getDiagnosticReportById` 直接 `leftJoin(users, ...)` | 同上 |
|
||||||
|
| [diagnostic/data-access-reports.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/data-access-reports.ts) L177-182 | 直接 `db.select({ name: users.name }).from(users)` | 同上 |
|
||||||
|
|
||||||
|
**后果**:`diagnostic` 模块绕过 `users` 模块的 data-access 层直接查询 `users` 表,破坏模块封装性。`users` 表 schema 变更将直接影响 diagnostic 模块。同模块的 `data-access.ts` 已正确通过 `getUserNamesByIds` 访问,但 `data-access-reports.ts` 却绕过,存在不一致。
|
||||||
|
|
||||||
|
### 2.3 架构分层:统计业务逻辑混入 data-access(P1)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [grades/data-access.ts](file:///e:/Desktop/CICD/src/modules/grades/data-access.ts) L217-270 | `getClassGradeStats` 包含 average/median/max/min/variance/stdDev/passRate/excellentRate 计算(53 行统计逻辑) | "严格三层架构,依赖方向单向" — 统计计算属业务逻辑层 |
|
||||||
|
| [grades/data-access.ts](file:///e:/Desktop/CICD/src/modules/grades/data-access.ts) L272-337 | `getStudentGradeSummary` 包含 averageScore 计算 | 同上 |
|
||||||
|
| [grades/data-access.ts](file:///e:/Desktop/CICD/src/modules/grades/data-access.ts) L339-373 | `getClassRanking` 包含 rank 计算 | 同上 |
|
||||||
|
| [grades/data-access-analytics.ts](file:///e:/Desktop/CICD/src/modules/grades/data-access-analytics.ts) L59-119 | `getGradeTrend` 包含 normalized/avg 计算 | 同上 |
|
||||||
|
| [grades/data-access-analytics.ts](file:///e:/Desktop/CICD/src/modules/grades/data-access-analytics.ts) L128-218 | `getClassComparison` 包含 normalized/median/avg/passCount/excellentCount 计算(90 行) | 同上 |
|
||||||
|
| [grades/data-access-analytics.ts](file:///e:/Desktop/CICD/src/modules/grades/data-access-analytics.ts) L226-289 | `getSubjectComparison` 包含 median/avg/passRate/excellentRate 计算 | 同上 |
|
||||||
|
| [grades/data-access-analytics.ts](file:///e:/Desktop/CICD/src/modules/grades/data-access-analytics.ts) L299-336 | `getGradeDistribution` 包含 bucket 分类逻辑 | 同上 |
|
||||||
|
| [grades/data-access-ranking.ts](file:///e:/Desktop/CICD/src/modules/grades/data-access-ranking.ts) L31-118 | `getRankingTrend` 包含 normalize/rank 计算逻辑 | 同上 |
|
||||||
|
|
||||||
|
**后果**:data-access 层职责混乱,既负责数据读取又负责业务计算,难以单独测试统计逻辑。架构图 L671 已标记此 P2 问题。应抽取到独立的 `stats-service.ts`(参考 homework 模块的 `stats-service.ts` 范例)。
|
||||||
|
|
||||||
|
### 2.4 重复代码:工具函数多处重复(P1)
|
||||||
|
|
||||||
|
| 重复函数 | 出现位置 | 违反规则 |
|
||||||
|
|----------|----------|----------|
|
||||||
|
| `buildScopeClassFilter` | [grades/data-access.ts](file:///e:/Desktop/CICD/src/modules/grades/data-access.ts) L57-75、[grades/data-access-analytics.ts](file:///e:/Desktop/CICD/src/modules/grades/data-access-analytics.ts) L34-48 | "工具函数:建议 ≤ 40 行" + DRY 原则 |
|
||||||
|
| `toNumber` | [grades/data-access.ts](file:///e:/Desktop/CICD/src/modules/grades/data-access.ts) L34-37、[grades/data-access-analytics.ts](file:///e:/Desktop/CICD/src/modules/grades/data-access-analytics.ts) L24-27、[grades/data-access-ranking.ts](file:///e:/Desktop/CICD/src/modules/grades/data-access-ranking.ts) L16-19 | 同上 |
|
||||||
|
| `normalize` | [grades/data-access.ts](file:///e:/Desktop/CICD/src/modules/grades/data-access.ts)、[grades/data-access-analytics.ts](file:///e:/Desktop/CICD/src/modules/grades/data-access-analytics.ts) L29-32、[grades/data-access-ranking.ts](file:///e:/Desktop/CICD/src/modules/grades/data-access-ranking.ts) L21-24 | 同上 |
|
||||||
|
|
||||||
|
**后果**:3 个文件重复实现相同工具函数,修改时需同步多处,易遗漏导致行为不一致。应抽取到 `grades/lib/stats-utils.ts` 或 `shared/lib/grade-utils.ts`。
|
||||||
|
|
||||||
|
### 2.5 国际化:完全缺失(P0)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `src/modules/grades/components/*`(16 个文件) | 全部使用硬编码英文字符串,0 处 `useTranslations` 调用 | "所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键" |
|
||||||
|
| `src/modules/diagnostic/components/*`(4 个文件) | 全部使用硬编码英文字符串,0 处 `useTranslations` 调用 | 同上 |
|
||||||
|
| [grades/export.ts](file:///e:/Desktop/CICD/src/modules/grades/export.ts) L12-17, L54-61, L68-80, L287, L295 | Excel 导出表头、指标名、文件名硬编码中文 | 同上 |
|
||||||
|
| `src/shared/i18n/messages/{zh-CN,en}/` | **不存在 `grades.json` 和 `diagnostic.json` 翻译文件** | 同上 |
|
||||||
|
| [i18n/request.ts](file:///e:/Desktop/CICD/src/i18n/request.ts) L22-28 | 仅加载 5 个命名空间(common/auth/onboarding/classes/errors),未加载 grades/diagnostic | 同上 |
|
||||||
|
| `src/modules/grade-management/components/*`(7 个文件,12 处) | 调用 `useTranslations("grade")` 但 `grade.json` 翻译文件不存在,**运行时会报 `MISSING_MESSAGE` 错误** | 同上 |
|
||||||
|
|
||||||
|
**后果**:
|
||||||
|
1. grades 和 diagnostic 模块完全无法国际化,所有用户可见文本固定为英文(部分中文混合),无法支持多语言。
|
||||||
|
2. grade-management 模块(年级管理,与成绩模块不同)调用未加载的 `grade` 命名空间,访问 `/management/grade/`、`/admin/school/grades/insights` 等页面会因找不到翻译键而**运行时报错**。
|
||||||
|
|
||||||
|
### 2.6 前端规范:Error Boundary 和 Suspense 缺失(P1)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `src/modules/grades/components/*`(16 个文件) | 全部无 Error Boundary | "每个独立的数据区块必须用 React Error Boundary 包裹" |
|
||||||
|
| `src/modules/diagnostic/components/*`(4 个文件) | 全部无 Error Boundary | 同上 |
|
||||||
|
| `src/modules/grades/components/*`(16 个文件) | 全部无 Suspense + 骨架屏 | "异步数据使用 React Suspense + 骨架屏" |
|
||||||
|
| `src/modules/diagnostic/components/*`(4 个文件) | 全部无 Suspense + 骨架屏 | 同上 |
|
||||||
|
| `src/app/(dashboard)/teacher/grades/` | **无 loading.tsx / error.tsx** | 路由级错误边界和加载态缺失 |
|
||||||
|
| `src/app/(dashboard)/teacher/diagnostic/` | **无 loading.tsx / error.tsx** | 同上 |
|
||||||
|
|
||||||
|
**后果**:单个组件抛错会导致整个页面崩溃;异步加载无骨架屏过渡,用户体验差(白屏等待)。
|
||||||
|
|
||||||
|
### 2.7 前端规范:a11y 无障碍缺失(P2)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `src/modules/grades/components/*`(15/16 个文件) | 无 ARIA 属性(仅 batch-grade-entry.tsx 有 `aria-hidden` 和 `aria-invalid`) | "可访问性(a11y):语义化标签、ARIA 属性、键盘导航" |
|
||||||
|
| `src/modules/diagnostic/components/*`(4 个文件) | 无 ARIA 属性 | 同上 |
|
||||||
|
| [grades/components/grade-record-list.tsx](file:///e:/Desktop/CICD/src/modules/grades/components/grade-record-list.tsx) L93-100 | 删除按钮无 `aria-label` | 同上 |
|
||||||
|
| [diagnostic/components/class-diagnostic-view.tsx](file:///e:/Desktop/CICD/src/modules/diagnostic/components/class-diagnostic-view.tsx) L128-139 | 热力图色块仅靠 `title` 属性,无 `role="img"` 和 `aria-label` | 同上 |
|
||||||
|
| [diagnostic/components/mastery-radar-chart.tsx](file:///e:/Desktop/CICD/src/modules/diagnostic/components/mastery-radar-chart.tsx) L38-66 | 雷达图无 `aria-label` / `role="img"` 描述 | 同上 |
|
||||||
|
| [diagnostic/components/report-list.tsx](file:///e:/Desktop/CICD/src/modules/diagnostic/components/report-list.tsx) L192-200, L202-210 | 发布/删除按钮仅 `title`,无 `aria-label` | 同上 |
|
||||||
|
|
||||||
|
**后果**:屏幕阅读器用户无法识别图表内容、按钮用途,不符合 WCAG 2.1 AA 标准。
|
||||||
|
|
||||||
|
### 2.8 TypeScript 规范:`as` 断言违规(P1)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [grades/components/batch-grade-entry.tsx](file:///e:/Desktop/CICD/src/modules/grades/components/batch-grade-entry.tsx) L221 | `remark: undefined as string \| undefined` | "禁止 `as` 断言(除非从 `unknown` 转换或测试中,需注释原因)" |
|
||||||
|
| [grades/components/batch-grade-entry.tsx](file:///e:/Desktop/CICD/src/modules/grades/components/batch-grade-entry.tsx) L312 | `setType(v as typeof type)` | 同上 |
|
||||||
|
| [grades/components/grade-record-form.tsx](file:///e:/Desktop/CICD/src/modules/grades/components/grade-record-form.tsx) L142 | `setType(v as typeof type)` | 同上 |
|
||||||
|
| [grades/components/grade-distribution-chart.tsx](file:///e:/Desktop/CICD/src/modules/grades/components/grade-distribution-chart.tsx) L66-67 | `payload as { payload?: {...} }`(从 unknown 转换但未使用类型守卫) | 同上 |
|
||||||
|
|
||||||
|
**后果**:`as` 断言绕过 TypeScript 类型检查,可能隐藏运行时类型错误。应使用类型守卫或 Zod 运行时校验。
|
||||||
|
|
||||||
|
### 2.9 Tailwind 规范:任意值违规(P2)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [diagnostic/components/class-diagnostic-view.tsx](file:///e:/Desktop/CICD/src/modules/diagnostic/components/class-diagnostic-view.tsx) L255 | `className="w-[180px]"` | "禁止使用任意值(`w-[137px]`),除非有充分理由并注释" |
|
||||||
|
| [diagnostic/components/mastery-radar-chart.tsx](file:///e:/Desktop/CICD/src/modules/diagnostic/components/mastery-radar-chart.tsx) L45 | `className="mx-auto h-[360px] w-full max-w-[520px]"` | 同上 |
|
||||||
|
|
||||||
|
**后果**:绕过设计令牌系统,无法统一调整尺寸主题。
|
||||||
|
|
||||||
|
### 2.10 数据模型缺陷:班级报告 studentId 字段语义错误(P2)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [diagnostic/data-access.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/data-access.ts) L111-114 | 班级报告 `studentId: generatedBy` 将生成者 ID 写入 studentId 字段 | "数据模型设计应语义清晰" |
|
||||||
|
| [diagnostic/data-access-reports.ts](file:///e:/Desktop/CICD/src/modules/diagnostic/data-access-reports.ts) L111 | 同上 | 同上 |
|
||||||
|
|
||||||
|
**后果**:`report-list.tsx` L178 显示 `r.studentName` 时,班级报告会显示生成者(教师)姓名而非学生姓名,存在数据语义错误。架构图 L1245 已标记此 P2 问题。
|
||||||
|
|
||||||
|
### 2.11 Server Action 规范:Zod 校验缺失(P1)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [grades/actions.ts](file:///e:/Desktop/CICD/src/modules/grades/actions.ts) L154-170 | `deleteGradeRecordAction` 无 Zod 校验(仅 id 字符串) | "输入使用 Zod 验证,验证失败返回结构化错误" |
|
||||||
|
| [grades/actions.ts](file:///e:/Desktop/CICD/src/modules/grades/actions.ts) L171-190 | `getGradeRecordsAction` 无 Zod 校验(使用 `GradeQueryParams` 类型) | 同上 |
|
||||||
|
| [grades/actions.ts](file:///e:/Desktop/CICD/src/modules/grades/actions.ts) L191-208 | `getClassGradeStatsAction` 无 Zod 校验 | 同上 |
|
||||||
|
| [grades/actions.ts](file:///e:/Desktop/CICD/src/modules/grades/actions.ts) L209-232 | `getStudentGradeSummaryAction` 无 Zod 校验 | 同上 |
|
||||||
|
| [grades/actions.ts](file:///e:/Desktop/CICD/src/modules/grades/actions.ts) L233-250 | `getClassRankingAction` 无 Zod 校验 | 同上 |
|
||||||
|
| [grades/actions.ts](file:///e:/Desktop/CICD/src/modules/grades/actions.ts) L251-269 | `getGradeRecordByIdAction` 无 Zod 校验 | 同上 |
|
||||||
|
| [grades/actions.ts](file:///e:/Desktop/CICD/src/modules/grades/actions.ts) L270-312 | `exportGradesAction` 无 Zod 校验(params 为内联对象类型) | 同上 |
|
||||||
|
| [grades/actions-analytics.ts](file:///e:/Desktop/CICD/src/modules/grades/actions-analytics.ts) L26-45 | `getGradeTrendAction` 无 Zod 校验 | 同上 |
|
||||||
|
| [grades/actions-analytics.ts](file:///e:/Desktop/CICD/src/modules/grades/actions-analytics.ts) L46-64 | `getClassComparisonAction` 无 Zod 校验 | 同上 |
|
||||||
|
| [grades/actions-analytics.ts](file:///e:/Desktop/CICD/src/modules/grades/actions-analytics.ts) L65-83 | `getSubjectComparisonAction` 无 Zod 校验 | 同上 |
|
||||||
|
| [grades/actions-analytics.ts](file:///e:/Desktop/CICD/src/modules/grades/actions-analytics.ts) L84-103 | `getGradeDistributionAction` 无 Zod 校验 | 同上 |
|
||||||
|
| [grades/actions-analytics.ts](file:///e:/Desktop/CICD/src/modules/grades/actions-analytics.ts) L104-133 | `getRankingTrendAction` 无 Zod 校验 | 同上 |
|
||||||
|
|
||||||
|
**后果**:12 个 Action 缺失 Zod 校验,客户端可传入任意类型参数,可能导致运行时错误或 SQL 注入风险。diagnostic 模块的 6 个 Action 全部使用 Zod 校验,是标杆范例。
|
||||||
|
|
||||||
|
### 2.12 业务逻辑漏洞:grade_managed scope 返回空数据(P2)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [grades/data-access.ts](file:///e:/Desktop/CICD/src/modules/grades/data-access.ts) L62-64 | `grade_managed` scope 返回 `sql\`1=0\``(始终无数据) | "权限过滤应正确反映角色数据范围" |
|
||||||
|
| [grades/data-access-analytics.ts](file:///e:/Desktop/CICD/src/modules/grades/data-access-analytics.ts) L39 | 同上 | 同上 |
|
||||||
|
|
||||||
|
**后果**:年级管理员(grade_managed scope)无法查看任何成绩数据,可能是业务逻辑漏洞。年级管理员应能查看所管年级的所有班级成绩。
|
||||||
|
|
||||||
|
### 2.13 路由缺陷:page.tsx 缺失(P1)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `src/app/(dashboard)/management/grade/page.tsx` | **文件缺失**,但有 loading.tsx/error.tsx 孤儿文件 | "路由页面应完整" |
|
||||||
|
|
||||||
|
**后果**:访问 `/management/grade` 会 404,但 loading.tsx 和 error.tsx 仍存在,造成混乱。
|
||||||
|
|
||||||
|
### 2.14 角色覆盖不一致:admin/parent 无 diagnostic UI(P2)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `005_architecture_data.json` L174-175, L214-215 | admin 和 parent 都有 `DIAGNOSTIC_MANAGE`/`DIAGNOSTIC_READ` 权限 | "权限点应有对应 UI" |
|
||||||
|
| `src/app/(dashboard)/admin/` | **无 diagnostic 页面** | 同上 |
|
||||||
|
| `src/app/(dashboard)/parent/` | **无 diagnostic 页面** | 同上 |
|
||||||
|
|
||||||
|
**后果**:admin 和 parent 拥有 diagnostic 权限但无对应 UI,权限与 UI 覆盖不一致。家长无法查看子女的学情诊断报告。
|
||||||
|
|
||||||
|
### 2.15 SearchParams 工具未统一(P3)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [student/grades/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/student/grades/page.tsx) | 自定义 `SearchParams` 类型和 `getParam` 函数 | "最大化复用" |
|
||||||
|
| [management/grade/insights/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/management/grade/insights/page.tsx) | 自定义 `SearchParams` 类型和 `getParam` 函数 | 同上 |
|
||||||
|
|
||||||
|
**后果**:与 teacher 端 grades 页面已复用 `@/shared/lib/search-params` 的做法不一致,存在重复代码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、行业差距对比
|
||||||
|
|
||||||
|
### 3.1 成绩模块(grades)行业对标
|
||||||
|
|
||||||
|
| 功能维度 | 行业优秀实践(K12 成绩管理系统) | 当前实现 | 差距影响 |
|
||||||
|
|----------|-------------------------------|----------|----------|
|
||||||
|
| **成绩录入** | 支持Excel批量导入、扫码录入、语音录入;录入时实时校验分数范围;自动计算总分、平均分 | 仅支持单条录入 + 批量录入(表单式);有分数范围校验;无 Excel 导入 | 教师录入效率低,大班级成绩录入耗时 |
|
||||||
|
| **成绩分析** | 多维度分析(班级/年级/个人/科目);支持自定义分析维度;提供归因分析(哪些题目失分多) | 5 种分析(趋势/班级对比/科目对比/分布/排名);无归因分析;无自定义维度 | 教师无法定位失分原因,难以针对性教学 |
|
||||||
|
| **可视化** | 交互式图表(hover 显示详情、点击下钻);支持图表下载为图片;支持自定义图表配置 | 静态图表(TrendLineChart/SimpleBarChart);无 hover 详情;无下载功能 | 数据呈现不够直观,教师难以深入分析 |
|
||||||
|
| **报告导出** | 支持 PDF/Excel/CSV 多格式;支持自定义报告模板;支持批量导出(按班级/年级) | 仅 Excel 导出(明细 + 班级汇总);无 PDF;无自定义模板 | 无法满足学校正式报告需求(如家长会报告需 PDF) |
|
||||||
|
| **预警机制** | 成绩异常预警(突然下降/持续低迷);及格率预警;班级对比异常预警 | 无预警机制 | 教师无法及时发现学生成绩异常 |
|
||||||
|
| **多角色视图** | 学生看自己 + 班级平均;家长看子女 + 班级排名;教师看所教班级;管理员看全校 | 4 角色都有基本视图;但 parent 无 diagnostic;admin 无 diagnostic | 家长无法全面了解子女学情 |
|
||||||
|
| **空状态/加载态** | 完善的空状态插画 + 引导操作;骨架屏过渡 | 仅部分页面有 loading.tsx;组件无 Suspense | 用户体验差,白屏等待 |
|
||||||
|
| **数据联动** | 成绩 → 学情诊断 → 推荐练习;成绩 → 作业 → 知识点掌握度 | grades 与 diagnostic 无数据联动;无推荐练习 | 无法形成"诊断-练习-反馈"闭环 |
|
||||||
|
|
||||||
|
### 3.2 学情诊断模块(diagnostic)行业对标
|
||||||
|
|
||||||
|
| 功能维度 | 行业优秀实践(K12 学情诊断系统) | 当前实现 | 差距影响 |
|
||||||
|
|----------|-------------------------------|----------|----------|
|
||||||
|
| **知识点掌握度** | 基于IRT(项目反应理论)计算;支持知识点权重;支持时间衰减(近期表现权重更高) | 基于正确率简单计算;无权重;无时间衰减 | 掌握度计算不够精准 |
|
||||||
|
| **诊断报告** | 自动生成 PDF 报告;支持自定义模板;含学习建议、练习推荐、进步轨迹 | 生成 draft 报告(JSON 存储);无 PDF;建议为静态文本 | 报告不够专业,无法直接发给家长 |
|
||||||
|
| **可视化** | 雷达图 + 热力图 + 知识图谱;支持知识点下钻;支持时间对比 | 雷达图 + 热力图;无知识图谱;无下钻 | 知识结构呈现不够清晰 |
|
||||||
|
| **个性化推荐** | 基于弱项推荐练习题/微课;支持难度自适应;支持学习路径规划 | 仅列出弱项知识点 + "Practice" 链接(跳转到作业列表) | 无法精准推荐练习内容 |
|
||||||
|
| **班级诊断** | 班级整体掌握度 + 重点关注学生列表 + 教学建议;支持按知识点筛选学生 | 班级掌握度摘要 + 需关注学生列表;无教学建议 | 教师难以根据诊断调整教学 |
|
||||||
|
| **历史趋势** | 掌握度随时间变化曲线;支持对比多个时间段 | 无历史趋势(仅当前快照) | 无法评估学习进步情况 |
|
||||||
|
| **多角色覆盖** | 学生/家长/教师/管理员都能查看;家长看子女诊断报告 | 仅 teacher + student 有 UI;parent/admin 无 UI | 家长无法了解子女学情 |
|
||||||
|
|
||||||
|
### 3.3 关键差距总结
|
||||||
|
|
||||||
|
1. **数据孤岛**:grades 和 diagnostic 模块无数据联动,无法形成"成绩 → 诊断 → 练习 → 反馈"闭环。行业优秀产品(如猿题库、作业帮)已实现完整学习闭环。
|
||||||
|
2. **家长端缺失**:parent 无 diagnostic UI,家长无法查看子女学情诊断报告。K12 场景下家长是重要决策者,缺失影响家校沟通。
|
||||||
|
3. **报告专业度不足**:diagnostic 报告为 JSON 存储,无 PDF 导出,无法直接用于家长会。行业产品普遍支持专业 PDF 报告。
|
||||||
|
4. **预警机制空白**:成绩异常、掌握度低迷无预警,教师无法主动干预。
|
||||||
|
5. **可视化深度不足**:无知识图谱、无下钻分析、无时间对比,数据呈现停留在表层。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、改进优先级建议
|
||||||
|
|
||||||
|
### P0(紧急 — 安全与合规)
|
||||||
|
|
||||||
|
| # | 问题 | 改进方向 |
|
||||||
|
|---|------|----------|
|
||||||
|
| P0-1 | 权限校验缺失(10 个页面) | 所有页面调用 `requirePermission()`:teacher/grades 用 `GRADE_RECORD_READ`/`GRADE_RECORD_MANAGE`,teacher/diagnostic 用 `DIAGNOSTIC_READ`/`DIAGNOSTIC_MANAGE`,student/parent 用对应 READ 权限 |
|
||||||
|
| P0-2 | diagnostic/data-access-reports.ts 直查 users 表 | 改为调用 `@/modules/users/data-access` 的 `getUserNamesByIds`,删除 `users` 表 import |
|
||||||
|
| P0-3 | i18n 完全缺失 + grade-management 运行时报错 | 创建 `grades.json` 和 `diagnostic.json` 翻译文件(zh-CN + en);修复 `grade-management` 模块的 `grade` 命名空间(创建 `grade.json` 或改用 `gradeManagement`);在 `i18n/request.ts` 注册新命名空间 |
|
||||||
|
| P0-4 | `/management/grade/page.tsx` 缺失 | 补齐 page.tsx 或删除孤儿 loading.tsx/error.tsx |
|
||||||
|
|
||||||
|
### P1(较严重 — 架构与质量)
|
||||||
|
|
||||||
|
| # | 问题 | 改进方向 |
|
||||||
|
|---|------|----------|
|
||||||
|
| 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-3 | 12 个 Action 缺失 Zod 校验 | 为 `deleteGradeRecordAction`/`getGradeRecordsAction`/`getClassGradeStatsAction`/`getStudentGradeSummaryAction`/`getClassRankingAction`/`getGradeRecordByIdAction`/`exportGradesAction` + 5 个 analytics Action 创建对应 Zod schema |
|
||||||
|
| 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-6 | 架构图同步 | 更新 `004` 和 `005` 文档:grades 行数、diagnostic deps、新增 stats-service.ts、新增 lib/、补齐 actions-analytics exports |
|
||||||
|
|
||||||
|
### P2(优化 — 体验与扩展)
|
||||||
|
|
||||||
|
| # | 问题 | 改进方向 |
|
||||||
|
|---|------|----------|
|
||||||
|
| P2-1 | a11y 无障碍缺失 | 补充 ARIA 属性:图表 `role="img"` + `aria-label`;按钮 `aria-label`;表格 `caption`;列表 `role="list"` |
|
||||||
|
| P2-2 | Tailwind 任意值 | 移除 `w-[180px]`/`h-[360px]`/`max-w-[520px]`,改用设计令牌或注释说明 |
|
||||||
|
| P2-3 | 班级报告 studentId 字段语义错误 | 修改 `learningDiagnosticReports` schema,将 `studentId` 改为可空,或增加 `classId`/`generatedBy` 字段 |
|
||||||
|
| P2-4 | grade_managed scope 返回空数据 | 修复 `buildScopeClassFilter`,grade_managed scope 应返回所管年级的班级过滤条件 |
|
||||||
|
| P2-5 | admin/parent 无 diagnostic UI | 新增 `/parent/diagnostic/` 页面(家长查看子女诊断报告);admin 可复用 teacher 视图 |
|
||||||
|
| P2-6 | SearchParams 工具未统一 | student/grades 和 management/grade/insights 改用 `@/shared/lib/search-params` |
|
||||||
|
|
||||||
|
### P3(长期 — 行业对标)
|
||||||
|
|
||||||
|
| # | 问题 | 改进方向 |
|
||||||
|
|---|------|----------|
|
||||||
|
| P3-1 | grades 与 diagnostic 无数据联动 | 设计联动接口:成绩录入后触发掌握度更新;诊断报告含成绩趋势 |
|
||||||
|
| P3-2 | 无预警机制 | 新增 `grades/alerts-service.ts`:成绩下降预警、及格率预警、掌握度低迷预警 |
|
||||||
|
| P3-3 | 诊断报告无 PDF 导出 | 集成 PDF 生成库(如 @react-pdf/renderer),支持专业报告模板 |
|
||||||
|
| P3-4 | 无知识图谱可视化 | 引入知识图谱组件(如 react-flow),展示知识点关系与掌握度 |
|
||||||
|
| P3-5 | 无个性化练习推荐 | 基于弱项推荐练习题,对接 questions 模块 |
|
||||||
|
| P3-6 | Widget 配置系统 | 设计 `GradesWidgetConfig`/`DiagnosticWidgetConfig` 类型,按角色配置渲染哪些 Widget |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、架构图同步说明
|
||||||
|
|
||||||
|
本次审计发现架构图存在以下遗漏或不一致,需在实现后同步更新:
|
||||||
|
|
||||||
|
### 5.1 `004_architecture_impact_map.md` 需补充
|
||||||
|
|
||||||
|
1. **§2.6 grades 模块**:
|
||||||
|
- 更新文件清单行数:`data-access.ts` 419→433、`data-access-analytics.ts` 293→337
|
||||||
|
- 补充 `actions-analytics.ts` 的 5 个 Action 到 exports 清单(当前仅列 11 个,实际 15 个)
|
||||||
|
- 新增 `stats-service.ts`(P1-1 抽取后)
|
||||||
|
- 新增 `lib/scope-filter.ts`、`lib/stats-utils.ts`(P1-2 抽取后)
|
||||||
|
- 新增 `components/widget-boundary.tsx`(P1-5 新增)
|
||||||
|
|
||||||
|
2. **§2.22 diagnostic 模块**:
|
||||||
|
- 更新已知问题:标注 `data-access-reports.ts` 直查 users 表(P0-2 修复前)
|
||||||
|
- 更新文件清单行数(如有变化)
|
||||||
|
|
||||||
|
3. **路由清单**:
|
||||||
|
- 标注 `/management/grade/page.tsx` 缺失(P0-4 修复前)
|
||||||
|
- 标注 teacher/grades 和 teacher/diagnostic 路由缺少 loading.tsx/error.tsx
|
||||||
|
- 新增 `/parent/diagnostic/` 路由(P2-5 实现后)
|
||||||
|
|
||||||
|
### 5.2 `005_architecture_data.json` 需修改
|
||||||
|
|
||||||
|
1. `modules.grades` 节点(L7362):
|
||||||
|
- 更新 `dataAccess` 中各函数的 `deps`:移除直查 `classes`/`classEnrollments`/`subjects`/`users`,改为 `classes/data-access.*`/`school/data-access.*`/`users/data-access.*`
|
||||||
|
- 新增 `stats-service.ts` 的 exports
|
||||||
|
- 新增 `lib/scope-filter.ts`、`lib/stats-utils.ts` 的 exports
|
||||||
|
- 补充 `actions-analytics.ts` 的 5 个 Action 到 `actions` 数组
|
||||||
|
|
||||||
|
2. `modules.diagnostic` 节点(L10927):
|
||||||
|
- 更新 `dataAccess` 中各函数的 `deps`:移除直查 `users`/`classes`/`classEnrollments`/`examSubmissions`/`submissionAnswers`/`questionsToKnowledgePoints`,改为对应模块 data-access
|
||||||
|
- 标注 `data-access-reports.ts` 的 `getDiagnosticReports`/`getDiagnosticReportById` 依赖 `users/data-access.getUserNamesByIds`(P0-2 修复后)
|
||||||
|
|
||||||
|
3. `permissions` 节点:
|
||||||
|
- 确认 `GRADE_RECORD_READ`/`GRADE_RECORD_MANAGE`/`DIAGNOSTIC_READ`/`DIAGNOSTIC_MANAGE` 权限点已定义(已存在 ✓)
|
||||||
|
|
||||||
|
4. `routes` 节点:
|
||||||
|
- 补充 teacher/grades/entry、teacher/grades/stats、teacher/diagnostic/class/[classId]、teacher/diagnostic/student/[studentId] 路由
|
||||||
|
- 标注 `/management/grade/page.tsx` 缺失
|
||||||
|
- 新增 `/parent/diagnostic/` 路由(P2-5 实现后)
|
||||||
|
|
||||||
|
5. `dependencyMatrix`:
|
||||||
|
- 更新 grades → classes/school/users 的依赖关系(通过 data-access,已正确)
|
||||||
|
- 更新 diagnostic → classes/exams/questions/users 的依赖关系(通过 data-access,P0-2 修复后完全正确)
|
||||||
|
|
||||||
|
### 5.3 翻译文件结构示例
|
||||||
|
|
||||||
|
```
|
||||||
|
src/shared/i18n/messages/
|
||||||
|
├─ zh-CN/
|
||||||
|
│ ├─ grades.json # 新增(成绩模块)
|
||||||
|
│ ├─ diagnostic.json # 新增(学情诊断模块)
|
||||||
|
│ └─ grade.json # 新增(grade-management 模块,修复运行时报错)
|
||||||
|
└─ en/
|
||||||
|
├─ grades.json # 新增
|
||||||
|
├─ diagnostic.json # 新增
|
||||||
|
└─ grade.json # 新增
|
||||||
|
```
|
||||||
|
|
||||||
|
`grades.json` 结构示例(zh-CN):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": {
|
||||||
|
"list": "成绩查询",
|
||||||
|
"entry": "成绩录入",
|
||||||
|
"analytics": "成绩分析",
|
||||||
|
"stats": "成绩统计"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"class": "班级",
|
||||||
|
"subject": "科目",
|
||||||
|
"type": "类型",
|
||||||
|
"semester": "学期",
|
||||||
|
"allClasses": "全部班级",
|
||||||
|
"allSubjects": "全部科目",
|
||||||
|
"allTypes": "全部类型",
|
||||||
|
"allSemesters": "全部学期",
|
||||||
|
"searchPlaceholder": "按标题搜索..."
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"exam": "考试",
|
||||||
|
"quiz": "测验",
|
||||||
|
"homework": "作业",
|
||||||
|
"other": "其他"
|
||||||
|
},
|
||||||
|
"semester": {
|
||||||
|
"s1": "第一学期",
|
||||||
|
"s2": "第二学期"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"empty": "暂无成绩记录",
|
||||||
|
"columns": {
|
||||||
|
"student": "学生",
|
||||||
|
"class": "班级",
|
||||||
|
"subject": "科目",
|
||||||
|
"title": "标题",
|
||||||
|
"score": "分数",
|
||||||
|
"type": "类型",
|
||||||
|
"semester": "学期",
|
||||||
|
"recordedBy": "录入人",
|
||||||
|
"date": "日期"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"title": "录入成绩",
|
||||||
|
"save": "保存",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"cancel": "取消",
|
||||||
|
"selectClass": "选择班级",
|
||||||
|
"selectSubject": "选择科目",
|
||||||
|
"selectStudent": "选择学生",
|
||||||
|
"titlePlaceholder": "如期中考试",
|
||||||
|
"score": "分数",
|
||||||
|
"fullScore": "满分",
|
||||||
|
"remark": "备注(可选)",
|
||||||
|
"remarkPlaceholder": "关于此成绩的备注..."
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"title": "删除成绩记录",
|
||||||
|
"confirmation": "确定要删除此成绩记录吗?此操作不可撤销。",
|
||||||
|
"confirm": "删除",
|
||||||
|
"cancel": "取消",
|
||||||
|
"deleting": "删除中..."
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"detail": "导出成绩明细",
|
||||||
|
"classReport": "导出班级成绩总表",
|
||||||
|
"success": "导出成功",
|
||||||
|
"failed": "导出失败"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"title": "统计",
|
||||||
|
"average": "平均分",
|
||||||
|
"median": "中位数",
|
||||||
|
"max": "最高分",
|
||||||
|
"min": "最低分",
|
||||||
|
"stdDev": "标准差",
|
||||||
|
"variance": "方差",
|
||||||
|
"passRate": "及格率",
|
||||||
|
"excellentRate": "优秀率",
|
||||||
|
"count": "人数"
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"trend": "成绩趋势",
|
||||||
|
"classComparison": "班级对比",
|
||||||
|
"subjectComparison": "科目对比",
|
||||||
|
"distribution": "分数分布",
|
||||||
|
"ranking": "排名",
|
||||||
|
"rankingTrend": "排名趋势"
|
||||||
|
},
|
||||||
|
"batch": {
|
||||||
|
"title": "批量录入",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"restored": "已恢复未保存的成绩草稿",
|
||||||
|
"invalidScores": "存在无效分数",
|
||||||
|
"fullScoreRequired": "满分必填",
|
||||||
|
"saved": "已录入"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"noRecords": "暂无成绩记录",
|
||||||
|
"noData": "暂无数据"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"loadFailed": "加载失败",
|
||||||
|
"saveFailed": "保存失败",
|
||||||
|
"deleteFailed": "删除失败",
|
||||||
|
"retry": "重试"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`diagnostic.json` 结构示例(zh-CN):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": {
|
||||||
|
"student": "学生学情诊断",
|
||||||
|
"class": "班级学情诊断",
|
||||||
|
"reportList": "诊断报告"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"individual": "个人",
|
||||||
|
"class": "班级",
|
||||||
|
"grade": "年级"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"draft": "草稿",
|
||||||
|
"published": "已发布",
|
||||||
|
"archived": "已归档"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"reportType": "报告类型",
|
||||||
|
"status": "状态",
|
||||||
|
"allTypes": "全部类型",
|
||||||
|
"allStatuses": "全部状态"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"overallMastery": "总体掌握度",
|
||||||
|
"strengths": "强项",
|
||||||
|
"weaknesses": "弱项",
|
||||||
|
"students": "学生数",
|
||||||
|
"avgMastery": "平均掌握度",
|
||||||
|
"needAttention": "需重点关注"
|
||||||
|
},
|
||||||
|
"chart": {
|
||||||
|
"radarTitle": "知识点掌握度",
|
||||||
|
"radarDescription": "掌握度雷达图",
|
||||||
|
"heatmapTitle": "知识点掌握度热力图",
|
||||||
|
"rankingTitle": "知识点排名"
|
||||||
|
},
|
||||||
|
"report": {
|
||||||
|
"generate": "生成诊断报告",
|
||||||
|
"generateStudent": "生成学生诊断报告",
|
||||||
|
"generateClass": "生成班级诊断报告",
|
||||||
|
"publish": "发布",
|
||||||
|
"delete": "删除",
|
||||||
|
"publishTitle": "发布报告",
|
||||||
|
"deleteTitle": "删除报告",
|
||||||
|
"recommendations": "学习建议",
|
||||||
|
"history": "报告历史"
|
||||||
|
},
|
||||||
|
"strengths": {
|
||||||
|
"title": "强项(≥80%)",
|
||||||
|
"practice": "练习"
|
||||||
|
},
|
||||||
|
"weaknesses": {
|
||||||
|
"title": "弱项(<60%)",
|
||||||
|
"practice": "练习"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"noData": "暂无诊断数据",
|
||||||
|
"noClassData": "无法加载班级掌握度摘要",
|
||||||
|
"noMastery": "暂无知识点掌握度记录",
|
||||||
|
"noReports": "暂无诊断报告"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"generateFailed": "生成报告失败",
|
||||||
|
"publishFailed": "发布失败",
|
||||||
|
"deleteFailed": "删除失败",
|
||||||
|
"loadFailed": "加载失败",
|
||||||
|
"retry": "重试"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、合规项确认
|
||||||
|
|
||||||
|
以下条目**已通过审计**:
|
||||||
|
|
||||||
|
- ✅ **grades 模块跨模块依赖全部通过 data-access**:所有跨模块访问(classes/school/users)均通过对方 data-access 函数
|
||||||
|
- ✅ **diagnostic 模块 data-access.ts 跨模块依赖通过 data-access**(仅 data-access-reports.ts 违规)
|
||||||
|
- ✅ **所有 Server Action 调用 `requirePermission()`**:grades 15 个 + diagnostic 6 个 = 21 个 Action 全部合规
|
||||||
|
- ✅ **所有 Server Action 返回 `ActionState<T>`**
|
||||||
|
- ✅ **所有 Server Action 使用 `revalidatePath` 精确刷新**
|
||||||
|
- ✅ **无 `role === "xxx"` 硬编码**:全模块无
|
||||||
|
- ✅ **diagnostic 组件使用 `usePermission().hasPermission()`**:class-diagnostic-view.tsx 和 report-list.tsx 已使用
|
||||||
|
- ✅ **无 `dangerouslySetInnerHTML`**
|
||||||
|
- ✅ **无 `any` 类型**
|
||||||
|
- ✅ **文件行数全部合规**:最大为 grades/components/batch-grade-entry.tsx 442 行 < 500 行组件建议上限
|
||||||
|
- ✅ **`"use client"` / `"use server"` / `"server-only"` 正确放置**
|
||||||
|
- ✅ **`import type` 使用规范**
|
||||||
|
- ✅ **diagnostic schema.ts 枚举与 types.ts 联合类型一致**
|
||||||
|
- ✅ **接口命名规范**(无 I 前缀,PascalCase)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、重构方案设计要点(供后续实现参考)
|
||||||
|
|
||||||
|
### 7.1 完全解耦
|
||||||
|
|
||||||
|
- 定义 `GradesDataService` 接口抽象数据依赖,使用 React Context 注入
|
||||||
|
- 模块内部组件绝不直接 import 其他业务模块的 actions 或 data-access
|
||||||
|
- 不同角色差异通过接口不同实现隔离(如 `TeacherGradesService`/`StudentGradesService`/`ParentGradesService`)
|
||||||
|
|
||||||
|
### 7.2 组合优先
|
||||||
|
|
||||||
|
- 所有 UI 通过组件组合(children、slots、render props)实现灵活性
|
||||||
|
- 逻辑复用抽取为自定义 hooks(如 `useGradeRecords`/`useGradeTrend`/`useMasterySummary`)
|
||||||
|
- 严禁继承或深层嵌套 HOC
|
||||||
|
|
||||||
|
### 7.3 最大化复用
|
||||||
|
|
||||||
|
- 识别四角色共用 UI 块:`GradeTrendChart`/`GradeStatsCard`/`MasteryRadarChart`/`WidgetBoundary`
|
||||||
|
- 抽象泛型组件:`<DataTable<T>>`/`<FilterBar>`/`<EmptyState>`/`<ErrorState>`
|
||||||
|
- 各角色模块仅组合复用单元,可配置化显示内容
|
||||||
|
|
||||||
|
### 7.4 配置驱动
|
||||||
|
|
||||||
|
- 设计 `GradesWidgetConfig` 类型,按角色配置渲染哪些 Widget
|
||||||
|
- 示例:teacher 看 [录入, 查询, 分析, 统计],student 看 [我的成绩, 趋势],parent 看 [子女成绩, 趋势]
|
||||||
|
|
||||||
|
### 7.5 错误与边界处理
|
||||||
|
|
||||||
|
- 每个独立数据区块用 `<WidgetBoundary>`(Error Boundary + Suspense + Skeleton 组合)包裹
|
||||||
|
- 明确处理空数据、无权限、网络异常等边界状态
|
||||||
|
- 支持流式渲染(React Server Components 获取初始数据)
|
||||||
|
|
||||||
|
### 7.6 可测试性
|
||||||
|
|
||||||
|
- 数据获取、计算、格式化等纯逻辑放入 `stats-service.ts` 或 hooks
|
||||||
|
- 导出清晰接口类型以便 mock
|
||||||
|
- 统计函数为纯函数,易于单测
|
||||||
|
|
||||||
|
### 7.7 监控埋点
|
||||||
|
|
||||||
|
- 预留关键操作埋点接口:成绩录入、报告生成、报告发布、导出操作
|
||||||
|
- 通过 `shared/lib/analytics` 统一上报
|
||||||
11
src/app/(dashboard)/management/grade/page.tsx
Normal file
11
src/app/(dashboard)/management/grade/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function GradeManagementPage(): Promise<void> {
|
||||||
|
await requirePermission(Permissions.GRADE_MANAGE)
|
||||||
|
redirect("/management/grade/classes")
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import { Stethoscope } from "lucide-react"
|
import { Stethoscope } from "lucide-react"
|
||||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { getClassMasterySummary } from "@/modules/diagnostic/data-access"
|
import { getClassMasterySummary } from "@/modules/diagnostic/data-access"
|
||||||
import { ClassDiagnosticView } from "@/modules/diagnostic/components/class-diagnostic-view"
|
import { ClassDiagnosticView } from "@/modules/diagnostic/components/class-diagnostic-view"
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ export default async function ClassDiagnosticPage({
|
|||||||
params: Promise<{ classId: string }>
|
params: Promise<{ classId: string }>
|
||||||
}): Promise<JSX.Element> {
|
}): Promise<JSX.Element> {
|
||||||
const { classId } = await params
|
const { classId } = await params
|
||||||
const ctx = await getAuthContext()
|
const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ)
|
||||||
|
|
||||||
// DataScope 校验:教师只能查看所教班级,学生/家长不可访问
|
// DataScope 校验:教师只能查看所教班级,学生/家长不可访问
|
||||||
if (ctx.dataScope.type === "class_taught" && !ctx.dataScope.classIds.includes(classId)) {
|
if (ctx.dataScope.type === "class_taught" && !ctx.dataScope.classIds.includes(classId)) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||||
import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports"
|
import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports"
|
||||||
import { ReportList } from "@/modules/diagnostic/components/report-list"
|
import { ReportList } from "@/modules/diagnostic/components/report-list"
|
||||||
@@ -33,7 +34,7 @@ export default async function TeacherDiagnosticPage({
|
|||||||
searchParams: Promise<SearchParams>
|
searchParams: Promise<SearchParams>
|
||||||
}): Promise<JSX.Element> {
|
}): Promise<JSX.Element> {
|
||||||
const sp = await searchParams
|
const sp = await searchParams
|
||||||
const ctx = await getAuthContext()
|
const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ)
|
||||||
|
|
||||||
const reportType = getParam(sp, "reportType")
|
const reportType = getParam(sp, "reportType")
|
||||||
const status = getParam(sp, "status")
|
const status = getParam(sp, "status")
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import { Stethoscope } from "lucide-react"
|
import { Stethoscope } from "lucide-react"
|
||||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import {
|
import {
|
||||||
getStudentMasterySummary,
|
getStudentMasterySummary,
|
||||||
getKnowledgePointStats,
|
getKnowledgePointStats,
|
||||||
@@ -18,7 +19,7 @@ export default async function StudentDiagnosticPage({
|
|||||||
params: Promise<{ studentId: string }>
|
params: Promise<{ studentId: string }>
|
||||||
}): Promise<JSX.Element> {
|
}): Promise<JSX.Element> {
|
||||||
const { studentId } = await params
|
const { studentId } = await params
|
||||||
const ctx = await getAuthContext()
|
const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ)
|
||||||
|
|
||||||
// DataScope 二次校验:学生只能看自己,家长只能看子女
|
// DataScope 二次校验:学生只能看自己,家长只能看子女
|
||||||
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
|
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { BarChart3, ArrowLeft } from "lucide-react"
|
|||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||||
import { getGrades } from "@/modules/school/data-access"
|
import { getGrades } from "@/modules/school/data-access"
|
||||||
@@ -30,7 +31,7 @@ export default async function GradeAnalyticsPage({
|
|||||||
searchParams: Promise<SearchParams>
|
searchParams: Promise<SearchParams>
|
||||||
}): Promise<JSX.Element> {
|
}): Promise<JSX.Element> {
|
||||||
const sp = await searchParams
|
const sp = await searchParams
|
||||||
const ctx = await getAuthContext()
|
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||||
|
|
||||||
const classId = getParam(sp, "classId")
|
const classId = getParam(sp, "classId")
|
||||||
const subjectId = getParam(sp, "subjectId")
|
const subjectId = getParam(sp, "subjectId")
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import Link from "next/link"
|
|||||||
import { PlusCircle, BarChart3, ClipboardList } from "lucide-react"
|
import { PlusCircle, BarChart3, ClipboardList } from "lucide-react"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
import { ListPagination, computePagination, paginate } from "@/shared/components/ui/list-pagination"
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||||
import { getGradeRecords } from "@/modules/grades/data-access"
|
import { getGradeRecords } from "@/modules/grades/data-access"
|
||||||
@@ -26,13 +28,15 @@ function parseSemester(v?: string): GradeRecordSemester | undefined {
|
|||||||
return v && VALID_SEMESTERS.has(v) ? (v as GradeRecordSemester) : undefined
|
return v && VALID_SEMESTERS.has(v) ? (v as GradeRecordSemester) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
export default async function TeacherGradesPage({
|
export default async function TeacherGradesPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<SearchParams>
|
searchParams: Promise<SearchParams>
|
||||||
}): Promise<JSX.Element> {
|
}): Promise<JSX.Element> {
|
||||||
const sp = await searchParams
|
const sp = await searchParams
|
||||||
const ctx = await getAuthContext()
|
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||||
|
|
||||||
const classId = getParam(sp, "classId")
|
const classId = getParam(sp, "classId")
|
||||||
const subjectId = getParam(sp, "subjectId")
|
const subjectId = getParam(sp, "subjectId")
|
||||||
@@ -55,24 +59,32 @@ export default async function TeacherGradesPage({
|
|||||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
||||||
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
|
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
|
||||||
|
|
||||||
|
// 分页计算
|
||||||
|
const { page } = computePagination(sp, PAGE_SIZE)
|
||||||
|
const total = records.length
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
const currentPage = Math.min(page, totalPages)
|
||||||
|
const pagedRecords = paginate(records, currentPage, PAGE_SIZE)
|
||||||
|
const hasFilters = Boolean(classId || subjectId || type || semester)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
<div className="flex items-center justify-between space-y-2">
|
<div className="flex items-center justify-between space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Grades</h1>
|
<h1 className="text-2xl font-bold tracking-tight">成绩记录</h1>
|
||||||
<p className="text-muted-foreground">Manage student grade records.</p>
|
<p className="text-muted-foreground">管理学生成绩记录。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href="/teacher/grades/stats">
|
<Link href="/teacher/grades/stats">
|
||||||
<BarChart3 className="mr-2 h-4 w-4" aria-hidden="true" />
|
<BarChart3 className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
Statistics
|
统计
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href="/teacher/grades/entry">
|
<Link href="/teacher/grades/entry">
|
||||||
<ClipboardList className="mr-2 h-4 w-4" aria-hidden="true" />
|
<ClipboardList className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
Batch Entry
|
批量录入
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<ExportButton
|
<ExportButton
|
||||||
@@ -83,7 +95,7 @@ export default async function TeacherGradesPage({
|
|||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href="/teacher/grades/entry">
|
<Link href="/teacher/grades/entry">
|
||||||
<PlusCircle className="mr-2 h-4 w-4" aria-hidden="true" />
|
<PlusCircle className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
Record Grades
|
录入成绩
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,18 +103,31 @@ export default async function TeacherGradesPage({
|
|||||||
|
|
||||||
<GradeQueryFilters classes={classOptions} subjects={subjectOptions} />
|
<GradeQueryFilters classes={classOptions} subjects={subjectOptions} />
|
||||||
|
|
||||||
{records.length === 0 && !classId && !subjectId ? (
|
{records.length === 0 && !hasFilters ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No grade records"
|
title="暂无成绩记录"
|
||||||
description="Start by recording grades for your classes."
|
description="开始为您的班级录入成绩。"
|
||||||
icon={ClipboardList}
|
icon={ClipboardList}
|
||||||
action={{
|
action={{
|
||||||
label: "Record Grades",
|
label: "录入成绩",
|
||||||
href: "/teacher/grades/entry",
|
href: "/teacher/grades/entry",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<GradeRecordList records={records} />
|
<div className="space-y-4">
|
||||||
|
<GradeRecordList records={pagedRecords} />
|
||||||
|
{total > 0 ? (
|
||||||
|
<ListPagination
|
||||||
|
page={currentPage}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
total={total}
|
||||||
|
totalPages={totalPages}
|
||||||
|
basePath="/teacher/grades"
|
||||||
|
searchParams={sp}
|
||||||
|
itemLabel="条记录"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
|||||||
className={`flex flex-col items-center justify-center rounded-md px-3 py-2 text-white ${masteryColor(kp.averageMastery)}`}
|
className={`flex flex-col items-center justify-center rounded-md px-3 py-2 text-white ${masteryColor(kp.averageMastery)}`}
|
||||||
title={`${kp.knowledgePointName}: ${kp.averageMastery.toFixed(1)}% (mastered ${kp.masteredCount}/${kp.totalStudents})`}
|
title={`${kp.knowledgePointName}: ${kp.averageMastery.toFixed(1)}% (mastered ${kp.masteredCount}/${kp.totalStudents})`}
|
||||||
>
|
>
|
||||||
<span className="max-w-[120px] truncate text-xs font-medium">
|
<span className="max-w-32 truncate text-xs font-medium">
|
||||||
{kp.knowledgePointName}
|
{kp.knowledgePointName}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-bold">{kp.averageMastery.toFixed(0)}%</span>
|
<span className="text-sm font-bold">{kp.averageMastery.toFixed(0)}%</span>
|
||||||
@@ -252,7 +252,7 @@ export function ClassDiagnosticView({ summary }: ClassDiagnosticViewProps) {
|
|||||||
type="month"
|
type="month"
|
||||||
value={period}
|
value={period}
|
||||||
onChange={(e) => setPeriod(e.target.value)}
|
onChange={(e) => setPeriod(e.target.value)}
|
||||||
className="w-[180px]"
|
className="w-44"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleGenerate} disabled={isGenerating}>
|
<Button onClick={handleGenerate} disabled={isGenerating}>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
|
|||||||
domain={[0, 100]}
|
domain={[0, 100]}
|
||||||
tickCount={5}
|
tickCount={5}
|
||||||
showLegend={hasClassAverage}
|
showLegend={hasClassAverage}
|
||||||
heightClassName="mx-auto h-[360px] w-full max-w-[520px]"
|
heightClassName="mx-auto h-96 w-full max-w-lg"
|
||||||
gridStrokeDasharray="4 4"
|
gridStrokeDasharray="4 4"
|
||||||
series={[
|
series={[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
|
|||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{r.period ?? "Untitled period"}
|
{r.period ?? "Untitled period"}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant="outline" className="text-[10px]">
|
<Badge variant="outline" className="text-xs">
|
||||||
{r.reportType}
|
{r.reportType}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import "server-only"
|
import "server-only"
|
||||||
|
|
||||||
import { createId } from "@paralleldrive/cuid2"
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
import { and, desc, eq, inArray, type SQL } from "drizzle-orm"
|
import { and, desc, eq, type SQL } from "drizzle-orm"
|
||||||
import { cache } from "react"
|
import { cache } from "react"
|
||||||
|
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import { learningDiagnosticReports, users } from "@/shared/db/schema"
|
import { learningDiagnosticReports } from "@/shared/db/schema"
|
||||||
|
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||||
|
|
||||||
import { getClassMasterySummary, getStudentMasterySummary } from "./data-access"
|
import { getClassMasterySummary, getStudentMasterySummary } from "./data-access"
|
||||||
import type {
|
import type {
|
||||||
@@ -132,31 +133,25 @@ export const getDiagnosticReports = cache(
|
|||||||
if (filters.period) conditions.push(eq(learningDiagnosticReports.period, filters.period))
|
if (filters.period) conditions.push(eq(learningDiagnosticReports.period, filters.period))
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({ report: learningDiagnosticReports })
|
||||||
report: learningDiagnosticReports,
|
|
||||||
studentName: users.name,
|
|
||||||
})
|
|
||||||
.from(learningDiagnosticReports)
|
.from(learningDiagnosticReports)
|
||||||
.leftJoin(users, eq(users.id, learningDiagnosticReports.studentId))
|
|
||||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||||
.orderBy(desc(learningDiagnosticReports.createdAt))
|
.orderBy(desc(learningDiagnosticReports.createdAt))
|
||||||
|
|
||||||
const generatorIds = Array.from(
|
// 收集所有需要查询姓名的用户 ID(学生 + 生成者),通过 users data-access 统一获取
|
||||||
new Set(rows.map((r) => r.report.generatedBy).filter((id): id is string => id !== null))
|
const userIds = new Set<string>()
|
||||||
)
|
for (const r of rows) {
|
||||||
const generatorMap = new Map<string, string>()
|
userIds.add(r.report.studentId)
|
||||||
if (generatorIds.length > 0) {
|
if (r.report.generatedBy) userIds.add(r.report.generatedBy)
|
||||||
const generators = await db
|
|
||||||
.select({ id: users.id, name: users.name })
|
|
||||||
.from(users)
|
|
||||||
.where(inArray(users.id, generatorIds))
|
|
||||||
for (const g of generators) generatorMap.set(g.id, g.name ?? "Unknown")
|
|
||||||
}
|
}
|
||||||
|
const userMap = await getUserNamesByIds(Array.from(userIds))
|
||||||
|
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
...serializeReport(r.report),
|
...serializeReport(r.report),
|
||||||
studentName: r.studentName ?? "Unknown",
|
studentName: userMap.get(r.report.studentId)?.name ?? "Unknown",
|
||||||
generatedByName: r.report.generatedBy ? generatorMap.get(r.report.generatedBy) ?? "Unknown" : null,
|
generatedByName: r.report.generatedBy
|
||||||
|
? userMap.get(r.report.generatedBy)?.name ?? "Unknown"
|
||||||
|
: null,
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -165,26 +160,23 @@ export const getDiagnosticReports = cache(
|
|||||||
export const getDiagnosticReportById = cache(
|
export const getDiagnosticReportById = cache(
|
||||||
async (id: string): Promise<DiagnosticReportWithDetails | null> => {
|
async (id: string): Promise<DiagnosticReportWithDetails | null> => {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({ report: learningDiagnosticReports, studentName: users.name })
|
.select({ report: learningDiagnosticReports })
|
||||||
.from(learningDiagnosticReports)
|
.from(learningDiagnosticReports)
|
||||||
.leftJoin(users, eq(users.id, learningDiagnosticReports.studentId))
|
|
||||||
.where(eq(learningDiagnosticReports.id, id))
|
.where(eq(learningDiagnosticReports.id, id))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
if (!row) return null
|
if (!row) return null
|
||||||
|
|
||||||
let generatedByName: string | null = null
|
// 通过 users data-access 获取学生姓名和生成者姓名
|
||||||
if (row.report.generatedBy) {
|
const userIds = [row.report.studentId]
|
||||||
const [gen] = await db
|
if (row.report.generatedBy) userIds.push(row.report.generatedBy)
|
||||||
.select({ name: users.name })
|
const userMap = await getUserNamesByIds(userIds)
|
||||||
.from(users)
|
|
||||||
.where(eq(users.id, row.report.generatedBy))
|
|
||||||
.limit(1)
|
|
||||||
generatedByName = gen?.name ?? null
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
...serializeReport(row.report),
|
...serializeReport(row.report),
|
||||||
studentName: row.studentName ?? "Unknown",
|
studentName: userMap.get(row.report.studentId)?.name ?? "Unknown",
|
||||||
generatedByName,
|
generatedByName: row.report.generatedBy
|
||||||
|
? userMap.get(row.report.generatedBy)?.name ?? null
|
||||||
|
: null,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ import {
|
|||||||
type SubjectComparisonParams,
|
type SubjectComparisonParams,
|
||||||
} from "./data-access-analytics"
|
} from "./data-access-analytics"
|
||||||
import { getRankingTrend } from "./data-access-ranking"
|
import { getRankingTrend } from "./data-access-ranking"
|
||||||
|
import {
|
||||||
|
ClassComparisonQuerySchema,
|
||||||
|
GradeDistributionQuerySchema,
|
||||||
|
GradeTrendQuerySchema,
|
||||||
|
RankingTrendQuerySchema,
|
||||||
|
SubjectComparisonQuerySchema,
|
||||||
|
} from "./schema"
|
||||||
import type {
|
import type {
|
||||||
ClassComparisonItem,
|
ClassComparisonItem,
|
||||||
GradeDistributionResult,
|
GradeDistributionResult,
|
||||||
@@ -28,8 +35,18 @@ export async function getGradeTrendAction(
|
|||||||
): Promise<ActionState<GradeTrendResult | null>> {
|
): Promise<ActionState<GradeTrendResult | null>> {
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||||
|
|
||||||
|
const parsed = GradeTrendQuerySchema.safeParse(params)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid query parameters",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await getGradeTrend({
|
const result = await getGradeTrend({
|
||||||
...params,
|
...parsed.data,
|
||||||
scope: ctx.dataScope,
|
scope: ctx.dataScope,
|
||||||
currentUserId: ctx.userId,
|
currentUserId: ctx.userId,
|
||||||
})
|
})
|
||||||
@@ -48,8 +65,18 @@ export async function getClassComparisonAction(
|
|||||||
): Promise<ActionState<ClassComparisonItem[]>> {
|
): Promise<ActionState<ClassComparisonItem[]>> {
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||||
|
|
||||||
|
const parsed = ClassComparisonQuerySchema.safeParse(params)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid query parameters",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await getClassComparison({
|
const result = await getClassComparison({
|
||||||
...params,
|
...parsed.data,
|
||||||
scope: ctx.dataScope,
|
scope: ctx.dataScope,
|
||||||
})
|
})
|
||||||
return { success: true, data: result }
|
return { success: true, data: result }
|
||||||
@@ -67,8 +94,18 @@ export async function getSubjectComparisonAction(
|
|||||||
): Promise<ActionState<SubjectComparisonItem[]>> {
|
): Promise<ActionState<SubjectComparisonItem[]>> {
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||||
|
|
||||||
|
const parsed = SubjectComparisonQuerySchema.safeParse(params)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid query parameters",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await getSubjectComparison({
|
const result = await getSubjectComparison({
|
||||||
...params,
|
...parsed.data,
|
||||||
scope: ctx.dataScope,
|
scope: ctx.dataScope,
|
||||||
})
|
})
|
||||||
return { success: true, data: result }
|
return { success: true, data: result }
|
||||||
@@ -86,8 +123,18 @@ export async function getGradeDistributionAction(
|
|||||||
): Promise<ActionState<GradeDistributionResult>> {
|
): Promise<ActionState<GradeDistributionResult>> {
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||||
|
|
||||||
|
const parsed = GradeDistributionQuerySchema.safeParse(params)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid query parameters",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await getGradeDistribution({
|
const result = await getGradeDistribution({
|
||||||
...params,
|
...parsed.data,
|
||||||
scope: ctx.dataScope,
|
scope: ctx.dataScope,
|
||||||
currentUserId: ctx.userId,
|
currentUserId: ctx.userId,
|
||||||
})
|
})
|
||||||
@@ -109,19 +156,32 @@ export async function getRankingTrendAction(
|
|||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||||
|
|
||||||
|
const parsed = RankingTrendQuerySchema.safeParse({ studentId, subjectId, semester })
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid query parameters",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Students can only view their own ranking trend
|
// Students can only view their own ranking trend
|
||||||
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
|
if (ctx.dataScope.type === "class_members" && ctx.userId !== parsed.data.studentId) {
|
||||||
return { success: false, message: "Can only view your own ranking trend" }
|
return { success: false, message: "Can only view your own ranking trend" }
|
||||||
}
|
}
|
||||||
// Parents can only view their children's ranking trend
|
// Parents can only view their children's ranking trend
|
||||||
if (
|
if (
|
||||||
ctx.dataScope.type === "children" &&
|
ctx.dataScope.type === "children" &&
|
||||||
!ctx.dataScope.childrenIds.includes(studentId)
|
!ctx.dataScope.childrenIds.includes(parsed.data.studentId)
|
||||||
) {
|
) {
|
||||||
return { success: false, message: "Can only view your children's ranking trend" }
|
return { success: false, message: "Can only view your children's ranking trend" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await getRankingTrend(studentId, subjectId, semester)
|
const result = await getRankingTrend(
|
||||||
|
parsed.data.studentId,
|
||||||
|
parsed.data.subjectId,
|
||||||
|
parsed.data.semester
|
||||||
|
)
|
||||||
return { success: true, data: result }
|
return { success: true, data: result }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) {
|
if (e instanceof PermissionDeniedError) {
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ import {
|
|||||||
CreateGradeRecordSchema,
|
CreateGradeRecordSchema,
|
||||||
BatchCreateGradeRecordSchema,
|
BatchCreateGradeRecordSchema,
|
||||||
UpdateGradeRecordSchema,
|
UpdateGradeRecordSchema,
|
||||||
|
DeleteGradeRecordSchema,
|
||||||
|
GetGradeRecordByIdSchema,
|
||||||
|
GradeQuerySchema,
|
||||||
|
ClassGradeStatsQuerySchema,
|
||||||
|
StudentGradeSummaryQuerySchema,
|
||||||
|
ClassRankingQuerySchema,
|
||||||
|
ExportGradesSchema,
|
||||||
} from "./schema"
|
} from "./schema"
|
||||||
import {
|
import {
|
||||||
createGradeRecord,
|
createGradeRecord,
|
||||||
@@ -156,7 +163,17 @@ export async function deleteGradeRecordAction(
|
|||||||
): Promise<ActionState<string>> {
|
): Promise<ActionState<string>> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||||
await deleteGradeRecord(id)
|
|
||||||
|
const parsed = DeleteGradeRecordSchema.safeParse({ id })
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid id",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteGradeRecord(parsed.data.id)
|
||||||
revalidatePath("/teacher/grades")
|
revalidatePath("/teacher/grades")
|
||||||
return { success: true, message: "Grade record deleted" }
|
return { success: true, message: "Grade record deleted" }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -173,8 +190,18 @@ export async function getGradeRecordsAction(
|
|||||||
): Promise<ActionState<GradeRecordListItem[]>> {
|
): Promise<ActionState<GradeRecordListItem[]>> {
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||||
|
|
||||||
|
const parsed = GradeQuerySchema.safeParse(params)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid query parameters",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const records = await getGradeRecords({
|
const records = await getGradeRecords({
|
||||||
...params,
|
...parsed.data,
|
||||||
scope: ctx.dataScope,
|
scope: ctx.dataScope,
|
||||||
currentUserId: ctx.userId,
|
currentUserId: ctx.userId,
|
||||||
})
|
})
|
||||||
@@ -195,7 +222,21 @@ export async function getClassGradeStatsAction(
|
|||||||
): Promise<ActionState<GradeStats | null>> {
|
): Promise<ActionState<GradeStats | null>> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.GRADE_RECORD_READ)
|
await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||||
const result = await getClassGradeStatsWithMeta(classId, subjectId, examId)
|
|
||||||
|
const parsed = ClassGradeStatsQuerySchema.safeParse({ classId, subjectId, examId })
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid query parameters",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getClassGradeStatsWithMeta(
|
||||||
|
parsed.data.classId,
|
||||||
|
parsed.data.subjectId,
|
||||||
|
parsed.data.examId
|
||||||
|
)
|
||||||
return { success: true, data: result?.stats ?? null }
|
return { success: true, data: result?.stats ?? null }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) {
|
if (e instanceof PermissionDeniedError) {
|
||||||
@@ -212,14 +253,26 @@ export async function getStudentGradeSummaryAction(
|
|||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||||
|
|
||||||
if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) {
|
const parsed = StudentGradeSummaryQuerySchema.safeParse({ studentId })
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid student id",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.dataScope.type === "class_members" && ctx.userId !== parsed.data.studentId) {
|
||||||
return { success: false, message: "Can only view your own grades" }
|
return { success: false, message: "Can only view your own grades" }
|
||||||
}
|
}
|
||||||
if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) {
|
if (
|
||||||
|
ctx.dataScope.type === "children" &&
|
||||||
|
!ctx.dataScope.childrenIds.includes(parsed.data.studentId)
|
||||||
|
) {
|
||||||
return { success: false, message: "Can only view your children's grades" }
|
return { success: false, message: "Can only view your children's grades" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const summary = await getStudentGradeSummary(studentId)
|
const summary = await getStudentGradeSummary(parsed.data.studentId)
|
||||||
return { success: true, data: summary }
|
return { success: true, data: summary }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) {
|
if (e instanceof PermissionDeniedError) {
|
||||||
@@ -237,7 +290,21 @@ export async function getClassRankingAction(
|
|||||||
): Promise<ActionState<Awaited<ReturnType<typeof getClassRanking>>>> {
|
): Promise<ActionState<Awaited<ReturnType<typeof getClassRanking>>>> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.GRADE_RECORD_READ)
|
await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||||
const ranking = await getClassRanking(classId, subjectId, examId)
|
|
||||||
|
const parsed = ClassRankingQuerySchema.safeParse({ classId, subjectId, examId })
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid query parameters",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ranking = await getClassRanking(
|
||||||
|
parsed.data.classId,
|
||||||
|
parsed.data.subjectId,
|
||||||
|
parsed.data.examId
|
||||||
|
)
|
||||||
return { success: true, data: ranking }
|
return { success: true, data: ranking }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) {
|
if (e instanceof PermissionDeniedError) {
|
||||||
@@ -253,7 +320,17 @@ export async function getGradeRecordByIdAction(
|
|||||||
): Promise<ActionState<Awaited<ReturnType<typeof getGradeRecordById>>>> {
|
): Promise<ActionState<Awaited<ReturnType<typeof getGradeRecordById>>>> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.GRADE_RECORD_READ)
|
await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||||
const record = await getGradeRecordById(id)
|
|
||||||
|
const parsed = GetGradeRecordByIdSchema.safeParse({ id })
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid id",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await getGradeRecordById(parsed.data.id)
|
||||||
return { success: true, data: record }
|
return { success: true, data: record }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) {
|
if (e instanceof PermissionDeniedError) {
|
||||||
@@ -276,20 +353,29 @@ export async function exportGradesAction(params: {
|
|||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||||
|
|
||||||
|
const parsed = ExportGradesSchema.safeParse(params)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid export parameters",
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let buffer: Buffer
|
let buffer: Buffer
|
||||||
let filename: string
|
let filename: string
|
||||||
|
|
||||||
if (params.reportType === "class") {
|
if (parsed.data.reportType === "class") {
|
||||||
buffer = await exportClassGradeReportToExcel({
|
buffer = await exportClassGradeReportToExcel({
|
||||||
classId: params.classId,
|
classId: parsed.data.classId,
|
||||||
scope: ctx.dataScope,
|
scope: ctx.dataScope,
|
||||||
})
|
})
|
||||||
filename = `班级成绩总表_${formatDateForFile()}.xlsx`
|
filename = `班级成绩总表_${formatDateForFile()}.xlsx`
|
||||||
} else {
|
} else {
|
||||||
buffer = await exportGradeRecordsToExcel({
|
buffer = await exportGradeRecordsToExcel({
|
||||||
classId: params.classId,
|
classId: parsed.data.classId,
|
||||||
subjectId: params.subjectId,
|
subjectId: parsed.data.subjectId,
|
||||||
examId: params.examId,
|
examId: parsed.data.examId,
|
||||||
scope: ctx.dataScope,
|
scope: ctx.dataScope,
|
||||||
})
|
})
|
||||||
filename = `成绩单_${formatDateForFile()}.xlsx`
|
filename = `成绩单_${formatDateForFile()}.xlsx`
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState, useRef, useEffect, useCallback, useMemo } from "react"
|
||||||
import { useFormStatus } from "react-dom"
|
import { useFormStatus } from "react-dom"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Search, TrendingUp, Trophy, AlertCircle } from "lucide-react"
|
||||||
|
|
||||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
@@ -18,11 +19,25 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/shared/components/ui/table"
|
} from "@/shared/components/ui/table"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
import { batchCreateGradeRecordsAction } from "../actions"
|
import { batchCreateGradeRecordsAction } from "../actions"
|
||||||
|
|
||||||
type Option = { id: string; name: string }
|
type Option = { id: string; name: string }
|
||||||
type Student = { id: string; name: string; email: string }
|
type Student = { id: string; name: string; email: string }
|
||||||
|
type GradeType = "exam" | "quiz" | "homework" | "other"
|
||||||
|
type Semester = "1" | "2"
|
||||||
|
|
||||||
|
function isGradeType(v: string): v is GradeType {
|
||||||
|
return v === "exam" || v === "quiz" || v === "homework" || v === "other"
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSemester(v: string): v is Semester {
|
||||||
|
return v === "1" || v === "2"
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_SCORE = 100
|
||||||
|
const DRAFT_KEY_PREFIX = "grade-draft"
|
||||||
|
|
||||||
function SubmitButton() {
|
function SubmitButton() {
|
||||||
const { pending } = useFormStatus()
|
const { pending } = useFormStatus()
|
||||||
@@ -47,15 +62,156 @@ export function BatchGradeEntry({
|
|||||||
defaultSubjectId?: string
|
defaultSubjectId?: string
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const initialDraftKey = `${DRAFT_KEY_PREFIX}-${defaultClassId ?? classes[0]?.id ?? ""}-${defaultSubjectId ?? subjects[0]?.id ?? ""}-exam`
|
||||||
const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "")
|
const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "")
|
||||||
const [subjectId, setSubjectId] = useState(defaultSubjectId ?? subjects[0]?.id ?? "")
|
const [subjectId, setSubjectId] = useState(defaultSubjectId ?? subjects[0]?.id ?? "")
|
||||||
const [type, setType] = useState<"exam" | "quiz" | "homework" | "other">("exam")
|
const [type, setType] = useState<GradeType>("exam")
|
||||||
const [semester, setSemester] = useState<"1" | "2">("1")
|
const [semester, setSemester] = useState<Semester>("1")
|
||||||
const [scores, setScores] = useState<Record<string, string>>({})
|
const [scores, setScores] = useState<Record<string, string>>(() => {
|
||||||
|
// 惰性初始化:从 localStorage 恢复草稿(避免 useEffect 中 setState 导致级联渲染)
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(initialDraftKey)
|
||||||
|
if (raw) {
|
||||||
|
const data = JSON.parse(raw) as { scores: Record<string, string>; timestamp: number }
|
||||||
|
if (Date.now() - data.timestamp < 2 * 60 * 60 * 1000 && Object.keys(data.scores).length > 0) {
|
||||||
|
return data.scores
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 解析失败,忽略
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
})
|
||||||
|
const [draftRestored] = useState(() => {
|
||||||
|
// 检查是否恢复了草稿(用于显示 toast)
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(initialDraftKey)
|
||||||
|
if (raw) {
|
||||||
|
const data = JSON.parse(raw) as { scores: Record<string, string>; timestamp: number }
|
||||||
|
return Date.now() - data.timestamp < 2 * 60 * 60 * 1000 && Object.keys(data.scores).length > 0
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 解析失败,忽略
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const inputRefs = useRef<Record<string, HTMLInputElement | null>>({})
|
||||||
|
|
||||||
const handleScoreChange = (studentId: string, value: string) => {
|
const draftKey = `${DRAFT_KEY_PREFIX}-${classId}-${subjectId}-${type}`
|
||||||
|
|
||||||
|
// 草稿恢复提示(仅在首次挂载时显示一次)
|
||||||
|
useEffect(() => {
|
||||||
|
if (draftRestored) {
|
||||||
|
toast.info("已恢复未保存的成绩草稿")
|
||||||
|
}
|
||||||
|
}, [draftRestored])
|
||||||
|
|
||||||
|
const handleScoreChange = useCallback((studentId: string, value: string) => {
|
||||||
|
if (value === "" || /^\d*\.?\d{0,2}$/.test(value)) {
|
||||||
setScores((prev) => ({ ...prev, [studentId]: value }))
|
setScores((prev) => ({ ...prev, [studentId]: value }))
|
||||||
}
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const validateScore = (value: string): boolean => {
|
||||||
|
if (value === "") return true
|
||||||
|
const num = Number(value)
|
||||||
|
return !isNaN(num) && num >= 0 && num <= MAX_SCORE
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreDraft = useCallback((key: string): boolean => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
if (raw) {
|
||||||
|
const data = JSON.parse(raw) as { scores: Record<string, string>; timestamp: number }
|
||||||
|
if (Date.now() - data.timestamp < 2 * 60 * 60 * 1000 && Object.keys(data.scores).length > 0) {
|
||||||
|
setScores(data.scores)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 解析失败,忽略
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleClassChange = (newClassId: string) => {
|
||||||
|
const hasUnsaved = Object.keys(scores).length > 0
|
||||||
|
if (hasUnsaved && newClassId !== classId) {
|
||||||
|
if (!window.confirm("当前班级有未保存的成绩记录,确认切换班级?")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setClassId(newClassId)
|
||||||
|
setScores({})
|
||||||
|
// 切换班级后尝试恢复该班级的草稿
|
||||||
|
const newDraftKey = `${DRAFT_KEY_PREFIX}-${newClassId}-${subjectId}-${type}`
|
||||||
|
if (restoreDraft(newDraftKey)) {
|
||||||
|
toast.info("已恢复未保存的成绩草稿")
|
||||||
|
}
|
||||||
|
const newUrl = newClassId ? `/teacher/grades/entry?classId=${encodeURIComponent(newClassId)}` : "/teacher/grades/entry"
|
||||||
|
router.push(newUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredStudents = useMemo(
|
||||||
|
() => students.filter((s) => !searchQuery || s.name.toLowerCase().includes(searchQuery.toLowerCase())),
|
||||||
|
[students, searchQuery]
|
||||||
|
)
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const validScores = students
|
||||||
|
.map((s) => scores[s.id])
|
||||||
|
.filter((v): v is string => v !== undefined && v !== "" && validateScore(v))
|
||||||
|
.map(Number)
|
||||||
|
const entered = validScores.length
|
||||||
|
if (entered === 0) return { entered: 0, average: 0, max: 0, min: 0, total: students.length }
|
||||||
|
const sum = validScores.reduce((acc, v) => acc + v, 0)
|
||||||
|
return {
|
||||||
|
entered,
|
||||||
|
average: Math.round((sum / entered) * 100) / 100,
|
||||||
|
max: Math.max(...validScores),
|
||||||
|
min: Math.min(...validScores),
|
||||||
|
total: students.length,
|
||||||
|
}
|
||||||
|
}, [scores, students])
|
||||||
|
|
||||||
|
const hasInvalidScores = Object.values(scores).some((v) => v !== "" && v !== undefined && !validateScore(v))
|
||||||
|
|
||||||
|
// 草稿保存到 localStorage(30秒间隔)
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (Object.keys(scores).length > 0) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(draftKey, JSON.stringify({ scores, timestamp: Date.now() }))
|
||||||
|
} catch {
|
||||||
|
// localStorage 可能已满或不可用,静默失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 30000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [scores, draftKey])
|
||||||
|
|
||||||
|
// 清除草稿
|
||||||
|
const clearDraft = useCallback(() => {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(draftKey)
|
||||||
|
} catch {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
}, [draftKey])
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>, studentId: string) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault()
|
||||||
|
const currentIndex = filteredStudents.findIndex((s) => s.id === studentId)
|
||||||
|
const nextStudent = filteredStudents[currentIndex + 1]
|
||||||
|
if (nextStudent && inputRefs.current[nextStudent.id]) {
|
||||||
|
inputRefs.current[nextStudent.id]?.focus()
|
||||||
|
inputRefs.current[nextStudent.id]?.select()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (formData: FormData) => {
|
const handleSubmit = async (formData: FormData) => {
|
||||||
if (!classId || !subjectId) {
|
if (!classId || !subjectId) {
|
||||||
@@ -63,19 +219,23 @@ export function BatchGradeEntry({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasInvalidScores) {
|
||||||
|
toast.error("存在无效分数(超过满分或格式错误),请检查后重试")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const records = students
|
const records = students
|
||||||
.map((s) => ({
|
.map((s) => ({
|
||||||
studentId: s.id,
|
studentId: s.id,
|
||||||
score: Number(scores[s.id] ?? 0),
|
score: Number(scores[s.id] ?? 0),
|
||||||
remark: undefined as string | undefined,
|
|
||||||
}))
|
}))
|
||||||
.filter((r) => r.score > 0 || scores[r.studentId] !== undefined)
|
.filter((r) => r.score > 0 || scores[r.studentId] !== undefined)
|
||||||
|
|
||||||
if (records.length === 0) {
|
if (records.length === 0) {
|
||||||
toast.error("Please enter at least one score")
|
toast.error("Please enter at least one score")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
formData.set("classId", classId)
|
formData.set("classId", classId)
|
||||||
formData.set("subjectId", subjectId)
|
formData.set("subjectId", subjectId)
|
||||||
formData.set("type", type)
|
formData.set("type", type)
|
||||||
@@ -83,7 +243,9 @@ export function BatchGradeEntry({
|
|||||||
formData.set("recordsJson", JSON.stringify(records))
|
formData.set("recordsJson", JSON.stringify(records))
|
||||||
|
|
||||||
const result = await batchCreateGradeRecordsAction(null, formData)
|
const result = await batchCreateGradeRecordsAction(null, formData)
|
||||||
|
setIsSubmitting(false)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
clearDraft()
|
||||||
toast.success(result.message)
|
toast.success(result.message)
|
||||||
router.push("/teacher/grades")
|
router.push("/teacher/grades")
|
||||||
router.refresh()
|
router.refresh()
|
||||||
@@ -93,16 +255,27 @@ export function BatchGradeEntry({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="relative">
|
||||||
|
{isSubmitting && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-background/60 backdrop-blur-sm">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
Saving grades...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Batch Grade Entry</CardTitle>
|
<CardTitle>Batch Grade Entry</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
满分 {MAX_SCORE} 分。输入分数后按 Enter 跳到下一位学生。草稿每 30 秒自动保存,2 小时内有效。
|
||||||
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form action={handleSubmit} className="space-y-6">
|
<form action={handleSubmit} className="space-y-6">
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Class</Label>
|
<Label>Class</Label>
|
||||||
<Select value={classId} onValueChange={setClassId}>
|
<Select value={classId} onValueChange={handleClassChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a class" />
|
<SelectValue placeholder="Select a class" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -139,12 +312,12 @@ export function BatchGradeEntry({
|
|||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="fullScore">Full Score</Label>
|
<Label htmlFor="fullScore">Full Score</Label>
|
||||||
<Input id="fullScore" name="fullScore" type="number" step="0.01" min="1" defaultValue="100" />
|
<Input id="fullScore" name="fullScore" type="number" step="0.01" min="1" defaultValue={String(MAX_SCORE)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Type</Label>
|
<Label>Type</Label>
|
||||||
<Select value={type} onValueChange={(v) => setType(v as typeof type)}>
|
<Select value={type} onValueChange={(v) => { if (isGradeType(v)) setType(v) }}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -159,7 +332,7 @@ export function BatchGradeEntry({
|
|||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Semester</Label>
|
<Label>Semester</Label>
|
||||||
<Select value={semester} onValueChange={(v) => setSemester(v as "1" | "2")}>
|
<Select value={semester} onValueChange={(v) => { if (isSemester(v)) setSemester(v) }}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -174,36 +347,94 @@ export function BatchGradeEntry({
|
|||||||
{students.length === 0 ? (
|
{students.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No students in this class.</p>
|
<p className="text-sm text-muted-foreground">No students in this class.</p>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 实时统计栏 */}
|
||||||
|
<div className="flex flex-col gap-3 rounded-md border bg-muted/30 p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="text-muted-foreground">已录入</span>
|
||||||
|
<span className="font-semibold tabular-nums">{stats.entered}</span>
|
||||||
|
<span className="text-muted-foreground">/ {stats.total}</span>
|
||||||
|
</span>
|
||||||
|
{stats.entered > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<TrendingUp className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
|
||||||
|
<span className="text-muted-foreground">均分</span>
|
||||||
|
<span className="font-semibold tabular-nums">{stats.average}</span>
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Trophy className="h-3.5 w-3.5 text-amber-500" aria-hidden="true" />
|
||||||
|
<span className="text-muted-foreground">最高</span>
|
||||||
|
<span className="font-semibold tabular-nums">{stats.max}</span>
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="text-muted-foreground">最低</span>
|
||||||
|
<span className="font-semibold tabular-nums">{stats.min}</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{hasInvalidScores && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-destructive">
|
||||||
|
<AlertCircle className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
存在无效分数
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search student..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="h-8 w-40 pl-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableHead className="w-12">#</TableHead>
|
||||||
<TableHead>Student</TableHead>
|
<TableHead>Student</TableHead>
|
||||||
<TableHead>Email</TableHead>
|
<TableHead className="hidden md:table-cell">Email</TableHead>
|
||||||
<TableHead className="w-32">Score</TableHead>
|
<TableHead className="w-32">Score</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{students.map((s) => (
|
{filteredStudents.map((s, idx) => {
|
||||||
|
const scoreValue = scores[s.id] ?? ""
|
||||||
|
const isInvalid = scoreValue !== "" && !validateScore(scoreValue)
|
||||||
|
return (
|
||||||
<TableRow key={s.id}>
|
<TableRow key={s.id}>
|
||||||
|
<TableCell className="text-muted-foreground tabular-nums">{idx + 1}</TableCell>
|
||||||
<TableCell className="font-medium">{s.name}</TableCell>
|
<TableCell className="font-medium">{s.name}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{s.email}</TableCell>
|
<TableCell className="hidden text-muted-foreground md:table-cell">{s.email}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Input
|
<Input
|
||||||
|
ref={(el) => { inputRefs.current[s.id] = el }}
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
|
max={MAX_SCORE}
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
value={scores[s.id] ?? ""}
|
value={scoreValue}
|
||||||
onChange={(e) => handleScoreChange(s.id, e.target.value)}
|
onChange={(e) => handleScoreChange(s.id, e.target.value)}
|
||||||
className="h-8"
|
onKeyDown={(e) => handleKeyDown(e, s.id)}
|
||||||
|
className={cn("h-8", isInvalid && "border-destructive focus-visible:ring-destructive")}
|
||||||
|
aria-invalid={isInvalid}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CardFooter className="justify-end gap-2 px-0">
|
<CardFooter className="justify-end gap-2 px-0">
|
||||||
|
|||||||
@@ -18,6 +18,30 @@ interface GradeDistributionChartProps {
|
|||||||
data: GradeDistributionResult | null
|
data: GradeDistributionResult | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DistributionTooltipItem {
|
||||||
|
label: string
|
||||||
|
count: number
|
||||||
|
percentage: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DistributionTooltipPayload {
|
||||||
|
payload?: DistributionTooltipItem
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDistributionTooltipPayload(v: unknown): v is DistributionTooltipPayload {
|
||||||
|
if (typeof v !== "object" || v === null) return false
|
||||||
|
const obj = v as Record<string, unknown>
|
||||||
|
const inner = obj.payload
|
||||||
|
if (inner === undefined || inner === null) return true
|
||||||
|
if (typeof inner !== "object") return false
|
||||||
|
const item = inner as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
typeof item.label === "string" &&
|
||||||
|
typeof item.count === "number" &&
|
||||||
|
typeof item.percentage === "number"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
|
export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
|
||||||
const isEmpty = !data || data.totalCount === 0
|
const isEmpty = !data || data.totalCount === 0
|
||||||
|
|
||||||
@@ -64,7 +88,8 @@ export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
|
|||||||
tooltipClassName="w-[200px]"
|
tooltipClassName="w-[200px]"
|
||||||
cellColors={BUCKET_COLORS}
|
cellColors={BUCKET_COLORS}
|
||||||
tooltipFormatter={(payload: unknown) => {
|
tooltipFormatter={(payload: unknown) => {
|
||||||
const item = (payload as { payload?: { label: string; count: number; percentage: number } })?.payload
|
if (!isDistributionTooltipPayload(payload)) return null
|
||||||
|
const item = payload.payload
|
||||||
if (!item) return null
|
if (!item) return null
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-0.5">
|
<div className="flex w-full flex-col gap-0.5">
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ import { Textarea } from "@/shared/components/ui/textarea"
|
|||||||
import { createGradeRecordAction } from "../actions"
|
import { createGradeRecordAction } from "../actions"
|
||||||
|
|
||||||
type Option = { id: string; name: string }
|
type Option = { id: string; name: string }
|
||||||
|
type GradeType = "exam" | "quiz" | "homework" | "other"
|
||||||
|
type Semester = "1" | "2"
|
||||||
|
|
||||||
|
function isGradeType(v: string): v is GradeType {
|
||||||
|
return v === "exam" || v === "quiz" || v === "homework" || v === "other"
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSemester(v: string): v is Semester {
|
||||||
|
return v === "1" || v === "2"
|
||||||
|
}
|
||||||
|
|
||||||
function SubmitButton() {
|
function SubmitButton() {
|
||||||
const { pending } = useFormStatus()
|
const { pending } = useFormStatus()
|
||||||
@@ -42,8 +52,8 @@ export function GradeRecordForm({
|
|||||||
const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "")
|
const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "")
|
||||||
const [subjectId, setSubjectId] = useState(defaultSubjectId ?? subjects[0]?.id ?? "")
|
const [subjectId, setSubjectId] = useState(defaultSubjectId ?? subjects[0]?.id ?? "")
|
||||||
const [studentId, setStudentId] = useState(students[0]?.id ?? "")
|
const [studentId, setStudentId] = useState(students[0]?.id ?? "")
|
||||||
const [type, setType] = useState<"exam" | "quiz" | "homework" | "other">("exam")
|
const [type, setType] = useState<GradeType>("exam")
|
||||||
const [semester, setSemester] = useState<"1" | "2">("1")
|
const [semester, setSemester] = useState<Semester>("1")
|
||||||
|
|
||||||
const handleSubmit = async (formData: FormData) => {
|
const handleSubmit = async (formData: FormData) => {
|
||||||
if (!classId || !subjectId || !studentId) {
|
if (!classId || !subjectId || !studentId) {
|
||||||
@@ -139,7 +149,7 @@ export function GradeRecordForm({
|
|||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Type</Label>
|
<Label>Type</Label>
|
||||||
<Select value={type} onValueChange={(v) => setType(v as typeof type)}>
|
<Select value={type} onValueChange={(v) => { if (isGradeType(v)) setType(v) }}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -154,7 +164,7 @@ export function GradeRecordForm({
|
|||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Semester</Label>
|
<Label>Semester</Label>
|
||||||
<Select value={semester} onValueChange={(v) => setSemester(v as "1" | "2")}>
|
<Select value={semester} onValueChange={(v) => { if (isSemester(v)) setSemester(v) }}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import "server-only"
|
import "server-only"
|
||||||
|
|
||||||
import { cache } from "react"
|
import { cache } from "react"
|
||||||
import { and, asc, eq, inArray, sql, type SQL } from "drizzle-orm"
|
import { and, asc, eq, inArray } from "drizzle-orm"
|
||||||
|
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import { gradeRecords } from "@/shared/db/schema"
|
import { gradeRecords } from "@/shared/db/schema"
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { getSubjectOptions } from "@/modules/school/data-access"
|
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 type {
|
import type {
|
||||||
ClassComparisonItem,
|
ClassComparisonItem,
|
||||||
GradeDistributionBucket,
|
GradeDistributionBucket,
|
||||||
@@ -21,32 +22,6 @@ import type {
|
|||||||
SubjectComparisonItem,
|
SubjectComparisonItem,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
const toNumber = (v: unknown): number => {
|
|
||||||
const n = typeof v === "number" ? v : Number(v)
|
|
||||||
return Number.isFinite(n) ? n : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalize = (score: number, fullScore: number): number => {
|
|
||||||
if (fullScore <= 0) return 0
|
|
||||||
return Math.round((score / fullScore) * 10000) / 100
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildScopeClassFilter = (scope: DataScope): SQL | null => {
|
|
||||||
if (scope.type === "all") return null
|
|
||||||
if (scope.type === "class_taught") {
|
|
||||||
return scope.classIds.length > 0 ? inArray(gradeRecords.classId, scope.classIds) : sql`1=0`
|
|
||||||
}
|
|
||||||
if (scope.type === "grade_managed") return sql`1=0`
|
|
||||||
if (scope.type === "class_members") return null
|
|
||||||
if (scope.type === "children") {
|
|
||||||
return scope.childrenIds.length > 0
|
|
||||||
? inArray(gradeRecords.studentId, scope.childrenIds)
|
|
||||||
: sql`1=0`
|
|
||||||
}
|
|
||||||
if (scope.type === "owned") return eq(gradeRecords.studentId, scope.userId)
|
|
||||||
return sql`1=0`
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GradeTrendParams {
|
export interface GradeTrendParams {
|
||||||
classId: string
|
classId: string
|
||||||
subjectId?: string
|
subjectId?: string
|
||||||
|
|||||||
@@ -8,21 +8,12 @@ import { gradeRecords } from "@/shared/db/schema"
|
|||||||
import { getStudentActiveClassId } from "@/modules/classes/data-access"
|
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 type {
|
import type {
|
||||||
RankingTrendPoint,
|
RankingTrendPoint,
|
||||||
RankingTrendResult,
|
RankingTrendResult,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
const toNumber = (v: unknown): number => {
|
|
||||||
const n = typeof v === "number" ? v : Number(v)
|
|
||||||
return Number.isFinite(n) ? n : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalize = (score: number, fullScore: number): number => {
|
|
||||||
if (fullScore <= 0) return 0
|
|
||||||
return Math.round((score / fullScore) * 10000) / 100
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a student's ranking trend across assessments within their class.
|
* Get a student's ranking trend across assessments within their class.
|
||||||
* Each point represents one assessment (grouped by title), with the
|
* Each point represents one assessment (grouped by title), with the
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import "server-only"
|
|||||||
|
|
||||||
import { cache } from "react"
|
import { cache } from "react"
|
||||||
import { createId } from "@paralleldrive/cuid2"
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
import { and, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
|
import { and, count, desc, eq, sql } from "drizzle-orm"
|
||||||
|
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import { gradeRecords } from "@/shared/db/schema"
|
import { gradeRecords } from "@/shared/db/schema"
|
||||||
@@ -16,6 +16,7 @@ import { getSubjectOptions } from "@/modules/school/data-access"
|
|||||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
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 type {
|
import type {
|
||||||
ClassGradeStats,
|
ClassGradeStats,
|
||||||
ClassRankingItem,
|
ClassRankingItem,
|
||||||
@@ -31,11 +32,6 @@ import type {
|
|||||||
UpdateGradeRecordInput,
|
UpdateGradeRecordInput,
|
||||||
} from "./schema"
|
} from "./schema"
|
||||||
|
|
||||||
const toNumber = (v: unknown): number => {
|
|
||||||
const n = typeof v === "number" ? v : Number(v)
|
|
||||||
return Number.isFinite(n) ? n : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const serializeRecord = (r: typeof gradeRecords.$inferSelect): GradeRecord => ({
|
const serializeRecord = (r: typeof gradeRecords.$inferSelect): GradeRecord => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
studentId: r.studentId,
|
studentId: r.studentId,
|
||||||
@@ -54,26 +50,6 @@ const serializeRecord = (r: typeof gradeRecords.$inferSelect): GradeRecord => ({
|
|||||||
updatedAt: r.updatedAt.toISOString(),
|
updatedAt: r.updatedAt.toISOString(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const buildScopeClassFilter = (scope: DataScope): SQL | null => {
|
|
||||||
if (scope.type === "all") return null
|
|
||||||
if (scope.type === "class_taught") {
|
|
||||||
return scope.classIds.length > 0 ? inArray(gradeRecords.classId, scope.classIds) : sql`1=0`
|
|
||||||
}
|
|
||||||
if (scope.type === "grade_managed") {
|
|
||||||
return sql`1=0`
|
|
||||||
}
|
|
||||||
if (scope.type === "class_members") {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
if (scope.type === "children") {
|
|
||||||
return scope.childrenIds.length > 0 ? inArray(gradeRecords.studentId, scope.childrenIds) : sql`1=0`
|
|
||||||
}
|
|
||||||
if (scope.type === "owned") {
|
|
||||||
return eq(gradeRecords.studentId, scope.userId)
|
|
||||||
}
|
|
||||||
return sql`1=0`
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getGradeRecords = cache(
|
export const getGradeRecords = cache(
|
||||||
async (
|
async (
|
||||||
params: GradeQueryParams & { scope: DataScope; currentUserId?: string }
|
params: GradeQueryParams & { scope: DataScope; currentUserId?: string }
|
||||||
|
|||||||
51
src/modules/grades/lib/grade-utils.ts
Normal file
51
src/modules/grades/lib/grade-utils.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import { eq, inArray, sql, type SQL } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { gradeRecords } from "@/shared/db/schema"
|
||||||
|
import type { DataScope } from "@/shared/types/permissions"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely convert an unknown value to a finite number.
|
||||||
|
* Returns 0 when the value is not a finite number.
|
||||||
|
*
|
||||||
|
* Used to normalize numeric columns returned by Drizzle (which may be
|
||||||
|
* string | number depending on the driver) into plain numbers.
|
||||||
|
*/
|
||||||
|
export const toNumber = (v: unknown): number => {
|
||||||
|
const n = typeof v === "number" ? v : Number(v)
|
||||||
|
return Number.isFinite(n) ? n : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a raw score to a 0-100 scale based on its full score.
|
||||||
|
* Returns 0 when fullScore is non-positive. Result is rounded to 2 decimals.
|
||||||
|
*/
|
||||||
|
export const normalize = (score: number, fullScore: number): number => {
|
||||||
|
if (fullScore <= 0) return 0
|
||||||
|
return Math.round((score / fullScore) * 10000) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Drizzle SQL filter that restricts `gradeRecords` rows based on
|
||||||
|
* the current user's DataScope. Returns `null` when no row-level filter
|
||||||
|
* is required (e.g. admin / student viewing their own records — the caller
|
||||||
|
* is expected to add the studentId condition separately for `class_members`).
|
||||||
|
*/
|
||||||
|
export const buildScopeClassFilter = (scope: DataScope): SQL | null => {
|
||||||
|
if (scope.type === "all") return null
|
||||||
|
if (scope.type === "class_taught") {
|
||||||
|
return scope.classIds.length > 0
|
||||||
|
? inArray(gradeRecords.classId, scope.classIds)
|
||||||
|
: sql`1=0`
|
||||||
|
}
|
||||||
|
if (scope.type === "grade_managed") return sql`1=0`
|
||||||
|
if (scope.type === "class_members") return null
|
||||||
|
if (scope.type === "children") {
|
||||||
|
return scope.childrenIds.length > 0
|
||||||
|
? inArray(gradeRecords.studentId, scope.childrenIds)
|
||||||
|
: sql`1=0`
|
||||||
|
}
|
||||||
|
if (scope.type === "owned") return eq(gradeRecords.studentId, scope.userId)
|
||||||
|
return sql`1=0`
|
||||||
|
}
|
||||||
@@ -50,3 +50,74 @@ export const UpdateGradeRecordSchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export type UpdateGradeRecordInput = z.infer<typeof UpdateGradeRecordSchema>
|
export type UpdateGradeRecordInput = z.infer<typeof UpdateGradeRecordSchema>
|
||||||
|
|
||||||
|
// --- 查询/分析相关 Schema(P1-3 新增:为缺失 Zod 校验的 Action 补齐) ---
|
||||||
|
|
||||||
|
export const DeleteGradeRecordSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const GetGradeRecordByIdSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const GradeQuerySchema = z.object({
|
||||||
|
classId: z.string().optional(),
|
||||||
|
subjectId: z.string().optional(),
|
||||||
|
studentId: z.string().optional(),
|
||||||
|
type: GradeRecordTypeEnum.optional(),
|
||||||
|
semester: GradeRecordSemesterEnum.optional(),
|
||||||
|
examId: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type GradeQueryInput = z.infer<typeof GradeQuerySchema>
|
||||||
|
|
||||||
|
export const ClassGradeStatsQuerySchema = z.object({
|
||||||
|
classId: z.string().min(1),
|
||||||
|
subjectId: z.string().optional(),
|
||||||
|
examId: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const StudentGradeSummaryQuerySchema = z.object({
|
||||||
|
studentId: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ClassRankingQuerySchema = z.object({
|
||||||
|
classId: z.string().min(1),
|
||||||
|
subjectId: z.string().optional(),
|
||||||
|
examId: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ExportGradesSchema = z.object({
|
||||||
|
classId: z.string().min(1),
|
||||||
|
subjectId: z.string().optional(),
|
||||||
|
examId: z.string().optional(),
|
||||||
|
reportType: z.enum(["detail", "class"]).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ExportGradesInput = z.infer<typeof ExportGradesSchema>
|
||||||
|
|
||||||
|
export const GradeTrendQuerySchema = z.object({
|
||||||
|
classId: z.string().min(1),
|
||||||
|
subjectId: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ClassComparisonQuerySchema = z.object({
|
||||||
|
gradeId: z.string().min(1),
|
||||||
|
subjectId: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SubjectComparisonQuerySchema = z.object({
|
||||||
|
classId: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const GradeDistributionQuerySchema = z.object({
|
||||||
|
classId: z.string().min(1),
|
||||||
|
subjectId: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const RankingTrendQuerySchema = z.object({
|
||||||
|
studentId: z.string().min(1),
|
||||||
|
subjectId: z.string().optional(),
|
||||||
|
semester: GradeRecordSemesterEnum.optional(),
|
||||||
|
})
|
||||||
|
|||||||
87
src/shared/i18n/messages/en/diagnostic.json
Normal file
87
src/shared/i18n/messages/en/diagnostic.json
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"title": {
|
||||||
|
"student": "Student Diagnostic",
|
||||||
|
"class": "Class Diagnostic",
|
||||||
|
"reportList": "Diagnostic Reports",
|
||||||
|
"myDiagnostic": "My Diagnostic"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"individual": "Individual",
|
||||||
|
"class": "Class",
|
||||||
|
"grade": "Grade"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"draft": "Draft",
|
||||||
|
"published": "Published",
|
||||||
|
"archived": "Archived"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"reportType": "Report Type",
|
||||||
|
"status": "Status",
|
||||||
|
"allTypes": "All types",
|
||||||
|
"allStatuses": "All statuses"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"overallMastery": "Overall Mastery",
|
||||||
|
"strengths": "Strengths",
|
||||||
|
"weaknesses": "Weaknesses",
|
||||||
|
"students": "Students",
|
||||||
|
"avgMastery": "Avg Mastery",
|
||||||
|
"needAttention": "Need Attention",
|
||||||
|
"class": "Class",
|
||||||
|
"student": "Student"
|
||||||
|
},
|
||||||
|
"chart": {
|
||||||
|
"radarTitle": "Knowledge Point Mastery",
|
||||||
|
"radarDescription": "Radar chart of mastery level (student vs class average)",
|
||||||
|
"heatmapTitle": "Knowledge Point Mastery Heatmap",
|
||||||
|
"rankingTitle": "Knowledge Point Ranking",
|
||||||
|
"noMasteryData": "No knowledge point mastery records found."
|
||||||
|
},
|
||||||
|
"report": {
|
||||||
|
"generate": "Generate Diagnostic Report",
|
||||||
|
"generateStudent": "Generate Student Diagnostic Report",
|
||||||
|
"generateClass": "Generate Class Diagnostic Report",
|
||||||
|
"publish": "Publish",
|
||||||
|
"delete": "Delete",
|
||||||
|
"publishTitle": "Publish Report",
|
||||||
|
"publishConfirmation": "Are you sure you want to publish this report? It will be visible to relevant users.",
|
||||||
|
"deleteTitle": "Delete Report",
|
||||||
|
"deleteConfirmation": "Are you sure you want to delete this report? This action cannot be undone.",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"publishing": "Publishing...",
|
||||||
|
"deleting": "Deleting...",
|
||||||
|
"recommendations": "Recommendations",
|
||||||
|
"history": "Report History",
|
||||||
|
"period": "Period",
|
||||||
|
"createdAt": "Created At",
|
||||||
|
"generatedBy": "Generated By",
|
||||||
|
"overallScore": "Overall Score",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"strengths": {
|
||||||
|
"title": "Strengths (≥80%)",
|
||||||
|
"practice": "Practice",
|
||||||
|
"empty": "No strength knowledge points"
|
||||||
|
},
|
||||||
|
"weaknesses": {
|
||||||
|
"title": "Weaknesses (<60%)",
|
||||||
|
"practice": "Practice",
|
||||||
|
"empty": "No weakness knowledge points"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"noData": "No diagnostic data",
|
||||||
|
"noClassData": "Unable to load class mastery summary.",
|
||||||
|
"noMastery": "No knowledge point mastery records found.",
|
||||||
|
"noReports": "No diagnostic reports"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"generateFailed": "Failed to generate report",
|
||||||
|
"generateClassFailed": "Failed to generate class report",
|
||||||
|
"publishFailed": "Failed to publish",
|
||||||
|
"deleteFailed": "Failed to delete",
|
||||||
|
"loadFailed": "Failed to load",
|
||||||
|
"retry": "Retry"
|
||||||
|
}
|
||||||
|
}
|
||||||
155
src/shared/i18n/messages/en/grade.json
Normal file
155
src/shared/i18n/messages/en/grade.json
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
{
|
||||||
|
"delete": {
|
||||||
|
"title": "Delete Grade",
|
||||||
|
"description": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Delete"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"noGrades": {
|
||||||
|
"title": "No Grades",
|
||||||
|
"description": "No grades have been created for this school yet. Click \"Create Grade\" to start."
|
||||||
|
},
|
||||||
|
"noMatch": {
|
||||||
|
"title": "No Matches",
|
||||||
|
"description": "Try adjusting your search or filters."
|
||||||
|
},
|
||||||
|
"noAssignedGrades": {
|
||||||
|
"title": "No Assigned Grades",
|
||||||
|
"description": "You have not been assigned to manage any grades."
|
||||||
|
},
|
||||||
|
"selectGrade": {
|
||||||
|
"title": "Select a Grade",
|
||||||
|
"description": "Select a grade to view insights."
|
||||||
|
},
|
||||||
|
"noInsights": {
|
||||||
|
"title": "No Insights Available",
|
||||||
|
"description": "There is not enough assignment or grade data to generate insights for this grade."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"loadFailed": "Failed to load",
|
||||||
|
"deleteFailed": "Failed to delete",
|
||||||
|
"noPermission": {
|
||||||
|
"title": "No Permission",
|
||||||
|
"description": "You do not have permission to perform this action."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"create": {
|
||||||
|
"title": "Create Grade"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "Edit Grade"
|
||||||
|
},
|
||||||
|
"field": {
|
||||||
|
"school": "School",
|
||||||
|
"name": "Grade Name",
|
||||||
|
"order": "Order",
|
||||||
|
"gradeHead": "Grade Head",
|
||||||
|
"teachingHead": "Teaching Head"
|
||||||
|
},
|
||||||
|
"placeholder": {
|
||||||
|
"school": "Select a school",
|
||||||
|
"name": "e.g. Grade 1",
|
||||||
|
"gradeHead": "Select grade head",
|
||||||
|
"teachingHead": "Select teaching head"
|
||||||
|
},
|
||||||
|
"button": {
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"create": "Create",
|
||||||
|
"save": "Save"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"schoolRequired": "Please select a school",
|
||||||
|
"nameRequired": "Please enter a grade name",
|
||||||
|
"nameTooLong": "Grade name cannot exceed 50 characters",
|
||||||
|
"nameDuplicate": "A grade with this name already exists in this school",
|
||||||
|
"orderInvalid": "Order must be an integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights": {
|
||||||
|
"assignments": {
|
||||||
|
"title": "Assignments",
|
||||||
|
"column": {
|
||||||
|
"assignment": "Assignment",
|
||||||
|
"status": "Status",
|
||||||
|
"createdAt": "Created At",
|
||||||
|
"targeted": "Targeted",
|
||||||
|
"submitted": "Submitted",
|
||||||
|
"graded": "Graded",
|
||||||
|
"avg": "Average",
|
||||||
|
"median": "Median"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ranking": {
|
||||||
|
"title": "Class Ranking",
|
||||||
|
"column": {
|
||||||
|
"class": "Class",
|
||||||
|
"students": "Students",
|
||||||
|
"latestAvg": "Latest Avg",
|
||||||
|
"prevAvg": "Previous Avg",
|
||||||
|
"delta": "Delta",
|
||||||
|
"overallAvg": "Overall Avg"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"classCount": "Classes",
|
||||||
|
"studentCount": "Students",
|
||||||
|
"studentDetail": "{count} students in total",
|
||||||
|
"overallAvg": "Overall Average",
|
||||||
|
"overallAvgDesc": "Weighted average across all grades",
|
||||||
|
"latestAvg": "Latest Average"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"title": "Grade List",
|
||||||
|
"notSet": "Not Set",
|
||||||
|
"column": {
|
||||||
|
"school": "School",
|
||||||
|
"grade": "Grade",
|
||||||
|
"order": "Order",
|
||||||
|
"gradeHead": "Grade Head",
|
||||||
|
"teachingHead": "Teaching Head",
|
||||||
|
"updatedAt": "Updated At",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"insights": "Insights",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"insights": {
|
||||||
|
"title": "Grade Insights"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"deleteSuccess": "Deleted successfully",
|
||||||
|
"createSuccess": "Created successfully",
|
||||||
|
"updateSuccess": "Updated successfully"
|
||||||
|
},
|
||||||
|
"toolbar": {
|
||||||
|
"search": "Search grades...",
|
||||||
|
"reset": "Reset",
|
||||||
|
"create": "Create Grade",
|
||||||
|
"filter": {
|
||||||
|
"school": "School",
|
||||||
|
"allSchools": "All Schools",
|
||||||
|
"head": "Head",
|
||||||
|
"allHeads": "All Heads",
|
||||||
|
"missingBoth": "Missing Both Heads",
|
||||||
|
"missingGradeHead": "Missing Grade Head",
|
||||||
|
"missingTeachingHead": "Missing Teaching Head",
|
||||||
|
"sort": "Sort",
|
||||||
|
"sortDefault": "Default",
|
||||||
|
"sortUpdatedDesc": "Recently Updated",
|
||||||
|
"sortUpdatedAsc": "Oldest Updated",
|
||||||
|
"sortNameAsc": "Name Ascending",
|
||||||
|
"sortNameDesc": "Name Descending",
|
||||||
|
"sortOrderAsc": "Order Ascending",
|
||||||
|
"sortOrderDesc": "Order Descending"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
143
src/shared/i18n/messages/en/grades.json
Normal file
143
src/shared/i18n/messages/en/grades.json
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
{
|
||||||
|
"title": {
|
||||||
|
"list": "Grade Records",
|
||||||
|
"entry": "Grade Entry",
|
||||||
|
"analytics": "Grade Analytics",
|
||||||
|
"stats": "Grade Statistics",
|
||||||
|
"myGrades": "My Grades",
|
||||||
|
"childrenGrades": "Children Grades"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"class": "Class",
|
||||||
|
"subject": "Subject",
|
||||||
|
"type": "Type",
|
||||||
|
"semester": "Semester",
|
||||||
|
"allClasses": "All classes",
|
||||||
|
"allSubjects": "All subjects",
|
||||||
|
"allTypes": "All types",
|
||||||
|
"allSemesters": "All semesters",
|
||||||
|
"searchPlaceholder": "Search by title..."
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"exam": "Exam",
|
||||||
|
"quiz": "Quiz",
|
||||||
|
"homework": "Homework",
|
||||||
|
"other": "Other"
|
||||||
|
},
|
||||||
|
"semester": {
|
||||||
|
"s1": "Semester 1",
|
||||||
|
"s2": "Semester 2"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"empty": "No grade records found.",
|
||||||
|
"columns": {
|
||||||
|
"student": "Student",
|
||||||
|
"class": "Class",
|
||||||
|
"subject": "Subject",
|
||||||
|
"title": "Title",
|
||||||
|
"score": "Score",
|
||||||
|
"type": "Type",
|
||||||
|
"semester": "Semester",
|
||||||
|
"recordedBy": "Recorded By",
|
||||||
|
"date": "Date"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"title": "Record Grade",
|
||||||
|
"save": "Save Record",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"selectClass": "Select a class",
|
||||||
|
"selectSubject": "Select a subject",
|
||||||
|
"selectStudent": "Select a student",
|
||||||
|
"titlePlaceholder": "e.g. Mid-term Exam",
|
||||||
|
"score": "Score",
|
||||||
|
"fullScore": "Full Score",
|
||||||
|
"remark": "Remark (optional)",
|
||||||
|
"remarkPlaceholder": "Notes about this grade...",
|
||||||
|
"selectPrompt": "Please select class, subject and student"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"title": "Delete Grade Record",
|
||||||
|
"confirmation": "Are you sure you want to delete this grade record? This action cannot be undone.",
|
||||||
|
"confirm": "Delete",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"deleting": "Deleting..."
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"detail": "Export Grade Details",
|
||||||
|
"classReport": "Export Class Grade Report",
|
||||||
|
"success": "Export succeeded",
|
||||||
|
"failed": "Export failed"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"title": "Statistics",
|
||||||
|
"average": "Average",
|
||||||
|
"median": "Median",
|
||||||
|
"max": "Max",
|
||||||
|
"min": "Min",
|
||||||
|
"stdDev": "Std Dev",
|
||||||
|
"variance": "Variance",
|
||||||
|
"passRate": "Pass Rate",
|
||||||
|
"excellentRate": "Excellent Rate",
|
||||||
|
"count": "Count"
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"trend": "Grade Trend",
|
||||||
|
"classComparison": "Class Comparison",
|
||||||
|
"subjectComparison": "Subject Comparison",
|
||||||
|
"distribution": "Grade Distribution",
|
||||||
|
"ranking": "Ranking",
|
||||||
|
"rankingTrend": "Ranking Trend",
|
||||||
|
"class": "Class",
|
||||||
|
"subject": "Subject",
|
||||||
|
"grade": "Grade",
|
||||||
|
"averageScore": "Average score, pass rate, excellent rate",
|
||||||
|
"passRate": "Pass Rate",
|
||||||
|
"excellentRate": "Excellent Rate",
|
||||||
|
"studentCount": "Student Count"
|
||||||
|
},
|
||||||
|
"batch": {
|
||||||
|
"title": "Batch Grade Entry",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"restored": "Restored unsaved grade draft",
|
||||||
|
"invalidScores": "Invalid scores found",
|
||||||
|
"fullScoreRequired": "Full score is required",
|
||||||
|
"saved": "Saved",
|
||||||
|
"score": "Score",
|
||||||
|
"remark": "Remark",
|
||||||
|
"fullScore": "Full Score",
|
||||||
|
"type": "Type",
|
||||||
|
"saveAll": "Save All",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
},
|
||||||
|
"trend": {
|
||||||
|
"title": "Grade Trend",
|
||||||
|
"empty": "No grade records yet",
|
||||||
|
"score": "Score",
|
||||||
|
"date": "Date"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"title": "Grade Summary",
|
||||||
|
"averageScore": "Average Score",
|
||||||
|
"classRank": "Class Rank",
|
||||||
|
"totalRecords": "Total Records",
|
||||||
|
"highestScore": "Highest Score",
|
||||||
|
"lowestScore": "Lowest Score"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"noRecords": "No grade records found.",
|
||||||
|
"noData": "No data available",
|
||||||
|
"noClassSelected": "Please select a class",
|
||||||
|
"noStudentSelected": "Please select a student"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"loadFailed": "Failed to load",
|
||||||
|
"saveFailed": "Failed to save",
|
||||||
|
"deleteFailed": "Failed to delete",
|
||||||
|
"exportFailed": "Failed to export",
|
||||||
|
"failedToCreate": "Failed to create",
|
||||||
|
"failedToDelete": "Failed to delete",
|
||||||
|
"retry": "Retry"
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/shared/i18n/messages/zh-CN/diagnostic.json
Normal file
87
src/shared/i18n/messages/zh-CN/diagnostic.json
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"title": {
|
||||||
|
"student": "学生学情诊断",
|
||||||
|
"class": "班级学情诊断",
|
||||||
|
"reportList": "诊断报告",
|
||||||
|
"myDiagnostic": "我的学情诊断"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"individual": "个人",
|
||||||
|
"class": "班级",
|
||||||
|
"grade": "年级"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"draft": "草稿",
|
||||||
|
"published": "已发布",
|
||||||
|
"archived": "已归档"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"reportType": "报告类型",
|
||||||
|
"status": "状态",
|
||||||
|
"allTypes": "全部类型",
|
||||||
|
"allStatuses": "全部状态"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"overallMastery": "总体掌握度",
|
||||||
|
"strengths": "强项",
|
||||||
|
"weaknesses": "弱项",
|
||||||
|
"students": "学生数",
|
||||||
|
"avgMastery": "平均掌握度",
|
||||||
|
"needAttention": "需重点关注",
|
||||||
|
"class": "班级",
|
||||||
|
"student": "学生"
|
||||||
|
},
|
||||||
|
"chart": {
|
||||||
|
"radarTitle": "知识点掌握度",
|
||||||
|
"radarDescription": "掌握度雷达图(学生 vs 班级平均)",
|
||||||
|
"heatmapTitle": "知识点掌握度热力图",
|
||||||
|
"rankingTitle": "知识点排名",
|
||||||
|
"noMasteryData": "暂无知识点掌握度记录"
|
||||||
|
},
|
||||||
|
"report": {
|
||||||
|
"generate": "生成诊断报告",
|
||||||
|
"generateStudent": "生成学生诊断报告",
|
||||||
|
"generateClass": "生成班级诊断报告",
|
||||||
|
"publish": "发布",
|
||||||
|
"delete": "删除",
|
||||||
|
"publishTitle": "发布报告",
|
||||||
|
"publishConfirmation": "确定要发布此报告吗?发布后将对相关人员可见。",
|
||||||
|
"deleteTitle": "删除报告",
|
||||||
|
"deleteConfirmation": "确定要删除此报告吗?此操作不可撤销。",
|
||||||
|
"confirm": "确认",
|
||||||
|
"cancel": "取消",
|
||||||
|
"publishing": "发布中...",
|
||||||
|
"deleting": "删除中...",
|
||||||
|
"recommendations": "学习建议",
|
||||||
|
"history": "报告历史",
|
||||||
|
"period": "周期",
|
||||||
|
"createdAt": "创建时间",
|
||||||
|
"generatedBy": "生成者",
|
||||||
|
"overallScore": "总体得分",
|
||||||
|
"actions": "操作"
|
||||||
|
},
|
||||||
|
"strengths": {
|
||||||
|
"title": "强项(≥80%)",
|
||||||
|
"practice": "练习",
|
||||||
|
"empty": "暂无强项知识点"
|
||||||
|
},
|
||||||
|
"weaknesses": {
|
||||||
|
"title": "弱项(<60%)",
|
||||||
|
"practice": "练习",
|
||||||
|
"empty": "暂无弱项知识点"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"noData": "暂无诊断数据",
|
||||||
|
"noClassData": "无法加载班级掌握度摘要",
|
||||||
|
"noMastery": "暂无知识点掌握度记录",
|
||||||
|
"noReports": "暂无诊断报告"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"generateFailed": "生成报告失败",
|
||||||
|
"generateClassFailed": "生成班级报告失败",
|
||||||
|
"publishFailed": "发布失败",
|
||||||
|
"deleteFailed": "删除失败",
|
||||||
|
"loadFailed": "加载失败",
|
||||||
|
"retry": "重试"
|
||||||
|
}
|
||||||
|
}
|
||||||
155
src/shared/i18n/messages/zh-CN/grade.json
Normal file
155
src/shared/i18n/messages/zh-CN/grade.json
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
{
|
||||||
|
"delete": {
|
||||||
|
"title": "删除年级",
|
||||||
|
"description": "确定要删除「{name}」吗?此操作不可撤销。",
|
||||||
|
"cancel": "取消",
|
||||||
|
"confirm": "删除"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"noGrades": {
|
||||||
|
"title": "暂无年级",
|
||||||
|
"description": "学校尚未创建任何年级,点击「新建年级」开始配置。"
|
||||||
|
},
|
||||||
|
"noMatch": {
|
||||||
|
"title": "无匹配结果",
|
||||||
|
"description": "尝试调整搜索条件或筛选器。"
|
||||||
|
},
|
||||||
|
"noAssignedGrades": {
|
||||||
|
"title": "未分配年级",
|
||||||
|
"description": "您尚未被分配管理任何年级。"
|
||||||
|
},
|
||||||
|
"selectGrade": {
|
||||||
|
"title": "请选择年级",
|
||||||
|
"description": "选择一个年级以查看洞察数据。"
|
||||||
|
},
|
||||||
|
"noInsights": {
|
||||||
|
"title": "暂无洞察数据",
|
||||||
|
"description": "所选年级暂无足够的作业或成绩数据生成洞察。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"loadFailed": "加载失败",
|
||||||
|
"deleteFailed": "删除失败",
|
||||||
|
"noPermission": {
|
||||||
|
"title": "无权限",
|
||||||
|
"description": "您没有权限执行此操作。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"create": {
|
||||||
|
"title": "新建年级"
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "编辑年级"
|
||||||
|
},
|
||||||
|
"field": {
|
||||||
|
"school": "学校",
|
||||||
|
"name": "年级名称",
|
||||||
|
"order": "排序",
|
||||||
|
"gradeHead": "年级主任",
|
||||||
|
"teachingHead": "教学主任"
|
||||||
|
},
|
||||||
|
"placeholder": {
|
||||||
|
"school": "选择学校",
|
||||||
|
"name": "如:一年级",
|
||||||
|
"gradeHead": "选择年级主任",
|
||||||
|
"teachingHead": "选择教学主任"
|
||||||
|
},
|
||||||
|
"button": {
|
||||||
|
"cancel": "取消",
|
||||||
|
"create": "创建",
|
||||||
|
"save": "保存"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"schoolRequired": "请选择学校",
|
||||||
|
"nameRequired": "请输入年级名称",
|
||||||
|
"nameTooLong": "年级名称不能超过 50 个字符",
|
||||||
|
"nameDuplicate": "该学校下已存在同名年级",
|
||||||
|
"orderInvalid": "排序必须为整数"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"insights": {
|
||||||
|
"assignments": {
|
||||||
|
"title": "作业列表",
|
||||||
|
"column": {
|
||||||
|
"assignment": "作业名称",
|
||||||
|
"status": "状态",
|
||||||
|
"createdAt": "创建时间",
|
||||||
|
"targeted": "目标人数",
|
||||||
|
"submitted": "已提交",
|
||||||
|
"graded": "已批改",
|
||||||
|
"avg": "平均分",
|
||||||
|
"median": "中位数"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ranking": {
|
||||||
|
"title": "班级排名",
|
||||||
|
"column": {
|
||||||
|
"class": "班级",
|
||||||
|
"students": "学生数",
|
||||||
|
"latestAvg": "最新平均分",
|
||||||
|
"prevAvg": "上次平均分",
|
||||||
|
"delta": "变化",
|
||||||
|
"overallAvg": "总体平均分"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"classCount": "班级数",
|
||||||
|
"studentCount": "学生数",
|
||||||
|
"studentDetail": "共 {count} 名学生",
|
||||||
|
"overallAvg": "总体平均分",
|
||||||
|
"overallAvgDesc": "年级所有成绩的加权平均",
|
||||||
|
"latestAvg": "最新平均分"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"title": "年级列表",
|
||||||
|
"notSet": "未设置",
|
||||||
|
"column": {
|
||||||
|
"school": "学校",
|
||||||
|
"grade": "年级",
|
||||||
|
"order": "排序",
|
||||||
|
"gradeHead": "年级主任",
|
||||||
|
"teachingHead": "教学主任",
|
||||||
|
"updatedAt": "更新时间",
|
||||||
|
"actions": "操作"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"insights": "洞察",
|
||||||
|
"edit": "编辑",
|
||||||
|
"delete": "删除"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"insights": {
|
||||||
|
"title": "年级洞察"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"deleteSuccess": "删除成功",
|
||||||
|
"createSuccess": "创建成功",
|
||||||
|
"updateSuccess": "更新成功"
|
||||||
|
},
|
||||||
|
"toolbar": {
|
||||||
|
"search": "搜索年级...",
|
||||||
|
"reset": "重置",
|
||||||
|
"create": "新建年级",
|
||||||
|
"filter": {
|
||||||
|
"school": "学校",
|
||||||
|
"allSchools": "全部学校",
|
||||||
|
"head": "主任",
|
||||||
|
"allHeads": "全部主任",
|
||||||
|
"missingBoth": "缺少双主任",
|
||||||
|
"missingGradeHead": "缺少年级主任",
|
||||||
|
"missingTeachingHead": "缺少教学主任",
|
||||||
|
"sort": "排序",
|
||||||
|
"sortDefault": "默认排序",
|
||||||
|
"sortUpdatedDesc": "最近更新",
|
||||||
|
"sortUpdatedAsc": "最早更新",
|
||||||
|
"sortNameAsc": "名称升序",
|
||||||
|
"sortNameDesc": "名称降序",
|
||||||
|
"sortOrderAsc": "排序升序",
|
||||||
|
"sortOrderDesc": "排序降序"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
143
src/shared/i18n/messages/zh-CN/grades.json
Normal file
143
src/shared/i18n/messages/zh-CN/grades.json
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
{
|
||||||
|
"title": {
|
||||||
|
"list": "成绩查询",
|
||||||
|
"entry": "成绩录入",
|
||||||
|
"analytics": "成绩分析",
|
||||||
|
"stats": "成绩统计",
|
||||||
|
"myGrades": "我的成绩",
|
||||||
|
"childrenGrades": "子女成绩"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"class": "班级",
|
||||||
|
"subject": "科目",
|
||||||
|
"type": "类型",
|
||||||
|
"semester": "学期",
|
||||||
|
"allClasses": "全部班级",
|
||||||
|
"allSubjects": "全部科目",
|
||||||
|
"allTypes": "全部类型",
|
||||||
|
"allSemesters": "全部学期",
|
||||||
|
"searchPlaceholder": "按标题搜索..."
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"exam": "考试",
|
||||||
|
"quiz": "测验",
|
||||||
|
"homework": "作业",
|
||||||
|
"other": "其他"
|
||||||
|
},
|
||||||
|
"semester": {
|
||||||
|
"s1": "第一学期",
|
||||||
|
"s2": "第二学期"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"empty": "暂无成绩记录",
|
||||||
|
"columns": {
|
||||||
|
"student": "学生",
|
||||||
|
"class": "班级",
|
||||||
|
"subject": "科目",
|
||||||
|
"title": "标题",
|
||||||
|
"score": "分数",
|
||||||
|
"type": "类型",
|
||||||
|
"semester": "学期",
|
||||||
|
"recordedBy": "录入人",
|
||||||
|
"date": "日期"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"title": "录入成绩",
|
||||||
|
"save": "保存",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"cancel": "取消",
|
||||||
|
"selectClass": "选择班级",
|
||||||
|
"selectSubject": "选择科目",
|
||||||
|
"selectStudent": "选择学生",
|
||||||
|
"titlePlaceholder": "如期中考试",
|
||||||
|
"score": "分数",
|
||||||
|
"fullScore": "满分",
|
||||||
|
"remark": "备注(可选)",
|
||||||
|
"remarkPlaceholder": "关于此成绩的备注...",
|
||||||
|
"selectPrompt": "请选择班级、科目和学生"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"title": "删除成绩记录",
|
||||||
|
"confirmation": "确定要删除此成绩记录吗?此操作不可撤销。",
|
||||||
|
"confirm": "删除",
|
||||||
|
"cancel": "取消",
|
||||||
|
"deleting": "删除中..."
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"detail": "导出成绩明细",
|
||||||
|
"classReport": "导出班级成绩总表",
|
||||||
|
"success": "导出成功",
|
||||||
|
"failed": "导出失败"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"title": "统计",
|
||||||
|
"average": "平均分",
|
||||||
|
"median": "中位数",
|
||||||
|
"max": "最高分",
|
||||||
|
"min": "最低分",
|
||||||
|
"stdDev": "标准差",
|
||||||
|
"variance": "方差",
|
||||||
|
"passRate": "及格率",
|
||||||
|
"excellentRate": "优秀率",
|
||||||
|
"count": "人数"
|
||||||
|
},
|
||||||
|
"analytics": {
|
||||||
|
"trend": "成绩趋势",
|
||||||
|
"classComparison": "班级对比",
|
||||||
|
"subjectComparison": "科目对比",
|
||||||
|
"distribution": "分数分布",
|
||||||
|
"ranking": "排名",
|
||||||
|
"rankingTrend": "排名趋势",
|
||||||
|
"class": "班级",
|
||||||
|
"subject": "科目",
|
||||||
|
"grade": "年级",
|
||||||
|
"averageScore": "平均分",
|
||||||
|
"passRate": "及格率",
|
||||||
|
"excellentRate": "优秀率",
|
||||||
|
"studentCount": "学生数"
|
||||||
|
},
|
||||||
|
"batch": {
|
||||||
|
"title": "批量录入",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"restored": "已恢复未保存的成绩草稿",
|
||||||
|
"invalidScores": "存在无效分数",
|
||||||
|
"fullScoreRequired": "满分必填",
|
||||||
|
"saved": "已录入",
|
||||||
|
"score": "分数",
|
||||||
|
"remark": "备注",
|
||||||
|
"fullScore": "满分",
|
||||||
|
"type": "类型",
|
||||||
|
"saveAll": "全部保存",
|
||||||
|
"cancel": "取消"
|
||||||
|
},
|
||||||
|
"trend": {
|
||||||
|
"title": "成绩趋势",
|
||||||
|
"empty": "暂无成绩记录",
|
||||||
|
"score": "分数",
|
||||||
|
"date": "日期"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"title": "成绩摘要",
|
||||||
|
"averageScore": "平均分",
|
||||||
|
"classRank": "班级排名",
|
||||||
|
"totalRecords": "总记录数",
|
||||||
|
"highestScore": "最高分",
|
||||||
|
"lowestScore": "最低分"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"noRecords": "暂无成绩记录",
|
||||||
|
"noData": "暂无数据",
|
||||||
|
"noClassSelected": "请选择班级",
|
||||||
|
"noStudentSelected": "请选择学生"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"loadFailed": "加载失败",
|
||||||
|
"saveFailed": "保存失败",
|
||||||
|
"deleteFailed": "删除失败",
|
||||||
|
"exportFailed": "导出失败",
|
||||||
|
"failedToCreate": "创建失败",
|
||||||
|
"failedToDelete": "删除失败",
|
||||||
|
"retry": "重试"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user