refactor(dashboard): V2 审计重构 — i18n 补齐 + 共享抽象 + 单测 + a11y

V2 审计报告(docs/architecture/audit/dashboard-audit-report-v2.md)发现并修复:

- P0 i18n:10 个子组件硬编码字符串全部接入 next-intl(teacher-quick-actions /
  teacher-classes-card / teacher-homework-card / teacher-schedule /
  recent-submissions / teacher-grade-trends / student-grades-card /
  student-today-schedule-card / student-upcoming-assignments-card /
  admin-dashboard),新增 ~50 个翻译键
- P1 共享抽象:新增 DashboardGreetingHeader 组件,消除 teacher/student
  头部 90% 重复代码,两个 Header 改为薄包装
- P2 单测:为 6 个纯函数添加 31 个单元测试
  (tests/integration/dashboard/dashboard-utils.test.ts)
- P2 a11y:admin 表格 caption、teacher/student 视图语义化标签
  (header / section aria-label / aside aria-label)
- 同步架构图 004/005
This commit is contained in:
SpecialX
2026-06-22 17:01:00 +08:00
parent 10c668f36a
commit e997abaf5e
41 changed files with 1811 additions and 516 deletions

View File

@@ -0,0 +1,408 @@
import { describe, it, expect } from "vitest"
import {
toWeekday,
countStudentAssignments,
sortUpcomingAssignments,
filterTodaySchedule,
computeTeacherMetrics,
getGreetingKey,
type Weekday,
} from "@/modules/dashboard/lib/dashboard-utils"
import type { StudentHomeworkAssignmentListItem } from "@/modules/homework/types"
import type { ClassScheduleItem, TeacherClass } from "@/modules/classes/types"
import type {
HomeworkAssignmentListItem,
HomeworkSubmissionListItem,
TeacherGradeTrendItem,
} from "@/modules/homework/types"
describe("toWeekday", () => {
it("returns 1 for Monday", () => {
const monday = new Date("2026-06-22T00:00:00") // 2026-06-22 is Monday
expect(toWeekday(monday)).toBe(1 as Weekday)
})
it("returns 7 for Sunday", () => {
const sunday = new Date("2026-06-28T00:00:00") // 2026-06-28 is Sunday
expect(toWeekday(sunday)).toBe(7 as Weekday)
})
it("returns 6 for Saturday", () => {
const saturday = new Date("2026-06-27T00:00:00") // 2026-06-27 is Saturday
expect(toWeekday(saturday)).toBe(6 as Weekday)
})
it("returns 2 for Tuesday", () => {
const tuesday = new Date("2026-06-23T00:00:00") // 2026-06-23 is Tuesday
expect(toWeekday(tuesday)).toBe(2 as Weekday)
})
})
describe("getGreetingKey", () => {
it("returns morning before 12:00", () => {
expect(getGreetingKey(new Date("2026-06-22T08:00:00"))).toBe("morning")
expect(getGreetingKey(new Date("2026-06-22T11:59:59"))).toBe("morning")
})
it("returns afternoon between 12:00 and 18:00", () => {
expect(getGreetingKey(new Date("2026-06-22T12:00:00"))).toBe("afternoon")
expect(getGreetingKey(new Date("2026-06-22T17:59:59"))).toBe("afternoon")
})
it("returns evening at 18:00 or later", () => {
expect(getGreetingKey(new Date("2026-06-22T18:00:00"))).toBe("evening")
expect(getGreetingKey(new Date("2026-06-22T23:59:59"))).toBe("evening")
})
})
describe("countStudentAssignments", () => {
const now = new Date("2026-06-22T10:00:00")
it("returns zero counts for empty list", () => {
expect(countStudentAssignments([], now)).toEqual({
dueSoonCount: 0,
overdueCount: 0,
gradedCount: 0,
})
})
it("counts graded assignments", () => {
const assignments = [
makeAssignment("1", "graded", null),
makeAssignment("2", "graded", null),
]
expect(countStudentAssignments(assignments, now)).toEqual({
dueSoonCount: 0,
overdueCount: 0,
gradedCount: 2,
})
})
it("counts due soon (within 7 days)", () => {
const in3Days = new Date(now)
in3Days.setDate(in3Days.getDate() + 3)
const assignments = [
makeAssignment("1", "in_progress", in3Days.toISOString()),
]
expect(countStudentAssignments(assignments, now).dueSoonCount).toBe(1)
})
it("counts overdue", () => {
const past = new Date(now)
past.setDate(past.getDate() - 1)
const assignments = [
makeAssignment("1", "submitted", past.toISOString()),
]
expect(countStudentAssignments(assignments, now).overdueCount).toBe(1)
})
it("ignores assignments without dueAt (non-graded)", () => {
const assignments = [
makeAssignment("1", "in_progress", null),
]
expect(countStudentAssignments(assignments, now)).toEqual({
dueSoonCount: 0,
overdueCount: 0,
gradedCount: 0,
})
})
it("respects custom dueSoonWindowDays", () => {
const in15Days = new Date(now)
in15Days.setDate(in15Days.getDate() + 15)
const assignments = [
makeAssignment("1", "in_progress", in15Days.toISOString()),
]
// default 7-day window: not due soon
expect(countStudentAssignments(assignments, now).dueSoonCount).toBe(0)
// 30-day window: due soon
expect(countStudentAssignments(assignments, now, 30).dueSoonCount).toBe(1)
})
})
describe("sortUpcomingAssignments", () => {
it("returns empty array for empty input", () => {
expect(sortUpcomingAssignments([])).toEqual([])
})
it("sorts by dueAt ascending", () => {
const assignments = [
makeAssignment("1", "in_progress", "2026-06-25T00:00:00"),
makeAssignment("2", "in_progress", "2026-06-23T00:00:00"),
makeAssignment("3", "in_progress", "2026-06-24T00:00:00"),
]
const sorted = sortUpcomingAssignments(assignments)
expect(sorted.map((a) => a.id)).toEqual(["2", "3", "1"])
})
it("puts assignments without dueAt at the end", () => {
const assignments = [
makeAssignment("1", "in_progress", null),
makeAssignment("2", "in_progress", "2026-06-23T00:00:00"),
]
const sorted = sortUpcomingAssignments(assignments)
expect(sorted.map((a) => a.id)).toEqual(["2", "1"])
})
it("respects limit parameter", () => {
const assignments = Array.from({ length: 10 }, (_, i) =>
makeAssignment(`${i}`, "in_progress", `2026-06-${23 + i}T00:00:00`),
)
expect(sortUpcomingAssignments(assignments, 3)).toHaveLength(3)
})
it("does not mutate original array", () => {
const assignments = [
makeAssignment("1", "in_progress", "2026-06-25T00:00:00"),
makeAssignment("2", "in_progress", "2026-06-23T00:00:00"),
]
sortUpcomingAssignments(assignments)
expect(assignments.map((a) => a.id)).toEqual(["1", "2"])
})
})
describe("filterTodaySchedule", () => {
const weekday = 1 as Weekday // Monday
it("returns empty array for empty schedule", () => {
expect(filterTodaySchedule([], weekday)).toEqual([])
})
it("filters by weekday", () => {
const schedule: ClassScheduleItem[] = [
makeScheduleItem("1", 1, "08:00"),
makeScheduleItem("2", 2, "09:00"),
makeScheduleItem("3", 1, "07:00"),
]
const result = filterTodaySchedule(schedule, weekday)
expect(result).toHaveLength(2)
expect((result as Array<{ id: string }>).map((s) => s.id)).toEqual(["3", "1"])
})
it("sorts by startTime ascending", () => {
const schedule: ClassScheduleItem[] = [
makeScheduleItem("1", 1, "10:00"),
makeScheduleItem("2", 1, "08:00"),
makeScheduleItem("3", 1, "09:00"),
]
const result = filterTodaySchedule(schedule, weekday) as Array<{ id: string }>
expect(result.map((s) => s.id)).toEqual(["2", "3", "1"])
})
it("uses classNameById map when provided", () => {
const schedule: ClassScheduleItem[] = [
makeScheduleItem("1", 1, "08:00"),
]
const classNameById = new Map([["class-1", "Math 101"]])
const result = filterTodaySchedule(schedule, weekday, classNameById) as Array<{ className: string }>
expect(result[0].className).toBe("Math 101")
})
it("falls back to 'Class' when classNameById missing", () => {
const schedule: ClassScheduleItem[] = [
makeScheduleItem("1", 1, "08:00"),
]
const result = filterTodaySchedule(schedule, weekday) as Array<{ className: string }>
expect(result[0].className).toBe("Class")
})
})
describe("computeTeacherMetrics", () => {
const now = new Date("2026-06-22T10:00:00") // Monday
it("returns zero metrics for empty inputs", () => {
const metrics = computeTeacherMetrics([], [], [], [], [], now)
expect(metrics.toGradeCount).toBe(0)
expect(metrics.activeAssignmentsCount).toBe(0)
expect(metrics.averageScore).toBe(0)
expect(metrics.submissionRate).toBe(0)
expect(metrics.todayScheduleItems).toEqual([])
expect(metrics.submissionsToGrade).toEqual([])
})
it("counts toGrade from submitted (non-graded) submissions", () => {
const submissions: HomeworkSubmissionListItem[] = [
makeSubmission("1", "submitted"),
makeSubmission("2", "submitted"),
makeSubmission("3", "graded"),
]
const metrics = computeTeacherMetrics([], [], [], submissions, [], now)
expect(metrics.toGradeCount).toBe(2)
})
it("counts active assignments (published)", () => {
const assignments: HomeworkAssignmentListItem[] = [
makeAssignmentTeacher("1", "published"),
makeAssignmentTeacher("2", "published"),
makeAssignmentTeacher("3", "draft"),
]
const metrics = computeTeacherMetrics([], [], assignments, [], [], now)
expect(metrics.activeAssignmentsCount).toBe(2)
})
it("computes averageScore from gradeTrends", () => {
const trends: TeacherGradeTrendItem[] = [
makeTrend("1", 80),
makeTrend("2", 90),
]
const metrics = computeTeacherMetrics([], [], [], [], trends, now)
expect(metrics.averageScore).toBe(85)
})
it("computes submissionRate as percentage", () => {
const trends: TeacherGradeTrendItem[] = [
makeTrendWithSubmissions("1", 8, 10),
makeTrendWithSubmissions("2", 6, 10),
]
const metrics = computeTeacherMetrics([], [], [], [], trends, now)
// (8 + 6) / (10 + 10) * 100 = 70
expect(metrics.submissionRate).toBe(70)
})
it("returns submissionRate 0 when no students", () => {
const trends: TeacherGradeTrendItem[] = [
makeTrendWithSubmissions("1", 0, 0),
]
const metrics = computeTeacherMetrics([], [], [], [], trends, now)
expect(metrics.submissionRate).toBe(0)
})
it("filters today schedule by current weekday", () => {
const classes: TeacherClass[] = [
makeClass("c1", "Math 101"),
]
const schedule: ClassScheduleItem[] = [
makeScheduleItem("s1", 1, "08:00"), // Monday
makeScheduleItem("s2", 2, "09:00"), // Tuesday
]
const metrics = computeTeacherMetrics(classes, schedule, [], [], [], now)
expect(metrics.todayScheduleItems).toHaveLength(1)
})
it("limits submissionsToGrade to 6 items", () => {
const submissions: HomeworkSubmissionListItem[] = Array.from({ length: 10 }, (_, i) =>
makeSubmission(`${i}`, "submitted"),
)
const metrics = computeTeacherMetrics([], [], [], submissions, [], now)
expect(metrics.submissionsToGrade).toHaveLength(6)
})
})
// ============ Helpers ============
function makeAssignment(
id: string,
progressStatus: string,
dueAt: string | null,
): StudentHomeworkAssignmentListItem {
return {
id,
title: `Assignment ${id}`,
subjectName: null,
dueAt,
availableAt: null,
maxAttempts: 1,
attemptsUsed: 0,
progressStatus: progressStatus as StudentHomeworkAssignmentListItem["progressStatus"],
latestSubmissionId: null,
latestSubmittedAt: null,
latestScore: null,
}
}
function makeScheduleItem(
id: string,
weekday: number,
startTime: string,
): ClassScheduleItem {
return {
id,
classId: `class-${id}`,
weekday: weekday as ClassScheduleItem["weekday"],
startTime,
endTime: "09:00",
course: `Course ${id}`,
location: null,
} as ClassScheduleItem
}
function makeSubmission(
id: string,
status: string,
): HomeworkSubmissionListItem {
return {
id,
assignmentId: `assignment-${id}`,
assignmentTitle: `Assignment ${id}`,
studentName: `Student ${id}`,
status: status as HomeworkSubmissionListItem["status"],
submittedAt: "2026-06-22T10:00:00",
isLate: false,
score: null,
}
}
function makeAssignmentTeacher(
id: string,
status: string,
): HomeworkAssignmentListItem {
return {
id,
sourceExamId: null,
sourceExamTitle: null,
title: `Assignment ${id}`,
status: status as HomeworkAssignmentListItem["status"],
availableAt: null,
dueAt: null,
allowLate: false,
lateDueAt: null,
maxAttempts: 1,
createdAt: "2026-06-01T00:00:00",
updatedAt: "2026-06-01T00:00:00",
targetCount: 0,
submittedCount: 0,
gradedCount: 0,
averageScore: null,
overdueCount: 0,
}
}
function makeTrend(id: string, averageScore: number): TeacherGradeTrendItem {
return {
id,
title: `Trend ${id}`,
averageScore,
maxScore: 100,
submissionCount: 10,
totalStudents: 10,
createdAt: "2026-06-01T00:00:00",
}
}
function makeTrendWithSubmissions(
id: string,
submissionCount: number,
totalStudents: number,
): TeacherGradeTrendItem {
return {
id,
title: `Trend ${id}`,
averageScore: 80,
maxScore: 100,
submissionCount,
totalStudents,
createdAt: "2026-06-01T00:00:00",
}
}
function makeClass(id: string, name: string): TeacherClass {
return {
id,
name,
grade: "G1",
homeroom: null,
room: null,
studentCount: 0,
} as TeacherClass
}