fix(dashboard): v3 审计修复 — 数据完整性、i18n、类型安全、死代码清理

P0 修复(严重):
- admin ContentRow 标签与值错配(stats.users→textbooks 等 6 处)
- admin/error.tsx 硬编码中文替换为 useTranslations
- UserGrowthChart 空数据时渲染 EmptyState(userGrowth/homeworkTrend 永远为空数组)

P1 修复(高):
- 新增 admin/dashboard 和 student/dashboard 的 loading.tsx + error.tsx
- 抽取 DashboardLoadingSkeleton 和 DashboardErrorFallback 共享组件,消除 5 套重复文件
- formatDate/formatLongDate 传入用户 locale(admin/teacher/student 共 6 个组件)
- 移除死代码:getCachedAdminDashboard、AvatarImage src={undefined}、TeacherStats isLoading prop
- filterTodaySchedule 改为泛型函数,消除 as 类型断言
- 辅助函数 getStatus/getDueUrgency 新增显式返回类型
- UserGrowthChart 新增 labelKey prop 区分用户增长/作业提交趋势标签

P2 修复(中):
- 4 个组件从客户端转为服务端组件(DashboardGreetingHeader、TeacherQuickActions、TeacherDashboardHeader、StudentDashboardHeader)
- Student dashboard 空状态新增 CTA(viewSchedule、viewAll)
- TeacherHomeworkCard 图标按钮新增 aria-label
- TeacherTodoCard 排序逻辑重写为可读的 if/return 模式

同步更新:
- docs/architecture/005_architecture_data.json 新增 DashboardLoadingSkeleton、DashboardErrorFallback 条目
- 新增 docs/architecture/audit/dashboard-audit-report-v3.md 审计报告
- dashboard.json 新增 6 个 i18n 键(textbooks/chapters/questions/exams/totalAssignments/totalSubmissions)
This commit is contained in:
SpecialX
2026-06-22 18:36:46 +08:00
parent f62b8c0f86
commit 682d385ee2
41 changed files with 4387 additions and 1979 deletions

View File

@@ -0,0 +1,115 @@
/**
* 6.5: 单测覆盖 ExamHomeworkRoleConfig 角色特性合并逻辑
*/
import { describe, it, expect } from "vitest"
import {
getExamHomeworkFeatures,
EXAM_HOMEWORK_ROLE_CONFIG,
DEFAULT_EXAM_HOMEWORK_FEATURES,
type ExamHomeworkRoleFeatures,
} from "./exam-homework-role-config"
describe("EXAM_HOMEWORK_ROLE_CONFIG", () => {
it("admin has all management features but not student take", () => {
const admin = EXAM_HOMEWORK_ROLE_CONFIG.admin
expect(admin.canCreate).toBe(true)
expect(admin.canManage).toBe(true)
expect(admin.canPublish).toBe(true)
expect(admin.canGrade).toBe(true)
expect(admin.canProctor).toBe(true)
expect(admin.canUseAi).toBe(true)
expect(admin.canViewStats).toBe(true)
expect(admin.canTake).toBe(false)
})
it("teacher has create/grade/build but not student take", () => {
const teacher = EXAM_HOMEWORK_ROLE_CONFIG.teacher
expect(teacher.canCreate).toBe(true)
expect(teacher.canBuild).toBe(true)
expect(teacher.canGrade).toBe(true)
expect(teacher.canUseAi).toBe(true)
expect(teacher.canTake).toBe(false)
})
it("student has take and viewResult but not manage/create", () => {
const student = EXAM_HOMEWORK_ROLE_CONFIG.student
expect(student.canTake).toBe(true)
expect(student.canViewResult).toBe(true)
expect(student.canCreate).toBe(false)
expect(student.canManage).toBe(false)
expect(student.canGrade).toBe(false)
expect(student.canPublish).toBe(false)
})
it("grade_head can grade/publish/proctor but not create/build", () => {
const gh = EXAM_HOMEWORK_ROLE_CONFIG.grade_head
expect(gh.canGrade).toBe(true)
expect(gh.canPublish).toBe(true)
expect(gh.canProctor).toBe(true)
expect(gh.canCreate).toBe(false)
expect(gh.canBuild).toBe(false)
expect(gh.canUseAi).toBe(false)
})
it("parent can only view results", () => {
const parent = EXAM_HOMEWORK_ROLE_CONFIG.parent
expect(parent.canViewResult).toBe(true)
expect(parent.canCreate).toBe(false)
expect(parent.canRead).toBe(false)
expect(parent.canTake).toBe(false)
expect(parent.canGrade).toBe(false)
})
})
describe("getExamHomeworkFeatures", () => {
it("returns default (all-false) for empty roles", () => {
const features = getExamHomeworkFeatures([])
expect(features).toEqual(DEFAULT_EXAM_HOMEWORK_FEATURES)
})
it("returns admin features for admin role", () => {
const features = getExamHomeworkFeatures(["admin"])
expect(features.canCreate).toBe(true)
expect(features.canManage).toBe(true)
expect(features.canTake).toBe(false)
})
it("returns student features for student role", () => {
const features = getExamHomeworkFeatures(["student"])
expect(features.canTake).toBe(true)
expect(features.canCreate).toBe(false)
})
it("merges features via union for multiple roles", () => {
// A user with both teacher and student roles should have both canCreate and canTake
const features = getExamHomeworkFeatures(["teacher", "student"])
expect(features.canCreate).toBe(true)
expect(features.canGrade).toBe(true)
expect(features.canTake).toBe(true)
expect(features.canViewResult).toBe(true)
})
it("ignores unknown roles gracefully", () => {
// Cast to bypass type-check for testing runtime safety
const features = getExamHomeworkFeatures(["unknown_role" as never])
expect(features).toEqual(DEFAULT_EXAM_HOMEWORK_FEATURES)
})
it("DEFAULT_EXAM_HOMEWORK_FEATURES has all features disabled", () => {
const allFalse: ExamHomeworkRoleFeatures = {
canCreate: false,
canRead: false,
canManage: false,
canPublish: false,
canBuild: false,
canGrade: false,
canProctor: false,
canTake: false,
canViewResult: false,
canUseAi: false,
canViewStats: false,
}
expect(DEFAULT_EXAM_HOMEWORK_FEATURES).toEqual(allFalse)
})
})

View File

@@ -0,0 +1,223 @@
/**
* P2-13: 配置驱动的角色渲染 ExamHomeworkRoleConfig
*
* 单一数据源:声明每个角色在考试/作业模块中可见的功能区与可执行的操作。
* 配合 usePermission() 使用,作为 UI 渲染的防御性筛选层(权限系统仍为最终授权源)。
*
* 使用方式:
* const features = getRoleFeatures(roles)
* if (features.canCreateExam) { render <CreateExamButton /> }
*/
import type { Permission, Role } from "@/shared/types/permissions"
import { Permissions } from "@/shared/types/permissions"
/**
* 考试/作业模块中按角色控制的功能区。
* 字段语义:`can*` 表示是否可见/可执行某类操作。
*/
export interface ExamHomeworkRoleFeatures {
/** 创建考试/作业(教师、管理员) */
canCreate: boolean
/** 查看考试/作业列表(所有有读权限的角色) */
canRead: boolean
/** 编辑/删除考试/作业(教师、管理员) */
canManage: boolean
/** 发布考试(教师、年级组长、教务主任) */
canPublish: boolean
/** 组卷/编辑作业题目(教师、管理员) */
canBuild: boolean
/** 批改作业/考试(教师、年级组长、教务主任) */
canGrade: boolean
/** 监考(教师、年级组长、教务主任) */
canProctor: boolean
/** 学生作答(学生) */
canTake: boolean
/** 查看成绩/反馈(学生、家长) */
canViewResult: boolean
/** AI 生成考试(教师、管理员) */
canUseAi: boolean
/** 查看统计/分析(教师、年级组长、教务主任、管理员) */
canViewStats: boolean
}
/**
* 角色到权限点的映射,作为渲染配置的依据。
* 每个角色对应一组 Permission 常量,用于推导 features。
*/
export const EXAM_HOMEWORK_ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
admin: [
Permissions.EXAM_CREATE,
Permissions.EXAM_READ,
Permissions.EXAM_UPDATE,
Permissions.EXAM_DELETE,
Permissions.EXAM_PUBLISH,
Permissions.EXAM_AI_GENERATE,
Permissions.EXAM_PROCTOR,
Permissions.HOMEWORK_CREATE,
Permissions.HOMEWORK_GRADE,
Permissions.HOMEWORK_SUBMIT,
],
teacher: [
Permissions.EXAM_CREATE,
Permissions.EXAM_READ,
Permissions.EXAM_UPDATE,
Permissions.EXAM_PUBLISH,
Permissions.EXAM_AI_GENERATE,
Permissions.EXAM_PROCTOR,
Permissions.HOMEWORK_CREATE,
Permissions.HOMEWORK_GRADE,
],
grade_head: [
Permissions.EXAM_READ,
Permissions.EXAM_PUBLISH,
Permissions.EXAM_PROCTOR,
Permissions.HOMEWORK_GRADE,
],
teaching_head: [
Permissions.EXAM_READ,
Permissions.EXAM_PUBLISH,
Permissions.EXAM_PROCTOR,
Permissions.HOMEWORK_GRADE,
],
student: [
Permissions.EXAM_READ,
Permissions.EXAM_SUBMIT,
Permissions.HOMEWORK_SUBMIT,
],
parent: [],
} as const
/**
* 角色到功能区配置的映射。
* 这是渲染层的单一数据源UI 根据 features 决定显示哪些入口/按钮。
*/
export const EXAM_HOMEWORK_ROLE_CONFIG: Record<Role, ExamHomeworkRoleFeatures> = {
admin: {
canCreate: true,
canRead: true,
canManage: true,
canPublish: true,
canBuild: true,
canGrade: true,
canProctor: true,
canTake: false,
canViewResult: true,
canUseAi: true,
canViewStats: true,
},
teacher: {
canCreate: true,
canRead: true,
canManage: true,
canPublish: true,
canBuild: true,
canGrade: true,
canProctor: true,
canTake: false,
canViewResult: true,
canUseAi: true,
canViewStats: true,
},
grade_head: {
canCreate: false,
canRead: true,
canManage: false,
canPublish: true,
canBuild: false,
canGrade: true,
canProctor: true,
canTake: false,
canViewResult: true,
canUseAi: false,
canViewStats: true,
},
teaching_head: {
canCreate: false,
canRead: true,
canManage: false,
canPublish: true,
canBuild: false,
canGrade: true,
canProctor: true,
canTake: false,
canViewResult: true,
canUseAi: false,
canViewStats: true,
},
student: {
canCreate: false,
canRead: true,
canManage: false,
canPublish: false,
canBuild: false,
canGrade: false,
canProctor: false,
canTake: true,
canViewResult: true,
canUseAi: false,
canViewStats: false,
},
parent: {
canCreate: false,
canRead: false,
canManage: false,
canPublish: false,
canBuild: false,
canGrade: false,
canProctor: false,
canTake: false,
canViewResult: true,
canUseAi: false,
canViewStats: false,
},
}
/**
* 默认(无角色)特性集:所有功能关闭。
* 用于未登录或角色未识别的兜底场景。
*/
export const DEFAULT_EXAM_HOMEWORK_FEATURES: ExamHomeworkRoleFeatures = {
canCreate: false,
canRead: false,
canManage: false,
canPublish: false,
canBuild: false,
canGrade: false,
canProctor: false,
canTake: false,
canViewResult: false,
canUseAi: false,
canViewStats: false,
}
/**
* 根据用户角色列表合并出最终的功能特性。
*
* 合并策略:任一角色拥有某功能即视为启用(并集)。
* 例如:同时具有 teacher + grade_head 的用户,将获得两个角色的所有能力。
*
* @param roles 当前用户的角色列表
* @returns 合并后的功能特性
*/
export function getExamHomeworkFeatures(roles: readonly Role[]): ExamHomeworkRoleFeatures {
if (roles.length === 0) return DEFAULT_EXAM_HOMEWORK_FEATURES
const merged: ExamHomeworkRoleFeatures = { ...DEFAULT_EXAM_HOMEWORK_FEATURES }
for (const role of roles) {
const cfg = EXAM_HOMEWORK_ROLE_CONFIG[role]
if (!cfg) continue
merged.canCreate = merged.canCreate || cfg.canCreate
merged.canRead = merged.canRead || cfg.canRead
merged.canManage = merged.canManage || cfg.canManage
merged.canPublish = merged.canPublish || cfg.canPublish
merged.canBuild = merged.canBuild || cfg.canBuild
merged.canGrade = merged.canGrade || cfg.canGrade
merged.canProctor = merged.canProctor || cfg.canProctor
merged.canTake = merged.canTake || cfg.canTake
merged.canViewResult = merged.canViewResult || cfg.canViewResult
merged.canUseAi = merged.canUseAi || cfg.canUseAi
merged.canViewStats = merged.canViewStats || cfg.canViewStats
}
return merged
}

View File

@@ -0,0 +1,18 @@
"use client"
/**
* P2-13: 客户端 Hook封装 ExamHomeworkRoleConfig 与 usePermission。
*
* 使用方式:
* const features = useExamHomeworkFeatures()
* if (features.canCreate) { render <CreateButton /> }
*/
import { useMemo } from "react"
import { usePermission } from "@/shared/hooks/use-permission"
import { getExamHomeworkFeatures } from "@/shared/config/exam-homework-role-config"
export function useExamHomeworkFeatures() {
const { roles } = usePermission()
return useMemo(() => getExamHomeworkFeatures(roles), [roles])
}

View File

@@ -44,7 +44,8 @@
"2": "Easy-Med",
"3": "Medium",
"4": "Med-Hard",
"5": "Hard"
"5": "Hard",
"ariaLabel": "Difficulty level {{level}}: {{label}}"
},
"actions": {
"preview": "Preview Exam",
@@ -191,6 +192,8 @@
"noDescription": "No description provided.",
"progress": "Progress",
"jumpToQuestion": "Jump to question {{index}}",
"answered": "Answered",
"unanswered": "Not answered",
"yourAnswer": "Your answer",
"answerPlaceholder": "Type your answer here...",
"true": "True",
@@ -198,7 +201,13 @@
"unsupportedType": "Unsupported question type",
"teacherFeedback": "Teacher Feedback",
"noFeedback": "No specific feedback provided.",
"makeSureAnswered": "Make sure you have answered all questions."
"makeSureAnswered": "Make sure you have answered all questions.",
"autoSaveIdle": "Auto-save ready",
"autoSaveSaving": "Auto-saving...",
"autoSaveSaved": "Auto-saved",
"autoSaveError": "Auto-save failed. Will retry when network recovers.",
"autoSaveRestored": "Restored unsaved answers from offline cache",
"autoSaveCacheError": "Failed to restore offline cache"
},
"grade": {
"title": "Grade",
@@ -248,7 +257,19 @@
"correctAnswer": "Correct Answer",
"teacherFeedback": "Teacher Feedback",
"score": "Score",
"maxScore": "Max Score"
"maxScore": "Max Score",
"correctMarker": "✓ Correct",
"backToList": "Back to List",
"gradedReport": "Graded Report",
"submissionDetails": "Submission Details",
"questionsUnit": "Questions",
"noAnswer": "No answer provided",
"noFeedback": "No specific feedback provided.",
"questionBreakdown": "Question Breakdown",
"responseSummary": "Response Summary",
"description": "Description",
"noDescription": "No description provided.",
"totalScore": "Total Score"
},
"status": {
"draft": "Draft",

View File

@@ -44,7 +44,8 @@
"2": "偏易",
"3": "中等",
"4": "偏难",
"5": "困难"
"5": "困难",
"ariaLabel": "难度等级 {{level}}{{label}}"
},
"actions": {
"preview": "预览考试",
@@ -191,6 +192,8 @@
"noDescription": "无描述。",
"progress": "进度",
"jumpToQuestion": "跳转到第 {{index}} 题",
"answered": "已作答",
"unanswered": "未作答",
"yourAnswer": "你的答案",
"answerPlaceholder": "在此输入答案...",
"true": "正确",
@@ -198,7 +201,13 @@
"unsupportedType": "不支持的题型",
"teacherFeedback": "教师反馈",
"noFeedback": "无具体反馈。",
"makeSureAnswered": "请确保已作答所有题目。"
"makeSureAnswered": "请确保已作答所有题目。",
"autoSaveIdle": "自动保存已就绪",
"autoSaveSaving": "正在自动保存...",
"autoSaveSaved": "已自动保存",
"autoSaveError": "自动保存失败,将在网络恢复后重试",
"autoSaveRestored": "已从离线缓存恢复未提交的答案",
"autoSaveCacheError": "离线缓存恢复失败"
},
"grade": {
"title": "批改",
@@ -248,7 +257,19 @@
"correctAnswer": "正确答案",
"teacherFeedback": "教师反馈",
"score": "得分",
"maxScore": "满分"
"maxScore": "满分",
"correctMarker": "✓ 正确",
"backToList": "返回列表",
"gradedReport": "批改报告",
"submissionDetails": "提交详情",
"questionsUnit": "道题",
"noAnswer": "未作答",
"noFeedback": "无具体反馈。",
"questionBreakdown": "题目分布",
"responseSummary": "作答概览",
"description": "描述",
"noDescription": "无描述。",
"totalScore": "总分"
},
"status": {
"draft": "草稿",

View File

@@ -40,6 +40,24 @@ export type EventName =
| "elective.course_selected"
| "elective.course_dropped"
| "elective.lottery_completed"
// 6.7: 考试/作业模块监控事件
| "exam.created"
| "exam.updated"
| "exam.published"
| "exam.archived"
| "exam.deleted"
| "exam.duplicated"
| "exam.ai_generated"
| "exam.submitted"
| "exam.graded"
| "homework.created"
| "homework.updated"
| "homework.published"
| "homework.archived"
| "homework.deleted"
| "homework.submitted"
| "homework.graded"
| "homework.auto_save_failed"
/** 埋点事件负载 */
export interface TrackEventPayload {
@@ -90,3 +108,36 @@ export async function trackEvent(payload: TrackEventPayload): Promise<void> {
// 埋点失败不影响主流程
}
}
/**
* 6.7: 考试/作业模块专用埋点函数
*
* 封装 trackEvent自动设置 targetType简化调用方代码。
*
* @example
* ```ts
* await trackExamEvent("exam.published", { userId: ctx.userId, targetId: examId })
* await trackExamEvent("homework.submitted", {
* userId: ctx.userId,
* targetId: submissionId,
* properties: { questionCount, duration: 1200 }
* })
* ```
*/
export async function trackExamEvent(
event: Extract<EventName, `exam.${string}` | `homework.${string}`>,
params: {
userId?: string
targetId?: string
properties?: Record<string, unknown>
}
): Promise<void> {
const targetType = event.startsWith("exam.") ? "exam" : "homework"
await trackEvent({
event,
userId: params.userId,
targetId: params.targetId,
targetType,
properties: params.properties,
})
}

View File

@@ -0,0 +1,112 @@
/**
* 6.1: ExamHomeworkServicePort — 考试/作业模块的服务端口接口
*
* 目的:
* - 为 app 层提供一个稳定的调用契约,屏蔽 modules 内部实现细节
* - 便于单元测试时注入 mock 实现(无需真实 DB
* - 为未来可能的远程服务BFF/API gateway预留替换点
*
* 使用方式:
* import { EXAM_HOMEWORK_SERVICE_PROVIDER, type ExamHomeworkServicePort } from "@/shared/services/exam-homework-port"
* const service = EXAM_HOMEWORK_SERVICE_PROVIDER.get()
* const exam = await service.getExamById(id, scope)
*
* 实际实现位于 modules/exams/data-access.ts 与 modules/homework/data-access.ts
* 通过 registerExamHomeworkService() 在应用启动时注入。
*/
import type { DataScope } from "@/shared/types/permissions"
import type { Exam } from "@/modules/exams/types"
import type { HomeworkAssignmentListItem } from "@/modules/homework/types"
/**
* 考试/作业模块对外暴露的服务契约。
* 仅包含 app 层和跨模块调用所需的方法,不暴露内部实现细节。
*/
export interface ExamHomeworkServicePort {
// ===== 考试 =====
/** 按 ID 获取考试(带数据范围过滤) */
getExamById(id: string, scope?: DataScope): Promise<Exam | null>
/** 获取考试列表 */
getExams(params: {
scope?: DataScope
subjectId?: string
gradeId?: string
status?: string
search?: string
}): Promise<Exam[]>
/** 获取考试创建者 ID用于权限校验 */
getExamCreatorId(examId: string): Promise<string | null>
/** 获取考试标题(跨模块引用时使用,避免全量加载) */
getExamTitleById(examId: string): Promise<string | null>
// ===== 作业 =====
/** 按 ID 获取作业 */
getHomeworkAssignmentById(id: string, scope?: DataScope): Promise<HomeworkAssignmentListItem | null>
/** 获取教师创建的作业列表 */
getHomeworkAssignments(params: {
creatorId?: string
classId?: string
scope?: DataScope
}): Promise<HomeworkAssignmentListItem[]>
/** 获取作业的最大分值(批量) */
getAssignmentMaxScoreByIds(assignmentIds: string[]): Promise<Map<string, number>>
// ===== 跨模块 =====
/** 获取考试及其题目(供作业模块引用考试内容时使用) */
getExamWithQuestionsForHomework(examId: string): Promise<{
exam: Pick<Exam, "id" | "title" | "totalScore" | "durationMin">
questions: Array<{
id: string
questionType: string
questionContent: unknown
maxScore: number
}>
} | null>
}
/**
* 服务提供者:单例注册 + 解析。
*
* - `register(impl)` 在应用启动时调用一次(如 instrumentation.ts
* - `get()` 在运行时获取当前实现
* - 未注册时抛出明确错误,避免静默失败
*/
class ServiceProvider<T> {
private impl: T | null = null
register(impl: T): void {
if (this.impl !== null) {
// 允许重复注册HMR 场景),覆盖旧实现
if (process.env.NODE_ENV !== "production") {
console.warn("[ServiceProvider] Re-registering service implementation (HMR?)")
}
}
this.impl = impl
}
get(): T {
if (this.impl === null) {
throw new Error(
"[ExamHomeworkServicePort] No implementation registered. " +
"Call registerExamHomeworkService() during application startup."
)
}
return this.impl
}
/** 测试专用:重置为未注册状态 */
reset(): void {
this.impl = null
}
}
export const EXAM_HOMEWORK_SERVICE_PROVIDER = new ServiceProvider<ExamHomeworkServicePort>()
/**
* 注册考试/作业服务实现。
* 应在应用启动时调用(如 instrumentation.ts 或模块初始化)。
*/
export function registerExamHomeworkService(impl: ExamHomeworkServicePort): void {
EXAM_HOMEWORK_SERVICE_PROVIDER.register(impl)
}