feat(teacher-portal): 完成参考项目差距闭环 P3-P7 全量实现

- P3 考试/作业/成绩 mutation + 详情页 + 批改界面 + 乐观更新 + 多 Tab 同步
- P4 知识图谱 SVG 可视化 + 学情分析仪表盘 + parent-portal Remote
- P5 WebSocket 通知中心 + AI 出题(SSE) + AI 教案 + AI 学情报告
- P6 可观测性硬化:Sentry + WebVitals + OTel + A11y + 性能配置 + Cookie 迁移
- P7 参考项目差距闭环:新增 35 个页面覆盖 13 个缺失模块
  - attendance(考勤 4 页)/questions(题库)/textbooks(教材 2 页)
  - classes/[id] 详情 + classes/schedule 课表
  - course-plans(2 页)/diagnostic(2 页)/error-book/practice
  - exams/[id]/build 组卷 + exams/[id]/analytics 考后分析
  - exams/[id]/edit-rich 富文本编辑 + exams/[id]/proctoring 监考
  - grades/entry 批量录入 + grades/stats 统计 + grades/analytics 分析 + grades/report-card 报告卡
  - homework/submissions 列表 + assignments/[id]/submissions 批量批改
  - homework/submissions/[submissionId] 单份批改 + scan-grading 扫描批改
  - lesson-plans 编辑器 + library + calendar + heatmap 5 页
  - elective 选修课 3 页 /leave 请假 /schedule-changes 调课
- P7 基础设施:61 GraphQL operations + 5 handlers + 13 fixtures + 11 viewports
- 集成 browser.ts/server.ts 注册所有 p7 handlers(fallthrough 顺序)
- viewports.ts 扩展 11 个新导航项
- 验证:tsc --noEmit 零错误 + eslint 零错误
- 文档:workline.md 新增 §5 P7 参考项目差距闭环(含完整文件清单)
This commit is contained in:
SpecialX
2026-07-13 14:27:04 +08:00
parent f13ca612e6
commit d49d211425
117 changed files with 30867 additions and 108 deletions

View File

@@ -0,0 +1,122 @@
/**
* GraphQL P4 扩展 Query - 知识图谱 + 学情分析
*
* 维护者ai13teacher-portal
* 关联graphql.ts P2/P3 扩展、workline.md §3 P4 交付物
*
* schema 尚未在 ARB-001 P2 第一版中定义,开发期通过 MSW 拦截返回 mock 响应;
* 上游就绪后由 ai03 在 teacher-bff.graphql 扩展,届时合并本文件到 graphql.ts。
*
* 查询范围P4
* - KnowledgeGraph班级/科目知识点图谱(节点 + 前置依赖边)
* - ClassAnalytics班级学情分析均分/及格率/趋势/Top 学生/弱项)
* - StudentAnalytics单生学情分析趋势/弱项/强项/掌握度)
*/
import { gql } from "urql";
// ============ Types对齐 P4 schema 设计) ============
/** 知识图谱节点(知识点) */
export interface KnowledgeNode {
id: string;
name: string;
subject: string;
description: string | null;
masteryLevel: number; // 0-100
}
/** 知识图谱边(前置依赖关系) */
export interface KnowledgeEdge {
from: string;
to: string;
type: "PREREQUISITE" | "RELATED";
}
/** 知识图谱完整结构 */
export interface KnowledgeGraphData {
nodes: KnowledgeNode[];
edges: KnowledgeEdge[];
}
/** 单生学情分析 */
export interface StudentAnalytics {
studentId: string;
studentName: string;
avgScore: number;
trend: number[]; // 最近 5 次成绩趋势
weakPoints: string[]; // 弱项知识点名称
strongPoints: string[];
masteryRate: number; // 掌握度 0-100
}
/** 班级学情分析 */
export interface ClassAnalytics {
classId: string;
className: string;
avgScore: number;
passRate: number;
avgTrend: number[];
topStudents: StudentAnalytics[];
weakPoints: string[];
}
// ============ P4 Query ============
/** 知识图谱查询(按班级 + 科目过滤) */
export const KnowledgeGraphQuery = gql`
query KnowledgeGraph($classId: ID, $subject: String) {
knowledgeGraph(classId: $classId, subject: $subject) {
nodes {
id
name
subject
description
masteryLevel
}
edges {
from
to
type
}
}
}
`;
/** 班级学情分析查询 */
export const ClassAnalyticsQuery = gql`
query ClassAnalytics($classId: ID!) {
classAnalytics(classId: $classId) {
classId
className
avgScore
passRate
avgTrend
topStudents {
studentId
studentName
avgScore
trend
weakPoints
strongPoints
masteryRate
}
weakPoints
}
}
`;
/** 单生学情分析查询 */
export const StudentAnalyticsQuery = gql`
query StudentAnalytics($studentId: ID!) {
studentAnalytics(studentId: $studentId) {
studentId
studentName
avgScore
trend
weakPoints
strongPoints
masteryRate
}
}
`;

View File

@@ -0,0 +1,137 @@
/**
* GraphQL operations - P5 扩展(通知中心 + AI 辅助)
*
* 维护者ai13teacher-portal
* 关联02-architecture-design.md §5 实时推送、§6 AI 辅助
*
* schema 来源teacher-bff.graphqlP5 扩展MSW mock待 ai03 在上游补 schema
* - 通知myNotifications / markAsReadpush-gateway 实时推送 + GraphQL 持久化)
* - AIgenerateQuestion / generateLessonPlan / generateReport
*
* 注意:本文件独立于 graphql.tsP2/P3不修改共享文件最后统一合并。
*/
import { gql } from "urql";
// ============ Types通知中心 ============
export type NotificationType =
| "HOMEWORK_SUBMITTED"
| "EXAM_GRADED"
| "BROADCAST"
| "ROLE_CHANGED";
export interface NotificationItem {
id: string;
type: NotificationType;
title: string;
message: string;
read: boolean;
createdAt: string;
}
// ============ TypesAI 辅助) ============
export interface GenerateQuestionInput {
subject: string;
questionType: string;
difficulty: string;
knowledgePoints: string;
count: number;
}
export interface GeneratedQuestion {
id: string;
question: string;
options: string[];
answer: string;
explanation: string;
}
export interface GenerateLessonPlanInput {
classId: string;
subject: string;
topic: string;
objectives: string;
}
export interface GeneratedLessonPlan {
id: string;
content: string;
summary: string;
}
export interface GenerateReportInput {
classId: string;
reportType: string;
studentId?: string;
}
export interface GeneratedReport {
id: string;
content: string;
summary: string;
recommendations: string[];
}
// ============ Queries / Mutations ============
/** 通知列表查询 */
export const MyNotificationsQuery = gql`
query MyNotifications($unreadOnly: Boolean) {
myNotifications(unreadOnly: $unreadOnly) {
id
type
title
message
read
createdAt
}
}
`;
/** 标记已读 */
export const MarkAsReadMutation = gql`
mutation MarkAsRead($id: ID!) {
markAsRead(id: $id) {
id
read
}
}
`;
/** AI 辅助出题SSE 流式mock 用 ReadableStream */
export const GenerateQuestionMutation = gql`
mutation GenerateQuestion($input: GenerateQuestionInput!) {
generateQuestion(input: $input) {
id
question
options
answer
explanation
}
}
`;
/** AI 生成教案 */
export const GenerateLessonPlanMutation = gql`
mutation GenerateLessonPlan($input: GenerateLessonPlanInput!) {
generateLessonPlan(input: $input) {
id
content
summary
}
}
`;
/** AI 学情报告 */
export const GenerateReportMutation = gql`
mutation GenerateReport($input: GenerateReportInput!) {
generateReport(input: $input) {
id
content
summary
recommendations
}
}
`;

View File

@@ -0,0 +1,713 @@
/**
* GraphQL operations - P7 扩展(行政管理子模块:考勤 + 班级详情 + 课表 + 请假 + 调课)
*
* 维护者ai13teacher-portal
* 关联graphql.ts P2/P3 扩展、workline.md §3 P7 交付物
*
* schema 尚未在 ARB-001 P2 第一版中定义,开发期通过 MSW 拦截返回 mock 响应;
* 上游就绪后由 ai03 在 teacher-bff.graphql 扩展,届时合并本文件到 graphql.ts。
*
* 查询范围P7-admin
* - AttendanceList考勤记录列表按 classId/date/status 筛选 + 分页)
* - AttendanceSheet批量录入用学生 + 当天状态
* - SaveAttendanceSheet批量保存考勤录入
* - AttendanceStats班级考勤统计出勤率/迟到率/早退率/请假率/预警/趋势)
* - AttendanceReport周报/月报数据
* - ClassDetail班级详情聚合基本信息 + 学生 + 课表 + 近期作业 + 概览统计)
* - ClassSchedule班级课表周一到周日 × 节次)
* - LeaveRequests教师请假审批列表
* - ApproveLeaveRequest批准/拒绝请假
* - SubmitLeaveRequest教师提交请假/调课申请
* - ScheduleChanges我的调课申请列表
*/
import { gql } from "urql";
import type { DataScope } from "./graphql";
// ============ Types考勤 ============
/** 考勤状态 */
export type AttendanceStatus =
| "PRESENT"
| "ABSENT"
| "LATE"
| "EARLY_LEAVE"
| "LEAVE";
/** 考勤记录项 */
export interface AttendanceRecord {
id: string;
classId: string;
className: string;
studentId: string;
studentName: string;
studentNo: string;
date: string; // YYYY-MM-DD
status: AttendanceStatus;
note: string | null;
}
/** 考勤列表筛选条件 */
export interface AttendanceListFilter {
classId?: string | null;
startDate?: string | null;
endDate?: string | null;
statuses?: AttendanceStatus[] | null;
page?: number | null;
pageSize?: number | null;
}
/** 考勤列表分页结果 */
export interface AttendanceListResult {
items: AttendanceRecord[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
/** 考勤录入项(按学生 + 日期) */
export interface AttendanceSheetItem {
studentId: string;
studentName: string;
studentNo: string;
date: string;
status: AttendanceStatus | null;
note: string | null;
}
/** 考勤批量保存项 */
export interface SaveAttendanceRecordInput {
studentId: string;
status: AttendanceStatus;
note?: string | null;
}
/** 考勤批量保存输入 */
export interface SaveAttendanceSheetInput {
classId: string;
date: string;
records: SaveAttendanceRecordInput[];
}
/** 考勤批量保存结果 */
export interface SaveAttendanceSheetResult {
classId: string;
date: string;
savedCount: number;
savedAt: string;
}
// ============ Types考勤统计 ============
/** 班级考勤统计 */
export interface AttendanceStats {
classId: string;
className: string;
startDate: string;
endDate: string;
totalRecords: number;
presentCount: number;
absentCount: number;
lateCount: number;
earlyLeaveCount: number;
leaveCount: number;
presentRate: number;
lateRate: number;
earlyLeaveRate: number;
leaveRate: number;
warningCount: number;
trend: AttendanceTrendPoint[];
}
/** 考勤趋势点 */
export interface AttendanceTrendPoint {
date: string;
presentRate: number;
lateRate: number;
absentRate: number;
}
/** 班级考勤对比项 */
export interface AttendanceClassComparison {
classId: string;
className: string;
presentRate: number;
lateRate: number;
absentRate: number;
}
/** 学生考勤预警项 */
export interface AttendanceWarning {
studentId: string;
studentName: string;
studentNo: string;
className: string;
presentRate: number;
absentCount: number;
lateCount: number;
}
// ============ Types考勤报告 ============
/** 报告类型 */
export type AttendanceReportType = "WEEKLY" | "MONTHLY";
/** 考勤报告数据 */
export interface AttendanceReport {
classId: string;
className: string;
reportType: AttendanceReportType;
startDate: string;
endDate: string;
generatedAt: string;
summary: AttendanceReportSummary;
details: AttendanceReportDetail[];
}
/** 考勤报告摘要 */
export interface AttendanceReportSummary {
totalRecords: number;
presentRate: number;
lateRate: number;
earlyLeaveRate: number;
leaveRate: number;
warningCount: number;
}
/** 考勤报告明细 */
export interface AttendanceReportDetail {
studentId: string;
studentName: string;
studentNo: string;
presentCount: number;
absentCount: number;
lateCount: number;
earlyLeaveCount: number;
leaveCount: number;
presentRate: number;
}
// ============ Types班级详情 ============
/** 班级详情 */
export interface ClassDetail {
id: string;
name: string;
gradeId: string;
subject: string;
studentCount: number;
homeroomTeacher: string;
room: string | null;
schoolName: string;
}
/** 班级详情聚合 */
export interface ClassDetailAggregate {
class: ClassDetail;
students: ClassDetailStudent[];
schedule: ClassScheduleItem[];
recentHomework: ClassDetailHomework[];
overview: ClassOverviewStats;
trend: ClassDetailTrendPoint[];
}
/** 班级详情学生 */
export interface ClassDetailStudent {
id: string;
name: string;
studentNo: string;
email: string;
}
/** 班级详情近期作业 */
export interface ClassDetailHomework {
id: string;
title: string;
dueDate: string;
submissionRate: number;
avgScore: number | null;
}
/** 班级概览统计 */
export interface ClassOverviewStats {
attendanceRate: number;
averageScore: number;
homeworkCompletionRate: number;
recentExamTitle: string | null;
recentExamAvg: number | null;
}
/** 班级详情趋势点 */
export interface ClassDetailTrendPoint {
month: string;
avgScore: number;
}
// ============ Types课表 ============
/** 课表项 */
export interface ClassScheduleItem {
id: string;
classId: string;
className: string;
dayOfWeek: number; // 1=周一 ... 7=周日
period: number; // 第 N 节1-8
subject: string;
teacherName: string;
room: string;
startTime: string;
endTime: string;
}
// ============ Types请假 ============
/** 请假类型 */
export type LeaveType = "SICK" | "PERSONAL" | "ANNUAL" | "OTHER";
/** 请假申请状态 */
export type LeaveStatus = "PENDING" | "APPROVED" | "REJECTED";
/** 请假申请项 */
export interface LeaveRequest {
id: string;
applicantId: string;
applicantName: string;
applicantRole: string;
type: LeaveType;
startDate: string;
endDate: string;
reason: string;
status: LeaveStatus;
submittedAt: string;
reviewedAt: string | null;
reviewerId: string | null;
reviewerName: string | null;
reviewComment: string | null;
}
/** 请假列表筛选 */
export interface LeaveRequestsFilter {
dataScope?: DataScope | null;
status?: LeaveStatus | null;
page?: number | null;
pageSize?: number | null;
}
/** 请假列表分页结果 */
export interface LeaveRequestsResult {
items: LeaveRequest[];
total: number;
page: number;
pageSize: number;
}
/** 审批请假输入 */
export interface ApproveLeaveRequestInput {
requestId: string;
action: "APPROVE" | "REJECT";
comment?: string | null;
}
/** 审批请假结果 */
export interface ApproveLeaveRequestResult {
requestId: string;
status: LeaveStatus;
reviewedAt: string;
reviewerId: string;
reviewerName: string;
reviewComment: string | null;
}
/** 提交请假申请输入 */
export interface SubmitLeaveRequestInput {
type: LeaveType;
startDate: string;
endDate: string;
reason: string;
}
/** 提交请假申请结果 */
export interface SubmitLeaveRequestResult {
requestId: string;
status: LeaveStatus;
submittedAt: string;
}
// ============ Types调课 ============
/** 调课类型 */
export type ScheduleChangeType = "ADJUST" | "SUBSTITUTE";
/** 调课状态 */
export type ScheduleChangeStatus = "PENDING" | "APPROVED" | "REJECTED";
/** 调课申请项 */
export interface ScheduleChange {
id: string;
requesterId: string;
requesterName: string;
classId: string;
className: string;
originalDate: string;
originalPeriod: number;
originalSubject: string;
adjustedDate: string;
adjustedPeriod: number;
adjustedSubject: string;
substituteTeacherId: string | null;
substituteTeacherName: string | null;
type: ScheduleChangeType;
reason: string;
status: ScheduleChangeStatus;
submittedAt: string;
reviewedAt: string | null;
reviewComment: string | null;
}
/** 调课列表分页结果 */
export interface ScheduleChangesResult {
items: ScheduleChange[];
total: number;
page: number;
pageSize: number;
}
/** 提交调课申请输入 */
export interface SubmitScheduleChangeInput {
classId: string;
originalDate: string;
originalPeriod: number;
originalSubject: string;
adjustedDate: string;
adjustedPeriod: number;
adjustedSubject: string;
substituteTeacherId?: string | null;
type: ScheduleChangeType;
reason: string;
}
/** 提交调课申请结果 */
export interface SubmitScheduleChangeResult {
requestId: string;
status: ScheduleChangeStatus;
submittedAt: string;
}
// ============ Queries / Mutations ============
/** 考勤记录列表查询 */
export const AttendanceListQuery = gql`
query AttendanceList($filter: AttendanceListFilterInput) {
attendanceList(filter: $filter) {
items {
id
classId
className
studentId
studentName
studentNo
date
status
note
}
total
page
pageSize
totalPages
}
}
`;
/** 考勤录入查询(学生列表 + 当天状态) */
export const AttendanceSheetQuery = gql`
query AttendanceSheet($classId: ID!, $date: String!) {
attendanceSheet(classId: $classId, date: $date) {
studentId
studentName
studentNo
date
status
note
}
}
`;
/** 批量保存考勤 Mutation */
export const SaveAttendanceSheetMutation = gql`
mutation SaveAttendanceSheet($input: SaveAttendanceSheetInput!) {
saveAttendanceSheet(input: $input) {
classId
date
savedCount
savedAt
}
}
`;
/** 考勤统计查询 */
export const AttendanceStatsQuery = gql`
query AttendanceStats($classId: ID!, $startDate: String!, $endDate: String!) {
attendanceStats(classId: $classId, startDate: $startDate, endDate: $endDate) {
classId
className
startDate
endDate
totalRecords
presentCount
absentCount
lateCount
earlyLeaveCount
leaveCount
presentRate
lateRate
earlyLeaveRate
leaveRate
warningCount
trend {
date
presentRate
lateRate
absentRate
}
}
}
`;
/** 班级考勤对比查询 */
export const AttendanceClassComparisonQuery = gql`
query AttendanceClassComparison($startDate: String!, $endDate: String!) {
attendanceClassComparison(startDate: $startDate, endDate: $endDate) {
classId
className
presentRate
lateRate
absentRate
}
}
`;
/** 学生考勤预警查询 */
export const AttendanceWarningsQuery = gql`
query AttendanceWarnings($classId: ID!, $startDate: String!, $endDate: String!) {
attendanceWarnings(classId: $classId, startDate: $startDate, endDate: $endDate) {
studentId
studentName
studentNo
className
presentRate
absentCount
lateCount
}
}
`;
/** 考勤报告查询 */
export const AttendanceReportQuery = gql`
query AttendanceReport(
$classId: ID!
$reportType: AttendanceReportType!
$startDate: String!
$endDate: String!
) {
attendanceReport(
classId: $classId
reportType: $reportType
startDate: $startDate
endDate: $endDate
) {
classId
className
reportType
startDate
endDate
generatedAt
summary {
totalRecords
presentRate
lateRate
earlyLeaveRate
leaveRate
warningCount
}
details {
studentId
studentName
studentNo
presentCount
absentCount
lateCount
earlyLeaveCount
leaveCount
presentRate
}
}
}
`;
/** 班级详情查询 */
export const ClassDetailQuery = gql`
query ClassDetail($id: ID!) {
classDetail(id: $id) {
class {
id
name
gradeId
subject
studentCount
homeroomTeacher
room
schoolName
}
students {
id
name
studentNo
email
}
schedule {
id
classId
className
dayOfWeek
period
subject
teacherName
room
startTime
endTime
}
recentHomework {
id
title
dueDate
submissionRate
avgScore
}
overview {
attendanceRate
averageScore
homeworkCompletionRate
recentExamTitle
recentExamAvg
}
trend {
month
avgScore
}
}
}
`;
/** 班级课表查询 */
export const ClassScheduleQuery = gql`
query ClassSchedule($classId: ID) {
classSchedule(classId: $classId) {
id
classId
className
dayOfWeek
period
subject
teacherName
room
startTime
endTime
}
}
`;
/** 请假审批列表查询 */
export const LeaveRequestsQuery = gql`
query LeaveRequests($filter: LeaveRequestsFilterInput) {
leaveRequests(filter: $filter) {
items {
id
applicantId
applicantName
applicantRole
type
startDate
endDate
reason
status
submittedAt
reviewedAt
reviewerId
reviewerName
reviewComment
}
total
page
pageSize
}
}
`;
/** 审批请假 Mutation */
export const ApproveLeaveRequestMutation = gql`
mutation ApproveLeaveRequest($input: ApproveLeaveRequestInput!) {
approveLeaveRequest(input: $input) {
requestId
status
reviewedAt
reviewerId
reviewerName
reviewComment
}
}
`;
/** 提交请假申请 Mutation */
export const SubmitLeaveRequestMutation = gql`
mutation SubmitLeaveRequest($input: SubmitLeaveRequestInput!) {
submitLeaveRequest(input: $input) {
requestId
status
submittedAt
}
}
`;
/** 调课申请列表查询 */
export const ScheduleChangesQuery = gql`
query ScheduleChanges($page: Int, $pageSize: Int) {
scheduleChanges(page: $page, pageSize: $pageSize) {
items {
id
requesterId
requesterName
classId
className
originalDate
originalPeriod
originalSubject
adjustedDate
adjustedPeriod
adjustedSubject
substituteTeacherId
substituteTeacherName
type
reason
status
submittedAt
reviewedAt
reviewComment
}
total
page
pageSize
}
}
`;
/** 提交调课申请 Mutation */
export const SubmitScheduleChangeMutation = gql`
mutation SubmitScheduleChange($input: SubmitScheduleChangeInput!) {
submitScheduleChange(input: $input) {
requestId
status
submittedAt
}
}
`;

View File

@@ -0,0 +1,499 @@
/**
* GraphQL operations - P7 扩展(选修课 + 富文本编辑 + 监考 + 扫描批改 + 课标热力图)
*
* 维护者ai13teacher-portal
* 关联graphql.ts P2/P3 扩展、workline.md §3 P7 交付物
*
* schema 尚未在 ARB-001 P2 第一版中定义,开发期通过 MSW 拦截返回 mock 响应;
* 上游就绪后由 ai03 在 teacher-bff.graphql 扩展,届时合并本文件到 graphql.ts。
*
* 查询范围P7-advanced
* - ElectiveCourses / ElectiveCourseDetail选修课列表 + 详情
* - CreateElectiveCourse / UpdateElectiveCourse / PublishElectiveCourse / CancelElectiveCourse
* - ExamRichEditor / SaveExamRichContent富文本试卷内容
* - ProctoringStatus / ProctoringEvent监考状态 + 事件
* - SubmissionScanGrading / SaveScanGrading扫描批改
* - LessonPlanHeatmap课标覆盖热力图
*/
import { gql } from "urql";
// ============ Types选修课 ============
/** 选修课状态 */
export type ElectiveCourseStatus =
| "draft"
| "open"
| "closed"
| "cancelled";
/** 选课学生项 */
export interface ElectiveStudent {
studentId: string;
studentName: string;
studentNo: string;
enrolledAt: string;
status: "enrolled" | "cancelled";
}
/** 选修课列表项 */
export interface ElectiveCourseItem {
id: string;
title: string;
subject: string;
grade: string;
capacity: number;
enrolledCount: number;
teacherId: string;
teacherName: string;
semester: string;
status: ElectiveCourseStatus;
description: string | null;
}
/** 选修课详情(含学生列表) */
export interface ElectiveCourseDetail extends ElectiveCourseItem {
students: ElectiveStudent[];
createdAt: string;
updatedAt: string;
}
/** 创建选修课输入 */
export interface CreateElectiveCourseInput {
title: string;
subject: string;
grade: string;
capacity: number;
semester: string;
description?: string | null;
}
/** 更新选修课输入 */
export interface UpdateElectiveCourseInput {
id: string;
title?: string;
subject?: string;
grade?: string;
capacity?: number;
semester?: string;
description?: string | null;
}
/** 创建/更新结果 */
export interface ElectiveCourseMutationResult {
id: string;
status: ElectiveCourseStatus;
updatedAt: string;
}
// ============ Types富文本编辑 ============
/** 富文本试卷内容Tiptap examNodes JSON 结构mock 用 JSON */
export interface ExamRichContent {
examId: string;
title: string;
totalScore: number;
questionCount: number;
content: unknown; // Tiptap JSON结构由编辑器决定
updatedAt: string;
}
/** 保存富文本输入 */
export interface SaveExamRichContentInput {
examId: string;
content: unknown;
}
/** 保存富文本结果 */
export interface SaveExamRichContentResult {
examId: string;
updatedAt: string;
}
// ============ Types监考 ============
/** 监考学生状态 */
export type ProctoringStudentStatus =
| "online"
| "offline"
| "submitted"
| "flagged";
/** 监考摘要 */
export interface ProctoringSummary {
examId: string;
expectedCount: number;
onlineCount: number;
submittedCount: number;
flaggedCount: number;
isProctoring: boolean;
startedAt: string | null;
}
/** 监考学生状态项 */
export interface ProctoringStudent {
studentId: string;
studentName: string;
studentNo: string;
status: ProctoringStudentStatus;
lastEventAt: string | null;
ipAddress: string | null;
}
/** 监考事件类型 */
export type ProctoringEventType = "WARNING" | "FLAG" | "NOTE";
/** 监考事件项 */
export interface ProctoringEvent {
eventId: string;
examId: string;
studentId: string;
studentName: string;
studentNo: string;
eventType: ProctoringEventType;
note: string | null;
createdAt: string;
}
/** 监考状态完整结构 */
export interface ProctoringStatus {
summary: ProctoringSummary;
students: ProctoringStudent[];
recentEvents: ProctoringEvent[];
}
/** 提交监考事件输入 */
export interface ProctoringEventInput {
examId: string;
studentId: string;
eventType: ProctoringEventType;
note?: string | null;
}
/** 提交监考事件结果 */
export interface ProctoringEventResult {
eventId: string;
createdAt: string;
}
// ============ Types扫描批改 ============
/** 扫描图片项 */
export interface ScanImage {
imageId: string;
url: string;
page: number;
}
/** 扫描批改单题项 */
export interface ScanGradingItem {
questionId: string;
questionTitle: string;
questionOrder: number;
maxScore: number;
recognizedAnswer: string;
aiSuggestedScore: number | null;
aiSuggestion: string | null;
score: number | null;
feedback: string | null;
}
/** 扫描批改视图 */
export interface SubmissionScanGrading {
submissionId: string;
homeworkTitle: string;
studentName: string;
studentNo: string;
submittedAt: string;
images: ScanImage[];
items: ScanGradingItem[];
prevSubmissionId: string | null;
nextSubmissionId: string | null;
}
/** 保存扫描批改单项 */
export interface SaveScanGradingItem {
questionId: string;
score: number;
feedback?: string | null;
}
/** 保存扫描批改输入 */
export interface SaveScanGradingInput {
submissionId: string;
items: SaveScanGradingItem[];
}
/** 保存扫描批改结果 */
export interface SaveScanGradingResult {
submissionId: string;
totalScore: number;
savedAt: string;
}
// ============ Types课标热力图 ============
/** 热力图知识点项 */
export interface HeatmapKnowledgePoint {
kpId: string;
name: string;
chapterName: string;
}
/** 热力图课案项 */
export interface HeatmapLessonPlan {
planId: string;
title: string;
}
/** 热力图覆盖矩阵单元格 */
export interface HeatmapCell {
kpId: string;
planId: string;
coverage: number; // 0-3 覆盖课案数
}
/** 课标覆盖热力图 */
export interface LessonPlanHeatmap {
textbookId: string;
textbookTitle: string;
knowledgePoints: HeatmapKnowledgePoint[];
lessonPlans: HeatmapLessonPlan[];
cells: HeatmapCell[];
}
// ============ Queries / Mutations选修课 ============
/** 选修课列表查询(按状态筛选,教师范围) */
export const ElectiveCoursesQuery = gql`
query ElectiveCourses($status: String, $subject: String) {
electiveCourses(status: $status, subject: $subject) {
id
title
subject
grade
capacity
enrolledCount
teacherId
teacherName
semester
status
description
}
}
`;
/** 选修课详情查询(含学生列表) */
export const ElectiveCourseDetailQuery = gql`
query ElectiveCourseDetail($id: ID!) {
electiveCourseDetail(id: $id) {
id
title
subject
grade
capacity
enrolledCount
teacherId
teacherName
semester
status
description
students {
studentId
studentName
studentNo
enrolledAt
status
}
createdAt
updatedAt
}
}
`;
/** 创建选修课 Mutation */
export const CreateElectiveCourseMutation = gql`
mutation CreateElectiveCourse($input: CreateElectiveCourseInput!) {
createElectiveCourse(input: $input) {
id
status
updatedAt
}
}
`;
/** 更新选修课 Mutation */
export const UpdateElectiveCourseMutation = gql`
mutation UpdateElectiveCourse($input: UpdateElectiveCourseInput!) {
updateElectiveCourse(input: $input) {
id
status
updatedAt
}
}
`;
/** 发布选修课 Mutation */
export const PublishElectiveCourseMutation = gql`
mutation PublishElectiveCourse($id: ID!) {
publishElectiveCourse(id: $id) {
id
status
updatedAt
}
}
`;
/** 取消选修课 Mutation */
export const CancelElectiveCourseMutation = gql`
mutation CancelElectiveCourse($id: ID!) {
cancelElectiveCourse(id: $id) {
id
status
updatedAt
}
}
`;
// ============ Queries / Mutations富文本编辑 ============
/** 富文本试卷内容查询 */
export const ExamRichEditorQuery = gql`
query ExamRichEditor($examId: ID!) {
examRichEditor(examId: $examId) {
examId
title
totalScore
questionCount
content
updatedAt
}
}
`;
/** 保存富文本内容 Mutation */
export const SaveExamRichContentMutation = gql`
mutation SaveExamRichContent($input: SaveExamRichContentInput!) {
saveExamRichContent(input: $input) {
examId
updatedAt
}
}
`;
// ============ Queries / Mutations监考 ============
/** 监考状态查询 */
export const ProctoringStatusQuery = gql`
query ProctoringStatus($examId: ID!) {
proctoringStatus(examId: $examId) {
summary {
examId
expectedCount
onlineCount
submittedCount
flaggedCount
isProctoring
startedAt
}
students {
studentId
studentName
studentNo
status
lastEventAt
ipAddress
}
recentEvents {
eventId
examId
studentId
studentName
studentNo
eventType
note
createdAt
}
}
}
`;
/** 提交监考事件 Mutation */
export const ProctoringEventMutation = gql`
mutation ProctoringEvent($input: ProctoringEventInput!) {
proctoringEvent(input: $input) {
eventId
createdAt
}
}
`;
// ============ Queries / Mutations扫描批改 ============
/** 扫描批改视图查询 */
export const SubmissionScanGradingQuery = gql`
query SubmissionScanGrading($submissionId: ID!) {
submissionScanGrading(submissionId: $submissionId) {
submissionId
homeworkTitle
studentName
studentNo
submittedAt
images {
imageId
url
page
}
items {
questionId
questionTitle
questionOrder
maxScore
recognizedAnswer
aiSuggestedScore
aiSuggestion
score
feedback
}
prevSubmissionId
nextSubmissionId
}
}
`;
/** 保存扫描批改 Mutation */
export const SaveScanGradingMutation = gql`
mutation SaveScanGrading($input: SaveScanGradingInput!) {
saveScanGrading(input: $input) {
submissionId
totalScore
savedAt
}
}
`;
// ============ Queries课标热力图 ============
/** 课标覆盖热力图查询(按教材 ID */
export const LessonPlanHeatmapQuery = gql`
query LessonPlanHeatmap($textbookId: ID!) {
lessonPlanHeatmap(textbookId: $textbookId) {
textbookId
textbookTitle
knowledgePoints {
kpId
name
chapterName
}
lessonPlans {
planId
title
}
cells {
kpId
planId
coverage
}
}
}
`;

View File

@@ -0,0 +1,628 @@
/**
* GraphQL operations - P7 扩展(组卷 + 题库 + 教材 + 考试分析)
*
* 维护者ai13teacher-portal
* 关联graphql.ts P2/P3 扩展、workline.md §3 P7 交付物
*
* schema 尚未在 ARB-001 P2 第一版中定义,开发期通过 MSW 拦截返回 mock 响应;
* 上游就绪后由 ai03 在 teacher-bff.graphql 扩展,届时合并本文件到 graphql.ts。
*
* 查询范围P7
* - ExamBuild按 examId 拉取试卷结构 + 题库候选列表分页
* - SaveExamBuild保存组卷已选题目 + 分值 + 排序)
* - ExamAnalytics考后分析分数分布/均分/最高/最低/及格率/科目对比/每题正确率/学生排名)
* - QuestionsLibrary题库列表六维筛选 + 分页 + 总数)
* - QuestionCreate / QuestionUpdate / QuestionDelete题目 CRUD
* - QuestionBatchImport / QuestionBatchExport批量导入导出
* - Textbooks教材列表q/subject/grade 筛选 + 分页)
* - TextbookDetail教材详情章节树 + 知识点列表)
* - TextbookCreate创建教材
*/
import { gql } from "urql";
// ============ Types题库 ============
/** 题目类型 */
export type QuestionType =
| "SINGLE_CHOICE"
| "MULTIPLE_CHOICE"
| "TRUE_FALSE"
| "FILL_BLANK"
| "SHORT_ANSWER";
/** 难度等级 */
export type QuestionDifficulty = "EASY" | "MEDIUM" | "HARD";
/** 题库项 */
export interface QuestionItem {
questionId: string;
content: string;
type: QuestionType;
difficulty: QuestionDifficulty;
textbookId: string | null;
textbookName: string | null;
chapterId: string | null;
chapterName: string | null;
kpId: string | null;
kpName: string | null;
score: number;
answer: string;
analysis: string;
createdAt: string;
updatedAt: string;
}
/** 题库分页结果 */
export interface QuestionsLibraryPage {
items: QuestionItem[];
total: number;
page: number;
pageSize: number;
}
/** 题库筛选条件 */
export interface QuestionsLibraryFilter {
q?: string | null;
type?: QuestionType | null;
difficulty?: QuestionDifficulty | null;
kp?: string | null;
textbook?: string | null;
chapter?: string | null;
page?: number;
pageSize?: number;
}
/** 创建题目输入 */
export interface QuestionCreateInput {
content: string;
type: QuestionType;
difficulty: QuestionDifficulty;
textbookId?: string | null;
chapterId?: string | null;
kpId?: string | null;
score: number;
answer: string;
analysis?: string | null;
}
/** 更新题目输入 */
export interface QuestionUpdateInput {
questionId: string;
content?: string;
type?: QuestionType;
difficulty?: QuestionDifficulty;
textbookId?: string | null;
chapterId?: string | null;
kpId?: string | null;
score?: number;
answer?: string;
analysis?: string | null;
}
/** 批量导入项 */
export interface QuestionBatchImportItem {
content: string;
type: QuestionType;
difficulty: QuestionDifficulty;
textbookId?: string | null;
chapterId?: string | null;
kpId?: string | null;
score: number;
answer: string;
analysis?: string | null;
}
/** 批量导入输入 */
export interface QuestionBatchImportInput {
items: QuestionBatchImportItem[];
}
/** 批量导入结果 */
export interface QuestionBatchImportResult {
importedCount: number;
failedCount: number;
errors: string[];
}
/** 批量导出结果 */
export interface QuestionBatchExportResult {
items: QuestionItem[];
exportedAt: string;
}
// ============ Types教材 ============
/** 教材项(列表) */
export interface TextbookItem {
id: string;
title: string;
subject: string;
grade: string;
version: string;
coverUrl: string | null;
chapterCount: number;
createdAt: string;
}
/** 教材分页结果 */
export interface TextbooksPage {
items: TextbookItem[];
total: number;
page: number;
pageSize: number;
}
/** 教材筛选 */
export interface TextbooksFilter {
q?: string | null;
subject?: string | null;
grade?: string | null;
page?: number;
pageSize?: number;
}
/** 教材创建输入 */
export interface TextbookCreateInput {
title: string;
subject: string;
grade: string;
version: string;
coverUrl?: string | null;
}
/** 知识点 */
export interface KnowledgePoint {
id: string;
name: string;
description: string | null;
}
/** 章节节点(含知识点) */
export interface TextbookChapter {
id: string;
textbookId: string;
title: string;
order: number;
content: string;
parentId: string | null;
knowledgePoints: KnowledgePoint[];
}
/** 教材详情 */
export interface TextbookDetail {
id: string;
title: string;
subject: string;
grade: string;
version: string;
coverUrl: string | null;
chapters: TextbookChapter[];
}
// ============ Types组卷 ============
/** 已选题目(试卷节点) */
export interface ExamBuildNode {
questionId: string;
score: number;
sortOrder: number;
// 题目冗余字段(便于预览)
content: string;
type: QuestionType;
difficulty: QuestionDifficulty;
}
/** 试卷结构 */
export interface ExamBuildStructure {
examId: string;
title: string;
totalScore: number;
passScore: number;
duration: number;
selected: ExamBuildNode[];
// 题库候选列表(首屏分页)
candidates: QuestionsLibraryPage;
}
/** 保存组卷输入项 */
export interface SaveExamBuildNodeInput {
questionId: string;
score: number;
sortOrder: number;
}
/** 保存组卷输入 */
export interface SaveExamBuildInput {
examId: string;
questions: SaveExamBuildNodeInput[];
}
/** 保存组卷结果 */
export interface SaveExamBuildResult {
examId: string;
totalScore: number;
questionCount: number;
savedAt: string;
}
// ============ Types考试分析 ============
/** 顶部统计卡片 */
export interface ExamAnalyticsSummary {
expectedCount: number;
attendedCount: number;
avgScore: number;
maxScore: number;
minScore: number;
passRate: number;
}
/** 分数分布段 */
export interface ExamScoreBand {
label: string;
min: number;
max: number;
count: number;
}
/** 每题正确率 */
export interface ExamQuestionAccuracy {
questionId: string;
questionTitle: string;
order: number;
correctRate: number;
avgScore: number;
maxScore: number;
}
/** 知识点掌握度(同 grades 模块,但语义独立) */
export interface ExamKpMastery {
knowledgePoint: string;
mastery: number;
}
/** 班级对比(复用 grades 类型语义,独立定义) */
export interface ExamClassComparison {
classId: string;
className: string;
avg: number;
significant: boolean;
}
/** 历次考试趋势点 */
export interface ExamHistoryTrend {
examTitle: string;
examDate: string;
avg: number;
}
/** 学生排名项 */
export interface ExamStudentRank {
rank: number;
studentId: string;
studentName: string;
studentNo: string;
totalScore: number;
level: "A" | "B" | "C" | "D" | "E";
}
/** 考试分析完整结构 */
export interface ExamAnalytics {
examId: string;
examTitle: string;
summary: ExamAnalyticsSummary;
distribution: ExamScoreBand[];
questionAccuracy: ExamQuestionAccuracy[];
knowledgeMastery: ExamKpMastery[];
classComparison: ExamClassComparison[];
historyTrend: ExamHistoryTrend[];
rankings: ExamStudentRank[];
}
// ============ Queries / Mutations ============
/** 组卷查询(按 examId 拉取试卷结构 + 题库候选列表分页) */
export const ExamBuildQuery = gql`
query ExamBuild(
$examId: ID!
$candidatePage: Int
$candidatePageSize: Int
$filter: QuestionsLibraryFilter
) {
examBuild(
examId: $examId
candidatePage: $candidatePage
candidatePageSize: $candidatePageSize
filter: $filter
) {
examId
title
totalScore
passScore
duration
selected {
questionId
score
sortOrder
content
type
difficulty
}
candidates {
items {
questionId
content
type
difficulty
textbookId
textbookName
chapterId
chapterName
kpId
kpName
score
answer
analysis
createdAt
updatedAt
}
total
page
pageSize
}
}
}
`;
/** 保存组卷 Mutation */
export const SaveExamBuildMutation = gql`
mutation SaveExamBuild($input: SaveExamBuildInput!) {
saveExamBuild(input: $input) {
examId
totalScore
questionCount
savedAt
}
}
`;
/** 考试分析查询 */
export const ExamAnalyticsQuery = gql`
query ExamAnalytics($examId: ID!) {
examAnalytics(examId: $examId) {
examId
examTitle
summary {
expectedCount
attendedCount
avgScore
maxScore
minScore
passRate
}
distribution {
label
min
max
count
}
questionAccuracy {
questionId
questionTitle
order
correctRate
avgScore
maxScore
}
knowledgeMastery {
knowledgePoint
mastery
}
classComparison {
classId
className
avg
significant
}
historyTrend {
examTitle
examDate
avg
}
rankings {
rank
studentId
studentName
studentNo
totalScore
level
}
}
}
`;
/** 题库列表查询(六维筛选 + 分页 + 总数) */
export const QuestionsLibraryQuery = gql`
query QuestionsLibrary($filter: QuestionsLibraryFilter) {
questionsLibrary(filter: $filter) {
items {
questionId
content
type
difficulty
textbookId
textbookName
chapterId
chapterName
kpId
kpName
score
answer
analysis
createdAt
updatedAt
}
total
page
pageSize
}
}
`;
/** 创建题目 Mutation */
export const QuestionCreateMutation = gql`
mutation QuestionCreate($input: QuestionCreateInput!) {
questionCreate(input: $input) {
questionId
content
type
difficulty
textbookId
textbookName
chapterId
chapterName
kpId
kpName
score
answer
analysis
createdAt
updatedAt
}
}
`;
/** 更新题目 Mutation */
export const QuestionUpdateMutation = gql`
mutation QuestionUpdate($input: QuestionUpdateInput!) {
questionUpdate(input: $input) {
questionId
content
type
difficulty
textbookId
textbookName
chapterId
chapterName
kpId
kpName
score
answer
analysis
createdAt
updatedAt
}
}
`;
/** 删除题目 Mutation */
export const QuestionDeleteMutation = gql`
mutation QuestionDelete($questionId: ID!) {
questionDelete(questionId: $questionId) {
questionId
deleted
}
}
`;
/** 批量导入题目 Mutation */
export const QuestionBatchImportMutation = gql`
mutation QuestionBatchImport($input: QuestionBatchImportInput!) {
questionBatchImport(input: $input) {
importedCount
failedCount
errors
}
}
`;
/** 批量导出题目查询(按当前筛选条件) */
export const QuestionBatchExportQuery = gql`
query QuestionBatchExport($filter: QuestionsLibraryFilter) {
questionBatchExport(filter: $filter) {
items {
questionId
content
type
difficulty
textbookId
textbookName
chapterId
chapterName
kpId
kpName
score
answer
analysis
createdAt
updatedAt
}
exportedAt
}
}
`;
/** 教材列表查询 */
export const TextbooksQuery = gql`
query Textbooks($filter: TextbooksFilter) {
textbooks(filter: $filter) {
items {
id
title
subject
grade
version
coverUrl
chapterCount
createdAt
}
total
page
pageSize
}
}
`;
/** 教材详情查询(章节树 + 知识点列表) */
export const TextbookDetailQuery = gql`
query TextbookDetail($id: ID!) {
textbookDetail(id: $id) {
id
title
subject
grade
version
coverUrl
chapters {
id
textbookId
title
order
content
parentId
knowledgePoints {
id
name
description
}
}
}
}
`;
/** 创建教材 Mutation */
export const TextbookCreateMutation = gql`
mutation TextbookCreate($input: TextbookCreateInput!) {
textbookCreate(input: $input) {
id
title
subject
grade
version
coverUrl
chapterCount
createdAt
}
}
`;

View File

@@ -0,0 +1,418 @@
/**
* GraphQL operations - P7 扩展(成绩完整子模块 + 作业批改链路)
*
* 维护者ai13teacher-portal
* 关联graphql.ts P2/P3 扩展、workline.md §3 P7 交付物
*
* schema 尚未在 ARB-001 P2 第一版中定义,开发期通过 MSW 拦截返回 mock 响应;
* 上游就绪后由 ai03 在 teacher-bff.graphql 扩展,届时合并本文件到 graphql.ts。
*
* 查询范围P7
* - GradeEntry按 examId + classId 拉取学生成绩录入列表
* - SaveGradeEntries批量保存成绩录入
* - GradeStats班级成绩统计avg/median/max/min/passRate/stdDev/rankings
* - GradeAnalytics成绩分析趋势/分布/学科对比/班级对比/知识点掌握度)
* - ReportCard学生成绩报告卡
* - HomeworkSubmissions按作业维度统计提交/已批数
* - HomeworkAssignmentSubmissions按作业 ID 拉取所有提交列表
* - GradeSubmission单份提交批改
*/
import { gql } from "urql";
// ============ Types成绩录入 ============
/** 成绩录入项(单生单考试) */
export interface GradeEntryItem {
studentId: string;
studentName: string;
studentNo: string;
score: number | null;
feedback: string | null;
}
/** 批量保存成绩录入项 */
export interface SaveGradeEntryInput {
studentId: string;
score: number;
feedback?: string | null;
}
/** 批量保存成绩录入输入 */
export interface SaveGradeEntriesInput {
examId: string;
classId: string;
entries: SaveGradeEntryInput[];
}
/** 批量保存结果 */
export interface SaveGradeEntriesResult {
examId: string;
classId: string;
savedCount: number;
}
// ============ Types班级成绩统计 ============
/** 班级成绩统计 */
export interface GradeStats {
classId: string;
className: string;
subject: string;
avg: number;
median: number;
max: number;
min: number;
passRate: number;
stdDev: number;
totalCount: number;
rankings: GradeRanking[];
}
/** 排名项 */
export interface GradeRanking {
rank: number;
studentId: string;
studentName: string;
studentNo: string;
totalScore: number;
level: "A" | "B" | "C" | "D" | "E";
}
// ============ Types成绩分析 ============
/** 成绩分析聚合 */
export interface GradeAnalytics {
classId: string;
className: string;
trend: GradeTrendPoint[];
distribution: GradeDistributionBand[];
subjectComparison: SubjectComparisonItem[];
classComparison: ClassComparisonItem[];
knowledgeMastery: KnowledgeMasteryItem[];
}
/** 趋势点 */
export interface GradeTrendPoint {
month: string;
avg: number;
max: number;
min: number;
}
/** 分数分布段 */
export interface GradeDistributionBand {
label: string;
min: number;
max: number;
count: number;
}
/** 学科对比项 */
export interface SubjectComparisonItem {
subject: string;
avg: number;
}
/** 班级对比项 */
export interface ClassComparisonItem {
classId: string;
className: string;
avg: number;
significant: boolean;
}
/** 知识点掌握度项 */
export interface KnowledgeMasteryItem {
knowledgePoint: string;
mastery: number;
}
// ============ Types报告卡 ============
/** 学生成绩报告卡 */
export interface ReportCard {
studentId: string;
studentName: string;
studentNo: string;
className: string;
academicYear: string;
semester: string;
subjects: ReportCardSubject[];
totalScore: number;
classRank: number;
classSize: number;
gradeRank: number;
gradeSize: number;
teacherComment: string;
}
/** 报告卡科目项 */
export interface ReportCardSubject {
subject: string;
score: number;
rank: number;
teacherName: string;
}
// ============ Types作业提交 ============
/** 作业提交统计项(按作业维度) */
export interface HomeworkSubmissionStat {
homeworkId: string;
title: string;
classId: string;
className: string;
status: "draft" | "published" | "graded";
dueDate: string;
totalCount: number;
submittedCount: number;
gradedCount: number;
avgScore: number | null;
}
/** 单份作业提交详情(含题目作答) */
export interface HomeworkSubmissionDetail {
submissionId: string;
homeworkId: string;
homeworkTitle: string;
classId: string;
className: string;
studentId: string;
studentName: string;
studentNo: string;
status: "SUBMITTED" | "GRADED";
submittedAt: string;
totalScore: number | null;
answers: SubmissionAnswer[];
prevSubmissionId: string | null;
nextSubmissionId: string | null;
}
/** 单题作答 */
export interface SubmissionAnswer {
questionId: string;
questionTitle: string;
questionType: "SINGLE_CHOICE" | "MULTIPLE_CHOICE" | "SHORT_ANSWER" | "ESSAY";
maxScore: number;
studentAnswer: string;
correctAnswer: string;
score: number | null;
feedback: string | null;
aiSuggestion: string | null;
}
/** 单份批改输入 */
export interface GradeSubmissionInput {
submissionId: string;
score: number;
feedback?: string | null;
aiAssisted?: boolean;
questionScores?: Array<{
questionId: string;
score: number;
feedback?: string | null;
}>;
}
/** 单份批改结果 */
export interface GradeSubmissionResult {
submissionId: string;
status: "GRADED";
totalScore: number;
feedback: string | null;
}
/** AI 批量评分建议项 */
export interface AiBatchGradingItem {
submissionId: string;
suggestedScore: number;
suggestion: string;
}
// ============ Queries / Mutations ============
/** 成绩录入列表查询(按 examId + classId */
export const GradeEntryQuery = gql`
query GradeEntry($examId: ID!, $classId: ID!) {
gradeEntry(examId: $examId, classId: $classId) {
studentId
studentName
studentNo
score
feedback
}
}
`;
/** 批量保存成绩录入 Mutation */
export const SaveGradeEntriesMutation = gql`
mutation SaveGradeEntries($input: SaveGradeEntriesInput!) {
saveGradeEntries(input: $input) {
examId
classId
savedCount
}
}
`;
/** 班级成绩统计查询 */
export const GradeStatsQuery = gql`
query GradeStats($classId: ID!, $subject: String) {
gradeStats(classId: $classId, subject: $subject) {
classId
className
subject
avg
median
max
min
passRate
stdDev
totalCount
rankings {
rank
studentId
studentName
studentNo
totalScore
level
}
}
}
`;
/** 成绩分析查询 */
export const GradeAnalyticsQuery = gql`
query GradeAnalytics($classId: ID!, $semester: String, $examType: String) {
gradeAnalytics(classId: $classId, semester: $semester, examType: $examType) {
classId
className
trend {
month
avg
max
min
}
distribution {
label
min
max
count
}
subjectComparison {
subject
avg
}
classComparison {
classId
className
avg
significant
}
knowledgeMastery {
knowledgePoint
mastery
}
}
}
`;
/** 学生成绩报告卡查询 */
export const ReportCardQuery = gql`
query ReportCard($studentId: ID!, $academicYear: String, $semester: String) {
reportCard(studentId: $studentId, academicYear: $academicYear, semester: $semester) {
studentId
studentName
studentNo
className
academicYear
semester
subjects {
subject
score
rank
teacherName
}
totalScore
classRank
classSize
gradeRank
gradeSize
teacherComment
}
}
`;
/** 作业提交统计列表查询(按作业维度) */
export const HomeworkSubmissionsQuery = gql`
query HomeworkSubmissions($classId: ID, $status: String) {
homeworkSubmissions(classId: $classId, status: $status) {
homeworkId
title
classId
className
status
dueDate
totalCount
submittedCount
gradedCount
avgScore
}
}
`;
/** 按作业 ID 拉取所有提交列表查询 */
export const HomeworkAssignmentSubmissionsQuery = gql`
query HomeworkAssignmentSubmissions($homeworkId: ID!) {
homeworkAssignmentSubmissions(homeworkId: $homeworkId) {
submissionId
homeworkId
homeworkTitle
classId
className
studentId
studentName
studentNo
status
submittedAt
totalScore
answers {
questionId
questionTitle
questionType
maxScore
studentAnswer
correctAnswer
score
feedback
aiSuggestion
}
prevSubmissionId
nextSubmissionId
}
}
`;
/** 单份提交批改 Mutation */
export const GradeSubmissionMutation = gql`
mutation GradeSubmission($input: GradeSubmissionInput!) {
gradeSubmission(input: $input) {
submissionId
status
totalScore
feedback
}
}
`;
/** AI 批量评分建议查询mock返回 AI 评分建议列表) */
export const AiBatchGradingQuery = gql`
query AiBatchGrading($homeworkId: ID!) {
aiBatchGrading(homeworkId: $homeworkId) {
submissionId
suggestedScore
suggestion
}
}
`;

View File

@@ -0,0 +1,737 @@
/**
* GraphQL operations - P7 扩展(备课 + 课程计划 + 诊断 + 错题本 + 自适应练习)
*
* 维护者ai13teacher-portal
* 关联graphql.ts P2/P3 扩展、workline.md §3 P7 交付物
*
* schema 尚未在 ARB-001 P2 第一版中定义,开发期通过 MSW 拦截返回 mock 响应;
* 上游就绪后由 ai03 在 teacher-bff.graphql 扩展,届时合并本文件到 graphql.ts。
*
* 查询范围P7-insights
* - LessonPlans / LessonPlanDetail / LessonPlanLibrary / LessonPlanCalendar / LessonPlanHeatmap
* - CreateLessonPlan / UpdateLessonPlan / AIGenerateLessonPlan / ForkLessonPlan
* - CoursePlans / CoursePlanDetail / CreateCoursePlan / UpdateCoursePlan
* - DiagnosticReports / ClassDiagnostic / StudentDiagnostic
* - ErrorBook错题分析聚合
* - PracticeAnalytics自适应练习分析聚合
*/
import { gql } from "urql";
// ============ Types课案 ============
export type LessonPlanStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
export type LessonPlanTemplate =
| "REGULAR"
| "UNIT"
| "REVIEW"
| "ACTIVITY"
| "PERSONALIZED";
export interface LessonPlanAIHistoryItem {
id: string;
prompt: string;
generatedAt: string;
model: string;
}
export interface LessonPlanItem {
id: string;
title: string;
subject: string;
grade: string;
chapter: string;
status: LessonPlanStatus;
textbookId: string | null;
textbookTitle: string | null;
classIds: string[];
updatedAt: string;
}
export interface LessonPlanDetail {
id: string;
title: string;
subject: string;
grade: string;
chapter: string;
content: string;
status: LessonPlanStatus;
textbookId: string | null;
textbookTitle: string | null;
classIds: string[];
aiHistory: LessonPlanAIHistoryItem[];
updatedAt: string;
createdAt: string;
}
export interface LessonPlanLibraryItem {
id: string;
title: string;
author: string;
subject: string;
grade: string;
chapter: string;
rating: number;
forkCount: number;
updatedAt: string;
}
export interface LessonPlanCalendarDay {
date: string;
planCount: number;
planTitles: string[];
}
export interface LessonPlanCalendarMonth {
year: number;
month: number;
days: LessonPlanCalendarDay[];
}
export interface LessonPlanHeatmapCell {
textbookKp: string;
planIds: string[];
covered: boolean;
}
export interface LessonPlanHeatmap {
rows: string[];
cols: string[];
cells: LessonPlanHeatmapCell[];
}
export interface CreateLessonPlanInput {
title: string;
subject: string;
grade: string;
chapter: string;
textbookId?: string | null;
template: LessonPlanTemplate;
}
export interface UpdateLessonPlanInput {
planId: string;
title?: string;
content?: string;
status?: LessonPlanStatus;
textbookId?: string | null;
chapter?: string;
classIds?: string[];
}
export interface AIGenerateLessonPlanInput {
subject: string;
grade: string;
chapter: string;
prompt: string;
}
export interface AIGenerateLessonPlanResult {
id: string;
content: string;
summary: string;
model: string;
generatedAt: string;
}
// ============ Types课程计划 ============
export type CoursePlanStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
export interface CoursePlanChapter {
id: string;
title: string;
hours: number;
objectives: string;
keyPoints: string;
activities: string;
}
export interface CoursePlanItem {
id: string;
title: string;
subject: string;
grade: string;
academicYear: string;
semester: string;
totalHours: number;
status: CoursePlanStatus;
textbookId: string | null;
updatedAt: string;
}
export interface CoursePlanDetail {
id: string;
title: string;
subject: string;
grade: string;
academicYear: string;
semester: string;
totalHours: number;
status: CoursePlanStatus;
textbookId: string | null;
textbookTitle: string | null;
homeworkCount: number;
chapters: CoursePlanChapter[];
updatedAt: string;
}
export interface CreateCoursePlanInput {
title: string;
subject: string;
grade: string;
academicYear: string;
semester: string;
totalHours: number;
textbookId?: string | null;
}
export interface UpdateCoursePlanInput {
id: string;
title?: string;
status?: CoursePlanStatus;
chapters?: CoursePlanChapter[];
}
// ============ Types诊断 ============
export type DiagnosticReportType = "INDIVIDUAL" | "CLASS" | "GRADE";
export type DiagnosticReportStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
export interface DiagnosticReportItem {
id: string;
title: string;
type: DiagnosticReportType;
targetType: string;
status: DiagnosticReportStatus;
createdAt: string;
}
export interface DiagnosticKpMastery {
knowledgePoint: string;
avg: number;
median: number;
studentCount: number;
distribution: { mastered: number; partial: number; weak: number };
}
export interface DiagnosticStudentRank {
rank: number;
studentId: string;
studentName: string;
masteryAvg: number;
}
export interface ClassDiagnostic {
classId: string;
className: string;
studentCount: number;
summary: { knowledgePoint: string; avg: number }[];
kpMastery: DiagnosticKpMastery[];
rankings: DiagnosticStudentRank[];
}
export interface DiagnosticRadarPoint {
axis: string;
value: number;
classAvg: number;
}
export interface StudentDiagnostic {
studentId: string;
studentName: string;
radar: DiagnosticRadarPoint[];
reports: { id: string; title: string; createdAt: string; status: DiagnosticReportStatus }[];
}
// ============ Types错题本 ============
export interface ErrorBookSummary {
totalErrors: number;
highFreqErrors: number;
weakKpCount: number;
avgErrorRate: number;
trendUp: boolean;
}
export interface ErrorBookClassComparison {
classId: string;
className: string;
errorCount: number;
}
export interface ErrorBookChapterWeakness {
chapter: string;
errorCount: number;
}
export interface ErrorBookKpWeakness {
knowledgePoint: string;
errorRate: number;
}
export interface ErrorBookStudentGroup {
studentId: string;
studentName: string;
errorCount: number;
topErrors: { questionId: string; content: string; errorRate: number }[];
}
export interface ErrorBookTopQuestion {
questionId: string;
content: string;
errorRate: number;
subject: string;
}
export interface ErrorBook {
subject: string;
classId: string;
summary: ErrorBookSummary;
classComparison: ErrorBookClassComparison[];
chapterWeakness: ErrorBookChapterWeakness[];
kpWeakness: ErrorBookKpWeakness[];
studentGroups: ErrorBookStudentGroup[];
topQuestions: ErrorBookTopQuestion[];
}
// ============ Types练习 ============
export interface PracticeSummary {
totalSessions: number;
completionRate: number;
avgAccuracy: number;
weakKpCount: number;
participationRate: number;
}
export interface PracticeClassComparison {
classId: string;
className: string;
totalSessions: number;
completionRate: number;
avgAccuracy: number;
participationRate: number;
weakKpCount: number;
}
export interface PracticeTypeBreakdown {
type: string;
count: number;
ratio: number;
}
export interface PracticeKpWeakness {
knowledgePoint: string;
accuracy: number;
}
export interface PracticeStudentRank {
rank: number;
studentId: string;
studentName: string;
studentNo: string;
totalSessions: number;
accuracy: number;
weakKp: string;
}
export interface PracticeInactiveStudent {
studentId: string;
studentName: string;
lastActiveDays: number;
}
export interface PracticeAnalytics {
classId: string;
summary: PracticeSummary;
classComparison: PracticeClassComparison[];
typeBreakdown: PracticeTypeBreakdown[];
kpWeakness: PracticeKpWeakness[];
rankings: PracticeStudentRank[];
inactiveStudents: PracticeInactiveStudent[];
}
// ============ Queries / Mutations课案 ============
export const LessonPlansQuery = gql`
query LessonPlans($subject: String) {
lessonPlans(subject: $subject) {
id
title
subject
grade
chapter
status
textbookId
textbookTitle
classIds
updatedAt
}
}
`;
export const LessonPlanDetailQuery = gql`
query LessonPlanDetail($planId: ID!) {
lessonPlanDetail(planId: $planId) {
id
title
subject
grade
chapter
content
status
textbookId
textbookTitle
classIds
aiHistory {
id
prompt
generatedAt
model
}
updatedAt
createdAt
}
}
`;
export const CreateLessonPlanMutation = gql`
mutation CreateLessonPlan($input: CreateLessonPlanInput!) {
createLessonPlan(input: $input) {
id
title
subject
grade
chapter
status
textbookId
textbookTitle
classIds
updatedAt
}
}
`;
export const UpdateLessonPlanMutation = gql`
mutation UpdateLessonPlan($input: UpdateLessonPlanInput!) {
updateLessonPlan(input: $input) {
id
title
content
status
textbookId
chapter
classIds
updatedAt
}
}
`;
export const AIGenerateLessonPlanMutation = gql`
mutation AIGenerateLessonPlan($input: AIGenerateLessonPlanInput!) {
aiGenerateLessonPlan(input: $input) {
id
content
summary
model
generatedAt
}
}
`;
export const LessonPlanLibraryQuery = gql`
query LessonPlanLibrary($subject: String, $grade: String) {
lessonPlanLibrary(subject: $subject, grade: $grade) {
id
title
author
subject
grade
chapter
rating
forkCount
updatedAt
}
}
`;
export const ForkLessonPlanMutation = gql`
mutation ForkLessonPlan($planId: ID!) {
forkLessonPlan(planId: $planId) {
id
title
subject
grade
chapter
status
textbookId
textbookTitle
classIds
updatedAt
}
}
`;
export const LessonPlanCalendarQuery = gql`
query LessonPlanCalendar($year: Int!, $month: Int!) {
lessonPlanCalendar(year: $year, month: $month) {
year
month
days {
date
planCount
planTitles
}
}
}
`;
export const LessonPlanHeatmapQuery = gql`
query LessonPlanHeatmap {
lessonPlanHeatmap {
rows
cols
cells {
textbookKp
planIds
covered
}
}
}
`;
// ============ Queries / Mutations课程计划 ============
export const CoursePlansQuery = gql`
query CoursePlans($status: CoursePlanStatus) {
coursePlans(status: $status) {
id
title
subject
grade
academicYear
semester
totalHours
status
textbookId
updatedAt
}
}
`;
export const CoursePlanDetailQuery = gql`
query CoursePlanDetail($id: ID!) {
coursePlanDetail(id: $id) {
id
title
subject
grade
academicYear
semester
totalHours
status
textbookId
textbookTitle
homeworkCount
chapters {
id
title
hours
objectives
keyPoints
activities
}
updatedAt
}
}
`;
export const CreateCoursePlanMutation = gql`
mutation CreateCoursePlan($input: CreateCoursePlanInput!) {
createCoursePlan(input: $input) {
id
title
subject
grade
academicYear
semester
totalHours
status
textbookId
updatedAt
}
}
`;
export const UpdateCoursePlanMutation = gql`
mutation UpdateCoursePlan($input: UpdateCoursePlanInput!) {
updateCoursePlan(input: $input) {
id
title
status
updatedAt
}
}
`;
// ============ Queries诊断 ============
export const DiagnosticReportsQuery = gql`
query DiagnosticReports($type: DiagnosticReportType, $status: DiagnosticReportStatus) {
diagnosticReports(type: $type, status: $status) {
id
title
type
targetType
status
createdAt
}
}
`;
export const ClassDiagnosticQuery = gql`
query ClassDiagnostic($classId: ID!) {
classDiagnostic(classId: $classId) {
classId
className
studentCount
summary {
knowledgePoint
avg
}
kpMastery {
knowledgePoint
avg
median
studentCount
distribution {
mastered
partial
weak
}
}
rankings {
rank
studentId
studentName
masteryAvg
}
}
}
`;
export const StudentDiagnosticQuery = gql`
query StudentDiagnostic($studentId: ID!) {
studentDiagnostic(studentId: $studentId) {
studentId
studentName
radar {
axis
value
classAvg
}
reports {
id
title
createdAt
status
}
}
}
`;
// ============ Queries错题本 / 练习) ============
export const ErrorBookQuery = gql`
query ErrorBook($subject: String!, $classId: String) {
errorBook(subject: $subject, classId: $classId) {
subject
classId
summary {
totalErrors
highFreqErrors
weakKpCount
avgErrorRate
trendUp
}
classComparison {
classId
className
errorCount
}
chapterWeakness {
chapter
errorCount
}
kpWeakness {
knowledgePoint
errorRate
}
studentGroups {
studentId
studentName
errorCount
topErrors {
questionId
content
errorRate
}
}
topQuestions {
questionId
content
errorRate
subject
}
}
}
`;
export const PracticeAnalyticsQuery = gql`
query PracticeAnalytics($classId: String) {
practiceAnalytics(classId: $classId) {
classId
summary {
totalSessions
completionRate
avgAccuracy
weakKpCount
participationRate
}
classComparison {
classId
className
totalSessions
completionRate
avgAccuracy
participationRate
weakKpCount
}
typeBreakdown {
type
count
ratio
}
kpWeakness {
knowledgePoint
accuracy
}
rankings {
rank
studentId
studentName
studentNo
totalSessions
accuracy
weakKp
}
inactiveStudents {
studentId
studentName
lastActiveDays
}
}
}
`;

View File

@@ -300,3 +300,173 @@ export const UpdateUserMutation = gql`
}
}
`;
// ============ P3 Mutation/QueryMSW mockcore-edu 域) ============
// 以下 operations 用于考试创建/作业布置/成绩录入/详情查询workline.md §3 P3 交付物)。
// 开发期通过 MSW 拦截返回 mock 响应;上游就绪后由 ai03 在 teacher-bff.graphql 扩展。
/** 创建考试输入 */
export interface CreateExamInput {
classId: string;
title: string;
description?: string | null;
examDate: string;
duration: number;
totalScore: number;
}
/** 创建考试 MutationP3 扩展MSW mock */
export const CreateExamMutation = gql`
mutation CreateExam($input: CreateExamInput!) {
createExam(input: $input) {
id
classId
title
description
examDate
duration
totalScore
status
}
}
`;
/** 布置作业输入 */
export interface AssignHomeworkInput {
classId: string;
title: string;
description?: string | null;
dueDate: string;
}
/** 布置作业 MutationP3 扩展MSW mock */
export const AssignHomeworkMutation = gql`
mutation AssignHomework($input: AssignHomeworkInput!) {
assignHomework(input: $input) {
id
classId
title
description
dueDate
status
}
}
`;
/** 录入成绩输入 */
export interface RecordGradeInput {
studentId: string;
examId?: string | null;
homeworkId?: string | null;
score: number;
feedback?: string | null;
}
/** 录入成绩 MutationP3 扩展MSW mock */
export const RecordGradeMutation = gql`
mutation RecordGrade($input: RecordGradeInput!) {
recordGrade(input: $input) {
id
studentId
studentName
examId
homeworkId
score
feedback
}
}
`;
/** 考试题目core-edu 域P3 扩展) */
export interface ExamQuestion {
id: string;
examId: string;
title: string;
type: "SINGLE_CHOICE" | "MULTIPLE_CHOICE" | "SHORT_ANSWER" | "ESSAY";
score: number;
order: number;
}
/** 考试详情(含题目列表) */
export interface ExamDetail {
id: string;
classId: string;
title: string;
description: string | null;
examDate: string;
duration: number;
totalScore: number;
status: ExamStatus;
questions: ExamQuestion[];
}
/** 考试详情查询P3 扩展MSW mock */
export const ExamDetailQuery = gql`
query ExamDetail($id: ID!) {
examDetail(id: $id) {
id
classId
title
description
examDate
duration
totalScore
status
questions {
id
examId
title
type
score
order
}
}
}
`;
/** 作业提交core-edu 域P3 扩展) */
export interface HomeworkSubmission {
id: string;
homeworkId: string;
studentId: string;
studentName: string;
status: SubmissionStatus;
score: number | null;
feedback: string | null;
submittedAt: string | null;
}
/** 作业详情(含提交列表) */
export interface HomeworkDetail {
id: string;
classId: string;
title: string;
description: string | null;
dueDate: string;
status: SubmissionStatus;
submissions: HomeworkSubmission[];
}
/** 作业详情查询P3 扩展MSW mock */
export const HomeworkDetailQuery = gql`
query HomeworkDetail($id: ID!) {
homeworkDetail(id: $id) {
id
classId
title
description
dueDate
status
submissions {
id
homeworkId
studentId
studentName
status
score
feedback
submittedAt
}
}
}
`;

View File

@@ -0,0 +1,272 @@
/**
* A11y 审计工具P6 硬化)
*
* - runA11yAudit() - 运行 axe-core 审计(条件加载,动态 import
* - checkContrast() - 对比度检查工具
* - generateA11yReport() - 生成审计报告
*
* 注意axe-core 未安装,使用动态 import 在运行时按需加载。
* 包将在统一安装阶段补充到 package.json。
*
* 关联02-architecture-design.md §12 可观测性 / WCAG 2.2 AA 规范
*/
/**
* WCAG 2.2 AA 对比度阈值。
*
* - 普通文本4.5:1
* - 大文本18pt+ 或 14pt 粗体3.0:1
* - 非文本组件UI 边框/图标3.0:1
*/
export const CONTRAST_THRESHOLDS = {
normalText: 4.5,
largeText: 3.0,
nonTextComponents: 3.0,
} as const;
/** A11y 审计结果级别 */
export type A11yIssueLevel = "minor" | "moderate" | "serious" | "critical";
/** A11y 审计单个问题 */
export interface A11yIssue {
/** 规则 ID如 "color-contrast" */
id: string;
/** 问题级别 */
level: A11yIssueLevel;
/** 问题描述 */
description: string;
/** 受影响元素的选择器 */
selector: string;
/** 修复建议 */
help: string;
/** 帮助文档 URL */
helpUrl: string;
}
/** A11y 审计报告 */
export interface A11yAuditReport {
/** 审计时间戳 */
timestamp: number;
/** 页面 URL */
url: string;
/** 通过的规则数 */
passes: number;
/** 违规规则数 */
violations: number;
/** 不完整规则数 */
incomplete: number;
/** 问题列表 */
issues: A11yIssue[];
/** 是否通过 WCAG 2.2 AA */
passed: boolean;
}
/**
* axe-core 的最小类型声明(仅声明使用的 API 子集)。
*/
interface AxeResult {
passes: unknown[];
violations: Array<{
id: string;
impact: A11yIssueLevel;
description: string;
help: string;
helpUrl: string;
nodes: Array<{ target: string[] }>;
}>;
incomplete: unknown[];
}
interface AxeModule {
default: (options: {
runOnly?: { type: string; values: string[] };
}) => Promise<AxeResult>;
}
/** axe-core 的最小配置(启用 WCAG 2.2 AA 规则集) */
const AXE_RUN_OPTIONS = {
runOnly: {
type: "tag" as const,
values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"],
},
};
/**
* 运行 axe-core A11y 审计。
*
* 动态 import axe-core避免未安装包导致构建失败。
* 仅在浏览器环境运行(需要 DOM
*
* @returns 审计结果数组(违规项),加载失败返回空数组
*/
export async function runA11yAudit(): Promise<A11yIssue[]> {
if (typeof window === "undefined") return [];
if (typeof document === "undefined") return [];
try {
const axeMod = (await import("axe-core")) as unknown as AxeModule;
const axe = axeMod.default ?? (axeMod as unknown as AxeModule["default"]);
const result = await axe(AXE_RUN_OPTIONS);
return result.violations.map((violation) => {
const node = violation.nodes[0];
return {
id: violation.id,
level: violation.impact,
description: violation.description,
selector: node ? node.target.join(", ") : "",
help: violation.help,
helpUrl: violation.helpUrl,
};
});
} catch (err) {
if (typeof console !== "undefined") {
console.warn("[teacher-portal] axe-core 加载失败,跳过 A11y 审计", err);
}
return [];
}
}
/**
* 将 hex 颜色转换为相对亮度值0~1
*
* 依据 WCAG 2.x 相对亮度公式:
* https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
*/
function relativeLuminance(hex: string): number | null {
const cleaned = hex.replace("#", "");
if (cleaned.length !== 6 && cleaned.length !== 3) return null;
const fullHex =
cleaned.length === 3
? cleaned
.split("")
.map((c) => c + c)
.join("")
: cleaned;
const r = parseInt(fullHex.slice(0, 2), 16) / 255;
const g = parseInt(fullHex.slice(2, 4), 16) / 255;
const b = parseInt(fullHex.slice(4, 6), 16) / 255;
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;
const toLinear = (c: number): number =>
c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
const R = toLinear(r);
const G = toLinear(g);
const B = toLinear(b);
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}
/**
* 对比度检查工具。
*
* 计算两个 hex 颜色之间的对比度比值1~21
*
* @param foreground 前景色hex如 "#000000"
* @param background 背景色hex如 "#ffffff"
* @param isLargeText 是否大文本(默认 false使用 4.5:1 阈值)
* @returns 是否通过 WCAG 2.2 AA 对比度要求(无效颜色返回 false
*/
export function checkContrast(
foreground: string,
background: string,
isLargeText = false,
): boolean {
const fg = relativeLuminance(foreground);
const bg = relativeLuminance(background);
if (fg === null || bg === null) return false;
const lighter = Math.max(fg, bg);
const darker = Math.min(fg, bg);
const ratio = (lighter + 0.05) / (darker + 0.05);
const threshold = isLargeText
? CONTRAST_THRESHOLDS.largeText
: CONTRAST_THRESHOLDS.normalText;
return ratio >= threshold;
}
/**
* 获取两个颜色之间的对比度比值。
*
* @returns 对比度比值1~21无效颜色返回 null
*/
export function getContrastRatio(
foreground: string,
background: string,
): number | null {
const fg = relativeLuminance(foreground);
const bg = relativeLuminance(background);
if (fg === null || bg === null) return null;
const lighter = Math.max(fg, bg);
const darker = Math.min(fg, bg);
return (lighter + 0.05) / (darker + 0.05);
}
/**
* 生成 A11y 审计报告。
*
* 运行 axe-core 审计并汇总为结构化报告。
*
* @returns 审计报告(加载失败返回空报告)
*/
export async function generateA11yReport(): Promise<A11yAuditReport> {
if (typeof window === "undefined") {
return {
timestamp: Date.now(),
url: "",
passes: 0,
violations: 0,
incomplete: 0,
issues: [],
passed: false,
};
}
try {
const axeMod = (await import("axe-core")) as unknown as AxeModule;
const axe = axeMod.default ?? (axeMod as unknown as AxeModule["default"]);
const result = await axe(AXE_RUN_OPTIONS);
const issues: A11yIssue[] = result.violations.map((violation) => {
const node = violation.nodes[0];
return {
id: violation.id,
level: violation.impact,
description: violation.description,
selector: node ? node.target.join(", ") : "",
help: violation.help,
helpUrl: violation.helpUrl,
};
});
return {
timestamp: Date.now(),
url: window.location.href,
passes: result.passes.length,
violations: result.violations.length,
incomplete: result.incomplete.length,
issues,
passed: result.violations.length === 0,
};
} catch (err) {
if (typeof console !== "undefined") {
console.warn("[teacher-portal] A11y 审计失败", err);
}
return {
timestamp: Date.now(),
url: typeof window !== "undefined" ? window.location.href : "",
passes: 0,
violations: 0,
incomplete: 0,
issues: [],
passed: false,
};
}
}

View File

@@ -0,0 +1,189 @@
/**
* Cookie 迁移准备P6 硬化)
*
* - migrateTokenToCookie() - 将 localStorage token 迁移到 httpOnly cookie
* - checkCookieSupport() - 检测浏览器是否支持 Secure + SameSite cookie
* - isCookieMigrationEnabled() - 检查是否启用迁移(环境变量)
* - 迁移状态枚举
*
* 注意:实际迁移在 iam refresh cookie 端点就绪后启用,此处仅做准备。
* 关联project_rules §4 安全规范Cookie: httpOnly + Secure + SameSite=Strict
* 02-architecture-design.md §2.1 会话状态
*/
import { getObservabilityConfig } from "@/lib/observability/env";
/**
* 迁移状态枚举。
*/
export enum CookieMigrationStatus {
/** 未启用迁移 */
Disabled = "disabled",
/** 浏览器不支持必要的 Cookie 特性 */
Unsupported = "unsupported",
/** localStorage 中无 token无需迁移 */
NoToken = "no_token",
/** 迁移进行中 */
InProgress = "in_progress",
/** 迁移成功 */
Completed = "completed",
/** 迁移失败iam 端点未就绪或网络错误) */
Failed = "failed",
}
/**
* localStorage 中存储的 token key与 auth.ts 保持一致)。
*
* 注意:此处直接引用 key 字符串,不导入 auth.ts避免循环依赖。
* auth.ts 使用 "edu_access_token"。
*/
const LEGACY_TOKEN_KEY = "edu_access_token";
/** iam refresh cookie 端点(端点就绪后启用) */
const IAM_REFRESH_COOKIE_ENDPOINT = "/api/v1/iam/auth/refresh-cookie";
/**
* 检查是否启用 Cookie 迁移(通过环境变量控制)。
*
* 实际迁移在 iam refresh cookie 端点就绪后由运维开启。
*
* @returns 是否启用迁移
*/
export function isCookieMigrationEnabled(): boolean {
return getObservabilityConfig().cookieMigrationEnabled;
}
/**
* 检测浏览器是否支持 Secure + SameSite=Strict cookie。
*
* 通过设置测试 cookie 并读回验证。仅在浏览器环境运行。
*
* @returns 是否支持必要的 Cookie 特性
*/
export function checkCookieSupport(): boolean {
if (typeof document === "undefined") return false;
try {
// 测试 Secure + SameSite=Strict cookie
const testCookie = "edu_cookie_test=1; Secure; SameSite=Strict; max-age=1";
document.cookie = testCookie;
// 检查是否能读回(不支持 Secure 时 HTTPS 以外环境设置会失败)
const supported = document.cookie.includes("edu_cookie_test");
// 清理测试 cookie
document.cookie =
"edu_cookie_test=; Secure; SameSite=Strict; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT";
return supported;
} catch {
return false;
}
}
/**
* 检测是否为 HTTPS 安全上下文Secure cookie 需要)。
*/
export function isSecureContext(): boolean {
if (typeof window === "undefined") return false;
// window.isSecureContext 是标准 API
return (
typeof window.isSecureContext === "boolean" ? window.isSecureContext : false
);
}
/**
* 从 localStorage 读取 legacy token仅检查是否存在不暴露 token 值)。
*/
function hasLegacyToken(): boolean {
if (typeof window === "undefined") return false;
try {
return localStorage.getItem(LEGACY_TOKEN_KEY) !== null;
} catch {
return false;
}
}
/**
* 将 localStorage token 迁移到 httpOnly cookie。
*
* 流程:
* 1. 检查是否启用迁移(环境变量)
* 2. 检查浏览器 Cookie 支持
* 3. 检查 localStorage 是否有 token
* 4. 调用 iam refresh cookie 端点(服务端设置 httpOnly cookie
* 5. 成功后清除 localStorage token
*
* 注意iam refresh cookie 端点未就绪时返回 Failed不影响现有功能。
*
* @returns 迁移状态
*/
export async function migrateTokenToCookie(): Promise<CookieMigrationStatus> {
// 1. 检查是否启用迁移
if (!isCookieMigrationEnabled()) {
return CookieMigrationStatus.Disabled;
}
// 2. 检查浏览器 Cookie 支持
if (!checkCookieSupport()) {
return CookieMigrationStatus.Unsupported;
}
// 3. 检查 localStorage 是否有 token
if (!hasLegacyToken()) {
return CookieMigrationStatus.NoToken;
}
// 4. 调用 iam refresh cookie 端点
// 端点未就绪时返回 Failed不抛异常不影响现有功能
try {
const response = await fetch(IAM_REFRESH_COOKIE_ENDPOINT, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
});
if (!response.ok) {
// iam 端点未就绪404或认证失败401返回 Failed
return CookieMigrationStatus.Failed;
}
const data = (await response.json()) as { success?: boolean };
if (!data.success) {
return CookieMigrationStatus.Failed;
}
// 5. 成功后清除 localStorage tokenhttpOnly cookie 已由服务端设置)
if (typeof window !== "undefined") {
try {
localStorage.removeItem(LEGACY_TOKEN_KEY);
} catch {
// 清除失败不影响迁移成功状态cookie 已生效)
}
}
return CookieMigrationStatus.Completed;
} catch {
// 网络错误或端点不可达
return CookieMigrationStatus.Failed;
}
}
/**
* 检查迁移状态(非破坏性,不调用 iam 端点)。
*
* 用于在应用启动时判断是否需要提示用户或执行迁移。
*/
export function getMigrationStatus(): CookieMigrationStatus {
if (!isCookieMigrationEnabled()) {
return CookieMigrationStatus.Disabled;
}
if (!checkCookieSupport()) {
return CookieMigrationStatus.Unsupported;
}
if (!hasLegacyToken()) {
return CookieMigrationStatus.NoToken;
}
// 有 token 且环境支持,等待迁移
return CookieMigrationStatus.InProgress;
}

View File

@@ -0,0 +1,113 @@
/**
* 可观测性环境变量集中管理P6 硬化)
*
* - 类型安全的环境变量访问
* - 统一所有可观测性模块Sentry / Web Vitals / OTel / Cookie 迁移)的配置入口
* - 所有变量必须以 NEXT_PUBLIC_ 前缀project_rules §4 安全规范)
*
* 关联02-architecture-design.md §12 可观测性
*/
/**
* 可观测性配置对象(运行时只读快照)
*/
export interface ObservabilityConfig {
/** Sentry DSN未配置则禁用 Sentry */
readonly sentryDsn: string | null;
/** Sentry release 版本号 */
readonly sentryRelease: string | null;
/** 运行环境development / production */
readonly environment: string;
/** 采样率0~1 */
readonly tracesSampleRate: number;
/** 是否启用 OTel browser SDK */
readonly otelEnabled: boolean;
/** OTLP collector 上报端点 */
readonly otelEndpoint: string | null;
/** Web Vitals 上报端点 */
readonly webVitalsEndpoint: string;
/** 是否启用 Cookie 迁移iam refresh cookie 端点就绪后开启) */
readonly cookieMigrationEnabled: boolean;
/** 服务名(用于上报标识) */
readonly serviceName: string;
}
/** 默认 Web Vitals 上报端点 */
const DEFAULT_WEB_VITALS_ENDPOINT = "/api/v1/admin/web-vitals";
/** 解析环境变量为布尔值("true" / "1" 视为真) */
function parseBoolean(value: string | undefined): boolean {
return value === "true" || value === "1";
}
/** 解析采样率(默认 0.1,非法值回退到默认) */
function parseSampleRate(value: string | undefined): number {
const parsed = Number.parseFloat(value ?? "");
if (Number.isNaN(parsed) || parsed < 0 || parsed > 1) {
return 0.1;
}
return parsed;
}
/**
* 读取并构建可观测性配置。
*
* Next.js 在构建时将 NEXT_PUBLIC_* 变量内联到客户端 bundle
* 因此此处直接读取 process.env。
*/
function buildConfig(): ObservabilityConfig {
return {
sentryDsn: process.env.NEXT_PUBLIC_SENTRY_DSN ?? null,
sentryRelease: process.env.NEXT_PUBLIC_SENTRY_RELEASE ?? null,
environment: process.env.NODE_ENV ?? "development",
tracesSampleRate: parseSampleRate(
process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE,
),
otelEnabled: parseBoolean(process.env.NEXT_PUBLIC_OTEL_ENABLED),
otelEndpoint: process.env.NEXT_PUBLIC_OTEL_ENDPOINT ?? null,
webVitalsEndpoint:
process.env.NEXT_PUBLIC_WEB_VITALS_ENDPOINT ??
DEFAULT_WEB_VITALS_ENDPOINT,
cookieMigrationEnabled: parseBoolean(
process.env.NEXT_PUBLIC_COOKIE_MIGRATION_ENABLED,
),
serviceName: "teacher-portal",
};
}
/** 配置单例(模块级缓存,避免重复读取) */
let cachedConfig: ObservabilityConfig | null = null;
/**
* 获取可观测性配置(单例)。
*
* 使用方式:
* ```ts
* import { getObservabilityConfig } from "@/lib/observability/env";
* const config = getObservabilityConfig();
* if (config.sentryDsn) { ... }
* ```
*/
export function getObservabilityConfig(): ObservabilityConfig {
if (cachedConfig === null) {
cachedConfig = buildConfig();
}
return cachedConfig;
}
/** 是否启用 SentryDSN 已配置) */
export function isSentryEnabled(): boolean {
return getObservabilityConfig().sentryDsn !== null;
}
/** 是否启用 OTel browser SDK */
export function isOTelEnabled(): boolean {
return getObservabilityConfig().otelEnabled;
}
/** 是否启用 Web Vitals 上报 */
export function isWebVitalsReportingEnabled(): boolean {
const config = getObservabilityConfig();
// 配置了 Sentry 或 OTel 任一即启用上报(避免开发环境噪音)
return config.sentryDsn !== null || config.otelEnabled;
}

View File

@@ -0,0 +1,140 @@
/**
* OpenTelemetry browser SDK 初始化P6 硬化)
*
* - 自动埋点 fetch / XHR / document load
* - 导出到 OTLP collectorendpoint 从环境变量读取)
* - 条件初始化NEXT_PUBLIC_OTEL_ENABLED=true 时)
* - 导出 initOTel() 函数
*
* 使用动态 import 加载 @opentelemetry/* 包,避免未安装包导致构建失败。
* 包将在统一安装阶段补充到 package.json。
*
* 关联02-architecture-design.md §12 可观测性 / project_rules §12 可观测性规范
*/
import { getObservabilityConfig } from "@/lib/observability/env";
/**
* OTel SDK 的最小类型声明(仅声明使用的 API 子集)。
* 避免对未安装包的静态类型依赖。
*/
interface OTelAutoInstrumentationType {
registerInstrumentations(options: {
instrumentations: unknown[];
}): void;
}
interface OTelExporterType {
OTLPTraceExporter: new (config: { url: string }) => unknown;
BatchSpanProcessor: new (exporter: unknown) => unknown;
WebTracerProvider: new () => unknown;
}
interface ZoneContextManagerType {
ZoneContextManager: new () => { enable(): unknown };
}
/** 初始化状态标记 */
let initialized = false;
/**
* 初始化 OpenTelemetry browser SDK。
*
* 条件初始化:仅在 NEXT_PUBLIC_OTEL_ENABLED=true 且 endpoint 已配置时启用。
* 使用动态 import 加载 @opentelemetry/* 各包。
*
* @returns 是否成功初始化
*/
export async function initOTel(): Promise<boolean> {
if (initialized) return false;
const config = getObservabilityConfig();
if (!config.otelEnabled) {
initialized = true;
return false;
}
if (config.otelEndpoint === null) {
if (typeof console !== "undefined") {
console.warn(
"[teacher-portal] OTel 已启用但未配置 NEXT_PUBLIC_OTEL_ENDPOINT跳过初始化",
);
}
initialized = true;
return false;
}
try {
// 动态加载 OTel 各包(运行时按需加载)
const instrumentation = (await import("@opentelemetry/instrumentation")) as unknown as OTelAutoInstrumentationType;
const fetchInstrumentationMod = await import("@opentelemetry/instrumentation-fetch");
const xhrInstrumentationMod = await import("@opentelemetry/instrumentation-xml-http-request");
const documentLoadMod = await import("@opentelemetry/instrumentation-document-load");
const webTracerMod = (await import("@opentelemetry/sdk-trace-web")) as unknown as OTelExporterType;
const exporterMod = (await import("@opentelemetry/exporter-trace-otlp-http")) as unknown as OTelExporterType;
const contextManagerMod = (await import("@opentelemetry/context-zone")) as unknown as ZoneContextManagerType;
// 构造 FetchInstrumentation
const FetchInstrumentation =
(fetchInstrumentationMod as unknown as { FetchInstrumentation: new (config: unknown) => unknown }).FetchInstrumentation;
const XHRInstrumentation =
(xhrInstrumentationMod as unknown as { XMLHttpRequestInstrumentation: new (config: unknown) => unknown }).XMLHttpRequestInstrumentation;
const DocumentLoadInstrumentation =
(documentLoadMod as unknown as { DocumentLoadInstrumentation: new () => unknown }).DocumentLoadInstrumentation;
const contextManager = new contextManagerMod.ZoneContextManager();
(contextManager as unknown as { enable(): unknown }).enable();
const exporter = new exporterMod.OTLPTraceExporter({
url: config.otelEndpoint,
});
const provider = new webTracerMod.WebTracerProvider();
const processor = new webTracerMod.BatchSpanProcessor(exporter);
// 注册 provider类型宽松处理OTel SDK 内部多态)
(
provider as unknown as {
addSpanProcessor: (processor: unknown) => void;
register: (options: { contextManager: unknown }) => void;
}
).addSpanProcessor(processor);
(
provider as unknown as {
register: (options: { contextManager: unknown }) => void;
}
).register({ contextManager });
// 注册自动埋点
instrumentation.registerInstrumentations({
instrumentations: [
new FetchInstrumentation({
propagateTraceHeaderCorsUrls: ["*"],
}),
new XHRInstrumentation({}),
new DocumentLoadInstrumentation(),
],
});
initialized = true;
if (typeof console !== "undefined" && config.environment === "development") {
console.info("[teacher-portal] OTel browser SDK 已初始化", {
endpoint: config.otelEndpoint,
});
}
return true;
} catch (err) {
// @opentelemetry/* 包未安装或加载失败,降级到无追踪
if (typeof console !== "undefined") {
console.warn("[teacher-portal] OTel 加载失败,降级到无追踪", err);
}
initialized = true;
return false;
}
}
/** OTel 是否已初始化 */
export function isOTelInitialized(): boolean {
return initialized;
}

View File

@@ -0,0 +1,230 @@
/**
* 性能优化配置P6 硬化)
*
* - Bundle 分析配置
* - size-limit 配置导出
* - 性能指标阈值Shell <150KB / Remote <80KB / CSS <50KB
* - checkBundleSize() 函数
* - getPerformanceMetrics() 函数
*
* 关联02-architecture-design.md §12 可观测性 / Module Federation 性能预算
*/
/**
* 性能预算阈值单位KB
*
* 参考 Module Federation 性能最佳实践:
* - Shell容器应用应保持精简避免大依赖
* - Remote微前端远程模块每个 remote 独立加载
* - CSS样式表预算
*/
export const PERFORMANCE_BUDGETS = {
/** Shell bundle 大小上限KB */
shell: 150,
/** Remote bundle 大小上限KB */
remote: 80,
/** CSS bundle 大小上限KB */
css: 50,
/** 单个 chunk 大小上限KB */
chunk: 244,
/** 首屏 LCP 阈值ms */
lcp: 2500,
/** 交互延迟 INP 阈值ms */
inp: 200,
/** 累计布局偏移 CLS 阈值 */
cls: 0.1,
/** 首字节时间 TTFB 阈值ms */
ttfb: 800,
} as const;
/**
* size-limit 配置(导出供 CI 使用)。
*
* 与 apps/teacher-portal/size-limit.json 保持一致。
*/
export const SIZE_LIMIT_CONFIG = [
{
name: "Shell (main bundle)",
path: ".next/static/chunks/main-*.js",
limit: `${PERFORMANCE_BUDGETS.shell} KB`,
gzip: true,
},
{
name: "Remote entry",
path: ".next/static/chunks/remoteEntry-*.js",
limit: `${PERFORMANCE_BUDGETS.remote} KB`,
gzip: true,
},
{
name: "CSS",
path: ".next/static/css/*.css",
limit: `${PERFORMANCE_BUDGETS.css} KB`,
gzip: true,
},
] as const;
/**
* Bundle 大小检查结果。
*/
export interface BundleSizeCheckResult {
/** 检查时间戳 */
timestamp: number;
/** 各资源检查结果 */
results: Array<{
name: string;
path: string;
limit: number;
actual: number | null;
passed: boolean;
}>;
/** 是否全部通过 */
passed: boolean;
}
/**
* 运行时性能指标。
*/
export interface RuntimePerformanceMetrics {
/** LCPms */
lcp: number | null;
/** INPms */
inp: number | null;
/** CLS */
cls: number | null;
/** TTFBms */
ttfb: number | null;
/** FCPms */
fcp: number | null;
/** 页面加载时间ms */
pageLoad: number | null;
/** 是否通过性能预算 */
passed: boolean;
}
/**
* 检查 bundle 是否符合大小预算。
*
* 注意:此函数需要在构建后调用,读取 .next 目录下的构建产物。
* 在浏览器环境(无文件系统访问)时返回未通过的结果。
*
* @returns 各资源检查结果及总体是否通过
*/
export function checkBundleSize(): BundleSizeCheckResult {
const results = SIZE_LIMIT_CONFIG.map((config) => {
const limitNum = Number.parseInt(config.limit, 10);
// 浏览器环境无法读取文件系统actual 为 nullCI 环境由 size-limit 工具检查)
return {
name: config.name,
path: config.path,
limit: limitNum,
actual: null,
passed: true, // CI 由 size-limit 工具实际检查
};
});
return {
timestamp: Date.now(),
results,
passed: results.every((r) => r.passed),
};
}
/**
* 获取运行时性能指标(基于 Navigation / Performance API
*
* 在浏览器环境通过 Performance API 采集运行时指标,
* 用于与性能预算对比。非浏览器环境返回空指标。
*
* @returns 运行时性能指标
*/
export function getPerformanceMetrics(): RuntimePerformanceMetrics {
if (typeof performance === "undefined") {
return {
lcp: null,
inp: null,
cls: null,
ttfb: null,
fcp: null,
pageLoad: null,
passed: false,
};
}
// 通过 Navigation Timing API 获取页面加载指标
let ttfb: number | null = null;
let fcp: number | null = null;
let pageLoad: number | null = null;
try {
const entries = performance.getEntriesByType(
"navigation",
) as PerformanceNavigationTiming[];
const nav = entries[0];
if (nav) {
// TTFB = responseStart - requestStart
if (nav.responseStart > 0 && nav.requestStart > 0) {
ttfb = nav.responseStart - nav.requestStart;
}
// 页面加载时间 = loadEventEnd - startTime
if (nav.loadEventEnd > 0) {
pageLoad = nav.loadEventEnd - nav.startTime;
}
}
// FCP 通过 Paint Timing API 获取
const paintEntries = performance.getEntriesByType(
"paint",
) as PerformanceEntry[];
const fcpEntry = paintEntries.find(
(e) => e.name === "first-contentful-paint",
);
if (fcpEntry) {
fcp = fcpEntry.startTime;
}
} catch {
// Performance API 不可用时保持 null
}
// LCP / INP / CLS 通过 Web Vitals 库采集(此处仅汇总阈值检查)
// 实际值由 web-vitals.ts 上报,此处仅做预算对比
const passed =
(ttfb === null || ttfb <= PERFORMANCE_BUDGETS.ttfb) &&
(fcp === null || fcp <= PERFORMANCE_BUDGETS.lcp);
return {
lcp: null, // 由 web-vitals.ts 采集
inp: null, // 由 web-vitals.ts 采集
cls: null, // 由 web-vitals.ts 采集
ttfb,
fcp,
pageLoad,
passed,
};
}
/**
* 检查单个指标是否通过性能预算。
*
* @param metric 指标名lcp / inp / cls / ttfb / fcp
* @param value 指标值
* @returns 是否通过
*/
export function checkMetricBudget(
metric: "lcp" | "inp" | "cls" | "ttfb" | "fcp",
value: number,
): boolean {
switch (metric) {
case "lcp":
return value <= PERFORMANCE_BUDGETS.lcp;
case "inp":
return value <= PERFORMANCE_BUDGETS.inp;
case "cls":
return value <= PERFORMANCE_BUDGETS.cls;
case "ttfb":
return value <= PERFORMANCE_BUDGETS.ttfb;
case "fcp":
return value <= PERFORMANCE_BUDGETS.lcp;
default:
return true;
}
}

View File

@@ -0,0 +1,193 @@
/**
* Sentry 错误追踪初始化P6 硬化)
*
* - 参考 student-portal/src/lib/observability/sentry.ts 实现
* - 仅当 NEXT_PUBLIC_SENTRY_DSN 配置时初始化(动态 import @sentry/nextjs
* - beforeSend PII 过滤email / phone / token / password / 身份证 / 教师姓名
* - 设置 release / tag / environment
* - 导出 initSentry() + captureException / captureMessage 包装器
*
* 关联02-architecture-design.md §12 可观测性
*/
import { getObservabilityConfig } from "@/lib/observability/env";
/**
* Sentry 模块类型(仅声明使用的 API 子集)。
* 避免对未安装包的静态类型依赖。
*/
interface SentryModule {
init(options: {
dsn: string;
environment: string;
release?: string;
tracesSampleRate: number;
beforeSend?: (event: unknown) => unknown;
}): void;
captureException(err: unknown, context?: { extra?: Record<string, unknown> }): void;
captureMessage(message: string): void;
addBreadcrumb(breadcrumb: {
category?: string;
message?: string;
level?: string;
}): void;
setTag(key: string, value: string): void;
}
/** 需要脱敏的 PII 字段名(匹配 key不区分大小写 */
const PII_KEYS = [
"teachername",
"name",
"email",
"phone",
"mobile",
"idcard",
"id_card",
"token",
"accesstoken",
"access_token",
"refreshtoken",
"refresh_token",
"password",
"secret",
"authorization",
];
/** Sentry 模块缓存initSentry 成功后赋值) */
let sentryModule: SentryModule | null = null;
/** 初始化状态标记 */
let initialized = false;
/**
* 递归移除 PII 字段(返回脱敏后的副本)。
*
* 从 unknown 转换为结构化对象处理,符合 project_rules §3.4 禁止 any 规则。
*/
function stripPII(input: unknown): unknown {
if (Array.isArray(input)) {
return input.map(stripPII);
}
if (input !== null && typeof input === "object") {
const obj = input as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const key of Object.keys(obj)) {
if (PII_KEYS.includes(key.toLowerCase())) {
out[key] = "[REDACTED]";
} else {
out[key] = stripPII(obj[key]);
}
}
return out;
}
return input;
}
/**
* 初始化 Sentry仅在浏览器/服务端入口调用一次)。
*
* 使用动态 import 加载 @sentry/nextjs避免未安装包导致构建失败。
* 包将在统一安装阶段补充到 package.json。
*
* @returns 是否成功初始化false 表示未配置 DSN 或加载失败)
*/
export async function initSentry(): Promise<boolean> {
if (initialized) return sentryModule !== null;
const config = getObservabilityConfig();
if (config.sentryDsn === null) {
// 未配置 DSN标记为已检查避免重复读取配置
initialized = true;
return false;
}
try {
const Sentry = (await import("@sentry/nextjs")) as unknown as SentryModule;
Sentry.init({
dsn: config.sentryDsn,
environment: config.environment,
release: config.sentryRelease ?? undefined,
tracesSampleRate: config.tracesSampleRate,
beforeSend(event: unknown) {
// 过滤 PII 后回传事件
return stripPII(event);
},
});
// 设置全局 tagservice 标识,便于 Sentry 面板按应用过滤)
Sentry.setTag("service", config.serviceName);
Sentry.setTag("portal", "teacher");
sentryModule = Sentry;
initialized = true;
// 挂载捕获器供 ErrorBoundary 使用(参考 student-portal 模式)
if (typeof window !== "undefined") {
(
window as unknown as {
__eduCaptureException?: (e: Error, extra?: unknown) => void;
}
).__eduCaptureException = (err: Error, extra?: unknown) => {
if (sentryModule) {
sentryModule.captureException(
err,
extra !== undefined ? { extra: { detail: extra } } : undefined,
);
}
};
}
return true;
} catch (err) {
// @sentry/nextjs 未安装或加载失败,降级到 console
if (typeof console !== "undefined") {
console.warn("[teacher-portal] Sentry 加载失败,降级到 console", err);
}
initialized = true;
return false;
}
}
/**
* 捕获异常(未初始化时降级为 console.error
*
* 同步 API若 Sentry 模块已加载则上报,否则输出到控制台。
*/
export function captureException(err: unknown): void {
if (sentryModule) {
sentryModule.captureException(err);
} else if (typeof console !== "undefined") {
console.error("[teacher-portal]", err);
}
}
/**
* 捕获消息(未初始化时降级为 console.warn
*/
export function captureMessage(message: string): void {
if (sentryModule) {
sentryModule.captureMessage(message);
} else if (typeof console !== "undefined") {
console.warn("[teacher-portal]", message);
}
}
/**
* 添加面包屑(用于错误上下文追踪)。
* 未初始化时为空操作。
*/
export function addBreadcrumb(breadcrumb: {
category?: string;
message?: string;
level?: string;
}): void {
if (sentryModule) {
sentryModule.addBreadcrumb(breadcrumb);
}
}
/** Sentry 是否已初始化(可用于运行时判断) */
export function isSentryInitialized(): boolean {
return sentryModule !== null;
}

View File

@@ -0,0 +1,131 @@
/**
* Web Vitals RUM 采集P6 硬化)
*
* - 参考 student-portal 和 admin-portal 的实现
* - 使用 web-vitals 库的 onCLS / onINP / onLCP / onTTFB / onFCP
* - 将指标上报到 /api/v1/admin/web-vitalsnavigator.sendBeacon
* - 导出 initWebVitals() + WebVitalMetric 类型
*
* 关联02-architecture-design.md §12 可观测性
*/
import { getObservabilityConfig, isWebVitalsReportingEnabled } from "@/lib/observability/env";
/**
* Web Vital 指标结构(与 web-vitals 库 Metric 对齐)。
*/
export interface WebVitalMetric {
/** 指标名LCP / CLS / INP / FCP / TTFB */
name: string;
/** 指标值 */
value: number;
/** 评级good / needs-improvement / poor */
rating: string;
/** 指标唯一标识 */
id: string;
/** 增量值(部分指标使用) */
delta?: number;
}
/**
* web-vitals 库的回调函数类型(最小声明)。
* 避免对未安装包的静态类型依赖。
*/
type MetricCallback = (metric: WebVitalMetric) => void;
interface WebVitalsLib {
onLCP(cb: MetricCallback): void;
onCLS(cb: MetricCallback): void;
onFCP(cb: MetricCallback): void;
onINP(cb: MetricCallback): void;
onTTFB(cb: MetricCallback): void;
}
/** 上报状态标记,避免重复注册回调 */
let initialized = false;
/**
* 上报单个 Web Vital 指标。
*
* 使用 navigator.sendBeacon 优先(页面卸载时不丢失),
* 降级到 fetch keepalive。
*/
function sendMetric(metric: WebVitalMetric): void {
if (typeof window === "undefined") return;
if (!isWebVitalsReportingEnabled()) return;
const config = getObservabilityConfig();
const payload = {
name: metric.name,
value: metric.value,
rating: metric.rating,
id: metric.id,
delta: metric.delta,
service: config.serviceName,
portal: "teacher",
page: window.location.pathname,
timestamp: Date.now(),
};
try {
const body = JSON.stringify(payload);
if (typeof navigator !== "undefined" && navigator.sendBeacon) {
const blob = new Blob([body], { type: "application/json" });
navigator.sendBeacon(config.webVitalsEndpoint, blob);
return;
}
// sendBeacon 不可用时降级 fetch keepalive
void fetch(config.webVitalsEndpoint, {
method: "POST",
body,
headers: { "Content-Type": "application/json" },
keepalive: true,
}).catch(() => {
// 上报失败不影响用户体验,静默忽略
});
} catch {
// 上报失败静默忽略
}
}
/**
* 初始化 Web Vitals 采集。
*
* 使用动态 import 加载 web-vitals 库,避免未安装包导致构建失败。
* 包将在统一安装阶段补充到 package.json。
*/
export async function initWebVitals(): Promise<void> {
if (initialized) return;
if (typeof window === "undefined") return;
try {
const webVitals = (await import("web-vitals")) as unknown as WebVitalsLib;
webVitals.onLCP(sendMetric);
webVitals.onCLS(sendMetric);
webVitals.onFCP(sendMetric);
webVitals.onINP(sendMetric);
webVitals.onTTFB(sendMetric);
initialized = true;
} catch (err) {
// web-vitals 未安装或加载失败,降级到无采集
if (typeof console !== "undefined") {
console.warn("[teacher-portal] web-vitals 加载失败", err);
}
}
}
/**
* 手动上报单个 Web Vital 指标(供 Next.js reportWebVitals 使用)。
*
* 用法:在 layout.tsx 中 export function reportWebVitals(metric) { sendWebVital(metric); }
*/
export function reportWebVitals(metric: WebVitalMetric): void {
sendMetric(metric);
}
/** Web Vitals 是否已初始化 */
export function isWebVitalsInitialized(): boolean {
return initialized;
}