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:
115
src/shared/config/exam-homework-role-config.test.ts
Normal file
115
src/shared/config/exam-homework-role-config.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
223
src/shared/config/exam-homework-role-config.ts
Normal file
223
src/shared/config/exam-homework-role-config.ts
Normal 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
|
||||
}
|
||||
18
src/shared/hooks/use-exam-homework-features.ts
Normal file
18
src/shared/hooks/use-exam-homework-features.ts
Normal 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])
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "草稿",
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
112
src/shared/services/exam-homework-port.ts
Normal file
112
src/shared/services/exam-homework-port.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user