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:
122
apps/teacher-portal/src/lib/graphql-p4.ts
Normal file
122
apps/teacher-portal/src/lib/graphql-p4.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* GraphQL P4 扩展 Query - 知识图谱 + 学情分析
|
||||
*
|
||||
* 维护者:ai13(teacher-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
|
||||
}
|
||||
}
|
||||
`;
|
||||
137
apps/teacher-portal/src/lib/graphql-p5.ts
Normal file
137
apps/teacher-portal/src/lib/graphql-p5.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* GraphQL operations - P5 扩展(通知中心 + AI 辅助)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:02-architecture-design.md §5 实时推送、§6 AI 辅助
|
||||
*
|
||||
* schema 来源:teacher-bff.graphql(P5 扩展,MSW mock,待 ai03 在上游补 schema)
|
||||
* - 通知:myNotifications / markAsRead(push-gateway 实时推送 + GraphQL 持久化)
|
||||
* - AI:generateQuestion / generateLessonPlan / generateReport
|
||||
*
|
||||
* 注意:本文件独立于 graphql.ts(P2/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;
|
||||
}
|
||||
|
||||
// ============ Types(AI 辅助) ============
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
`;
|
||||
713
apps/teacher-portal/src/lib/graphql-p7-admin.ts
Normal file
713
apps/teacher-portal/src/lib/graphql-p7-admin.ts
Normal file
@@ -0,0 +1,713 @@
|
||||
/**
|
||||
* GraphQL operations - P7 扩展(行政管理子模块:考勤 + 班级详情 + 课表 + 请假 + 调课)
|
||||
*
|
||||
* 维护者:ai13(teacher-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
|
||||
}
|
||||
}
|
||||
`;
|
||||
499
apps/teacher-portal/src/lib/graphql-p7-advanced.ts
Normal file
499
apps/teacher-portal/src/lib/graphql-p7-advanced.ts
Normal file
@@ -0,0 +1,499 @@
|
||||
/**
|
||||
* GraphQL operations - P7 扩展(选修课 + 富文本编辑 + 监考 + 扫描批改 + 课标热力图)
|
||||
*
|
||||
* 维护者:ai13(teacher-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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
628
apps/teacher-portal/src/lib/graphql-p7-exams.ts
Normal file
628
apps/teacher-portal/src/lib/graphql-p7-exams.ts
Normal file
@@ -0,0 +1,628 @@
|
||||
/**
|
||||
* GraphQL operations - P7 扩展(组卷 + 题库 + 教材 + 考试分析)
|
||||
*
|
||||
* 维护者:ai13(teacher-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
|
||||
}
|
||||
}
|
||||
`;
|
||||
418
apps/teacher-portal/src/lib/graphql-p7-grades.ts
Normal file
418
apps/teacher-portal/src/lib/graphql-p7-grades.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* GraphQL operations - P7 扩展(成绩完整子模块 + 作业批改链路)
|
||||
*
|
||||
* 维护者:ai13(teacher-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
|
||||
}
|
||||
}
|
||||
`;
|
||||
737
apps/teacher-portal/src/lib/graphql-p7-insights.ts
Normal file
737
apps/teacher-portal/src/lib/graphql-p7-insights.ts
Normal file
@@ -0,0 +1,737 @@
|
||||
/**
|
||||
* GraphQL operations - P7 扩展(备课 + 课程计划 + 诊断 + 错题本 + 自适应练习)
|
||||
*
|
||||
* 维护者:ai13(teacher-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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -300,3 +300,173 @@ export const UpdateUserMutation = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ============ P3 Mutation/Query(MSW mock,core-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;
|
||||
}
|
||||
|
||||
/** 创建考试 Mutation(P3 扩展,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;
|
||||
}
|
||||
|
||||
/** 布置作业 Mutation(P3 扩展,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;
|
||||
}
|
||||
|
||||
/** 录入成绩 Mutation(P3 扩展,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
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
272
apps/teacher-portal/src/lib/observability/a11y.ts
Normal file
272
apps/teacher-portal/src/lib/observability/a11y.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
189
apps/teacher-portal/src/lib/observability/cookie-migration.ts
Normal file
189
apps/teacher-portal/src/lib/observability/cookie-migration.ts
Normal 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 token(httpOnly 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;
|
||||
}
|
||||
113
apps/teacher-portal/src/lib/observability/env.ts
Normal file
113
apps/teacher-portal/src/lib/observability/env.ts
Normal 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;
|
||||
}
|
||||
|
||||
/** 是否启用 Sentry(DSN 已配置) */
|
||||
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;
|
||||
}
|
||||
140
apps/teacher-portal/src/lib/observability/otel.ts
Normal file
140
apps/teacher-portal/src/lib/observability/otel.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* OpenTelemetry browser SDK 初始化(P6 硬化)
|
||||
*
|
||||
* - 自动埋点 fetch / XHR / document load
|
||||
* - 导出到 OTLP collector(endpoint 从环境变量读取)
|
||||
* - 条件初始化(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;
|
||||
}
|
||||
230
apps/teacher-portal/src/lib/observability/performance.ts
Normal file
230
apps/teacher-portal/src/lib/observability/performance.ts
Normal 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 {
|
||||
/** LCP(ms) */
|
||||
lcp: number | null;
|
||||
/** INP(ms) */
|
||||
inp: number | null;
|
||||
/** CLS */
|
||||
cls: number | null;
|
||||
/** TTFB(ms) */
|
||||
ttfb: number | null;
|
||||
/** FCP(ms) */
|
||||
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 为 null(CI 环境由 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;
|
||||
}
|
||||
}
|
||||
193
apps/teacher-portal/src/lib/observability/sentry.ts
Normal file
193
apps/teacher-portal/src/lib/observability/sentry.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
|
||||
// 设置全局 tag(service 标识,便于 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;
|
||||
}
|
||||
131
apps/teacher-portal/src/lib/observability/web-vitals.ts
Normal file
131
apps/teacher-portal/src/lib/observability/web-vitals.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Web Vitals RUM 采集(P6 硬化)
|
||||
*
|
||||
* - 参考 student-portal 和 admin-portal 的实现
|
||||
* - 使用 web-vitals 库的 onCLS / onINP / onLCP / onTTFB / onFCP
|
||||
* - 将指标上报到 /api/v1/admin/web-vitals(navigator.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;
|
||||
}
|
||||
Reference in New Issue
Block a user