Files
NextEdu/src/modules/dashboard/lib/dashboard-utils.ts
SpecialX 868ac5f9cf feat(dashboard): 仪表盘模块审计重构 — 权限校验 + i18n + 逻辑抽离
基于 dashboard-audit-report.md 审计结论,对仪表盘模块进行 P0/P1 级修复:

- 新增 4 个 dashboard 权限点(DASHBOARD_ADMIN/TEACHER/STUDENT/PARENT_READ),补充到 permissions.ts 和角色-权限映射

- 新建 actions.ts:4 个 Server Action 均调用 requirePermission() 校验权限,消除 admin 页面零鉴权、teacher/student/parent 仅 requireAuth 的安全隐患

- 根重定向页 /dashboard 改用 resolvePermissions() + 权限点判断,不再 role === xxx 硬编码

- 新建 lib/dashboard-utils.ts:抽取 toWeekday / countStudentAssignments / sortUpcomingAssignments / filterTodaySchedule / computeTeacherMetrics / getGreetingKey 纯函数,与 UI 分离,便于单测

- 新建 messages/{zh-CN,en}/dashboard.json 翻译文件,i18n request.ts 加载 dashboard 命名空间;所有视图组件接入 useTranslations / getTranslations,消除中英混杂硬编码

- 重构 4 个角色 page.tsx:通过 actions 获取数据,generateMetadata 使用 i18n

- 同步更新架构图 004 / 005 文档(dashboard exports / permissions / 文件清单)
2026-06-22 15:50:56 +08:00

191 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 仪表盘纯逻辑工具函数(与 UI 分离,便于单测)。
*
* 所有函数均为纯函数:相同输入 → 相同输出,无副作用。
*/
import type {
HomeworkAssignmentListItem,
HomeworkSubmissionListItem,
StudentHomeworkAssignmentListItem,
StudentHomeworkProgressStatus,
TeacherGradeTrendItem,
} from "@/modules/homework/types"
import type { ClassScheduleItem, TeacherClass } from "@/modules/classes/types"
import type {
StudentTodayScheduleItem,
TeacherTodayScheduleItem,
TeacherDashboardData,
} from "@/modules/dashboard/types"
/** 周一=1 ... 周日=7 */
export type Weekday = 1 | 2 | 3 | 4 | 5 | 6 | 7
/**
* 将 Date 转换为 1-7 周几表示(周一=1周日=7
* getDay() 返回 0(周日)-6(周六),需映射为 1-7。
*/
export function toWeekday(d: Date): Weekday {
const day = d.getDay()
if (day < 0 || day > 6) {
throw new Error(`Invalid day from getDay(): ${day}`)
}
const WEEKDAY_MAP: readonly Weekday[] = [7, 1, 2, 3, 4, 5, 6]
return WEEKDAY_MAP[day]
}
/** 学生作业统计结果 */
export interface StudentAssignmentStats {
dueSoonCount: number
overdueCount: number
gradedCount: number
}
/**
* 单次遍历统计学生作业状态:即将到期 / 已逾期 / 已批改。
*/
export function countStudentAssignments(
assignments: readonly StudentHomeworkAssignmentListItem[],
now: Date,
dueSoonWindowDays = 7,
): StudentAssignmentStats {
const in7Days = new Date(now)
in7Days.setDate(in7Days.getDate() + dueSoonWindowDays)
let dueSoonCount = 0
let overdueCount = 0
let gradedCount = 0
for (const a of assignments) {
const status: StudentHomeworkProgressStatus = a.progressStatus
if (status === "graded") {
gradedCount++
continue
}
if (!a.dueAt) continue
const due = new Date(a.dueAt)
if (due >= now && due <= in7Days) {
dueSoonCount++
} else if (due < now) {
overdueCount++
}
}
return { dueSoonCount, overdueCount, gradedCount }
}
/**
* 按截止日期升序排序作业,取前 N 条作为「即将到期」列表。
* 无截止日期的作业排到最后。
*/
export function sortUpcomingAssignments(
assignments: readonly StudentHomeworkAssignmentListItem[],
limit = 6,
): StudentHomeworkAssignmentListItem[] {
return [...assignments]
.sort((a, b) => {
const aDue = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY
const bDue = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY
return aDue - bDue
})
.slice(0, limit)
}
/**
* 从课表中筛选指定周几的课程,按开始时间升序排序。
*/
export function filterTodaySchedule(
schedule: readonly ClassScheduleItem[],
weekday: Weekday,
classNameById?: ReadonlyMap<string, string>,
): StudentTodayScheduleItem[] | TeacherTodayScheduleItem[] {
return schedule
.filter((s) => s.weekday === weekday)
.sort((a, b) => a.startTime.localeCompare(b.startTime))
.map((s) => ({
id: s.id,
classId: s.classId,
className: classNameById?.get(s.classId) ?? "Class",
course: s.course,
startTime: s.startTime,
endTime: s.endTime,
location: s.location ?? null,
})) as StudentTodayScheduleItem[] | TeacherTodayScheduleItem[]
}
/** 教师仪表盘派生指标 */
export interface TeacherDashboardMetrics {
todayScheduleItems: TeacherTodayScheduleItem[]
toGradeCount: number
submissionsToGrade: HomeworkSubmissionListItem[]
activeAssignmentsCount: number
averageScore: number
submissionRate: number
}
/**
* 计算教师仪表盘派生指标:待批改数、进行中作业数、平均分、提交率等。
*/
export function computeTeacherMetrics(
classes: readonly TeacherClass[],
schedule: readonly ClassScheduleItem[],
assignments: readonly HomeworkAssignmentListItem[],
submissions: readonly HomeworkSubmissionListItem[],
gradeTrends: readonly TeacherGradeTrendItem[],
now: Date,
): TeacherDashboardMetrics {
const todayWeekday = toWeekday(now)
const classNameById = new Map(classes.map((c) => [c.id, c.name] as const))
const todayScheduleItems = filterTodaySchedule(
schedule,
todayWeekday,
classNameById,
) as TeacherTodayScheduleItem[]
const submittedSubmissions = submissions.filter((s) => Boolean(s.submittedAt))
const toGradeCount = submittedSubmissions.filter((s) => s.status === "submitted").length
const submissionsToGrade = submittedSubmissions
.filter((s) => s.status === "submitted")
.sort(
(a, b) =>
(a.submittedAt ? new Date(a.submittedAt).getTime() : 0) -
(b.submittedAt ? new Date(b.submittedAt).getTime() : 0),
)
.slice(0, 6)
const activeAssignmentsCount = assignments.filter((a) => a.status === "published").length
const totalTrendScore = gradeTrends.reduce((acc, curr) => acc + curr.averageScore, 0)
const averageScore = gradeTrends.length > 0 ? totalTrendScore / gradeTrends.length : 0
const totalSubmissions = gradeTrends.reduce((acc, curr) => acc + curr.submissionCount, 0)
const totalPotentialSubmissions = gradeTrends.reduce((acc, curr) => acc + curr.totalStudents, 0)
const submissionRate =
totalPotentialSubmissions > 0 ? (totalSubmissions / totalPotentialSubmissions) * 100 : 0
return {
todayScheduleItems,
toGradeCount,
submissionsToGrade,
activeAssignmentsCount,
averageScore,
submissionRate,
}
}
/**
* 根据当前小时返回问候语时段 keymorning / afternoon / evening
*/
export function getGreetingKey(now: Date): "morning" | "afternoon" | "evening" {
const hour = now.getHours()
if (hour < 12) return "morning"
if (hour < 18) return "afternoon"
return "evening"
}
/** 重导出 TeacherDashboardData 便于 actions 使用 */
export type { TeacherDashboardData }