98 KiB
模块架构设计文档 — student-portal
AI:ai14(TS/React · 学习场景域前端 remote) 阶段:阶段 2 交付物(v2 — ai14 接管审计与补全版) 初版日期:2026-07-09(ai07 起草) 审计日期:2026-07-10(ai14 修订:协议 REST→GraphQL、端口 3001→4001、所有权 ai07→ai14、MF 配置 ARB-002、路由前缀、错误码前缀、长远架构补全) 关联:阶段 1 理解确认书、004 架构影响地图 §5.4、pending-features P3、teacher-portal 阶段 2 架构设计、teacher-portal 阶段 3 长远架构、known-issues §2.15、coord 仲裁 ARB-001、coord 仲裁 ARB-002、student-bff 契约、student-portal 契约 状态:待 coord 交叉审查
v2 审计修订摘要(ai14 → ai07 初稿):
- 协议修订:REST → GraphQL over HTTP(ARB-001 已裁决 student-bff 走 GraphQL Yoga + ActionState 信封 + DataLoader;前端 all-in GraphQL,复用 Shell 暴露的 urql client 单例)
- 端口修订:3001 → 4001(004 §1.2 强制 4 端 4000-4003;port-allocation §4 硬约束;3001 已被 classes 历史占用)
- 所有权修订:ai07 → ai14(ai-allocation.md §3.2 L94;ai07=classes/core-edu 交接)
- MF 配置修订:按 ARB-002,Shell 暴露
GraphQLProvider/useGraphQLClient/urql/graphql单例;student-portal 不再实现 ApiClient,不重复创建 GraphQL client- 路由修订:
/student/*→/*(无前缀,与 student-portal_contract.md §1.2 对齐;student-portal 是独立 dev server :4001)- 登录路径修订:
/iam/login→POST /api/auth/login(iam 仅 gRPC,登录由 api-gateway 聚合)- 错误码前缀修订:
EXAMS_/HOMEWORK_/GRADES_→CORE_EDU_(known-issues §2.15);STUDENT_BFF_→BFF_STUDENT_- 遗漏补全:考试作答架构设计(断网恢复/时间对齐/防作弊/IDB 队列)、详细组件设计、跨标签同步、API 版本管理、监控与降级、模块演化、长远架构愿景
- 新增章节:§14 考试作答架构设计、§15 详细组件设计、§16 跨标签同步实现、§17 API 版本管理、§18 监控与降级、§19 模块演化与解耦、§20 长远架构愿景
1. 模块内部分层图(student-portal Remote 视角)
graph TB
subgraph Browser["浏览器(学生)"]
URL[URL 路由 /*<br/>无 /student 前缀]
end
subgraph Shell["teacher-portal(Shell 宿主 :4000)"]
AppShell[AppShell<br/>左栏导航 + 主内容区<br/>按 scope=student 过滤视口]
RootLayout[RootLayout<br/>字体/令牌/i18n/GraphQL/Query Provider]
GraphQLProvider["GraphQLProvider<br/>urql client 单例(ARB-002)<br/>含 exchanges: cache/auth/error/retry"]
SharedDeps["共享依赖暴露(singleton)<br/>react/react-dom/urql/graphql/@tanstack/react-query<br/>zustand/nuqs/@edu/ui-components/@edu/ui-tokens<br/>@edu/contracts/@edu/hooks/@edu/shared-ts"]
Rewrites["rewrites /api/v1/* + /api/auth/* → api-gateway"]
end
subgraph RemoteStudent["student-portal(Remote :4001)"]
StudentPages["学习场景页面<br/>dashboard/my-homework/my-exams/take<br/>my-grades/my-attendance/learning-path<br/>diagnostic(P4)/weakness(P4)/notifications(P5)"]
ExamTaking[ExamTaking 组件<br/>倒计时 + 自动保存 + 断网恢复 + 防作弊]
StudentHooks["GraphQL Hooks<br/>useMyExams/useMyHomework/useExamTaking<br/>useSubmitHomework/useMyGrades 等"]
IDBQueue["IDB 队列<br/>断网恢复重试队列<br/>考试草稿持久化"]
end
subgraph Shared["共享层(packages/,由 Shell 暴露)"]
UITokens[ui-tokens<br/>三层设计令牌]
UIComponents[ui-components<br/>shadcn + A11y + ErrorBoundary + RequirePermission]
Contracts[contracts<br/>Permissions 常量 + 类型]
Hooks[hooks<br/>usePermission/useAuth/useViewports/useA11y]
LibTS[shared-ts<br/>Logger/Tracer/通用工具]
end
subgraph Gateway["api-gateway :8080"]
GW[Gin 路由/JWT 验签/限流]
end
subgraph BFF["student-bff :3009"]
BFFGraphQL[GraphQL Yoga endpoint<br/>POST /graphql]
end
subgraph PushGW["push-gateway :8081(P5)"]
WS[WebSocket /ws]
end
Browser --> URL
URL --> RootLayout
RootLayout --> AppShell
RootLayout --> GraphQLProvider
AppShell -->|动态加载 Remote| RemoteStudent
RemoteStudent -->|消费 singleton| SharedDeps
RemoteStudent --> StudentPages
StudentPages --> ExamTaking
StudentPages --> StudentHooks
StudentHooks -->|复用 urql client| GraphQLProvider
ExamTaking --> IDBQueue
SharedDeps --> UITokens
SharedDeps --> UIComponents
SharedDeps --> Contracts
SharedDeps --> Hooks
SharedDeps --> LibTS
StudentHooks -->|POST /api/v1/student/graphql| Rewrites
Rewrites --> GW
GW --> BFFGraphQL
RemoteStudent -.->|P5 WebSocket| WS
WS -.->|推送事件| RemoteStudent
1.1 Remote 与 Shell 的职责边界
| 职责 | Shell(teacher-portal) | Remote(student-portal) |
|---|---|---|
| RootLayout(字体/令牌/Provider) | ✅ 提供 | ❌ 复用 |
| AppShell(左栏 + 主内容区) | ✅ 提供 | ❌ 复用 |
| GraphQLProvider(urql client) | ✅ 提供(ARB-002) | ❌ 复用单例 |
| 共享依赖暴露(singleton) | ✅ 提供 | ❌ 消费 |
| 登录页 | ✅ 提供(P2 不走 MF) | ❌ 复用 |
rewrites /api/v1/* |
✅ 提供 | ❌ 依赖 Shell |
路由表 /*(无 /student 前缀) |
❌ 由 Remote 暴露 | ✅ 提供 |
| 学习场景页面 | ❌ | ✅ |
| GraphQL 业务 Hooks | ❌ | ✅ |
| 考试作答组件(ExamTaking 等) | ❌ | ✅ |
| IDB 断网恢复队列 | ❌ | ✅ |
1.2 MF 配置(student-portal/next.config.js,Remote 角色)
// apps/student-portal/next.config.js(Remote)
const NextFederationPlugin = require("@module-federation/nextjs-mf");
module.exports = {
reactStrictMode: true,
transpilePackages: [
"@edu/ui-tokens",
"@edu/ui-components",
"@edu/hooks",
"@edu/contracts",
"@edu/shared-ts",
],
webpack(config, { isServer }) {
config.plugins.push(
new NextFederationPlugin({
name: "student_app",
filename: "static/chunks/remoteEntry.js",
// Remote 暴露页面入口 + 考试作答组件(供 Shell 路由复用)
exposes: {
"./pages": "./src/pages",
"./ExamTaking": "./src/components/ExamTaking",
},
// Remote 反向引用 Shell 暴露的共享组件 + GraphQL 单例(可选,多数通过 singleton shared 解决)
remotes: {
teacher: `teacher_app@http://localhost:4000/_next/static/${isServer ? "ssr" : "chunks"}/remoteEntry.js`,
},
shared: {
react: { singleton: true, requiredVersion: "^18.3.0" },
"react-dom": { singleton: true, requiredVersion: "^18.3.0" },
// ARB-002:GraphQL 相关依赖由 Shell 暴露为 singleton
urql: { singleton: true, requiredVersion: "^2.2.0" },
graphql: { singleton: true, requiredVersion: "^16.8.0" },
"@tanstack/react-query": { singleton: true, requiredVersion: "^5.0.0" },
zustand: { singleton: true },
nuqs: { singleton: true },
"@edu/ui-tokens": { singleton: true },
"@edu/ui-components": { singleton: true },
"@edu/hooks": { singleton: true },
"@edu/contracts": { singleton: true },
"@edu/shared-ts": { singleton: true },
},
extraOptions: { exposePages: false },
}),
);
return config;
},
// student-portal 不实现 rewrites,依赖 teacher-portal Shell 的 rewrites 代理 /api/v1/* 与 /api/auth/*
};
说明(ARB-002 对齐):
name: 'student_app':Remote 应用名,被 Shell 在remotes中引用为student: 'student_app@http://localhost:4001/...'- Shell 端口 4000(ARB-002 MF URL),student-portal dev 端口 4001
urql/graphql声明为singleton: true,确保与 Shell 共享同一 GraphQL client 实例(ARB-002 强制)shared全部singleton: true,避免 React 多实例、Zustand store 分裂、GraphQL cache 分裂transpilePackages列出本地packages/*软链依赖,确保 SWC 正确编译- student-portal 不实现 rewrites,所有
/api/v1/*与/api/auth/*请求由 Shell 的 rewrites 代理到 api-gateway
2. 领域模型(前端视角)
前端不持有业务聚合根,仅持有"视图模型"(ViewModel)和"会话状态"。Session/Viewport/Permission 三模型与 Shell 共享,详见 teacher-portal 阶段 2 §2。
2.1 会话状态(Session)
复用 Shell 的 Session 模型,student-portal 不重复定义。详见 teacher-portal 文档 §2.1。
2.2 视口模型(Viewport)
复用 Shell 的 ViewportItem 模型,但 scope 字段值为 'student'。来源:student-bff GraphQL viewports query(聚合 iam 视口配置)。AppShell 按 scope='student' 过滤渲染学生端导航。
2.3 权限模型(Permission)
复用 Shell 的 PermissionState 模型。来源:POST /api/v1/student/graphql query currentUser → { permissions, viewports, dataScope }。学生 dataScope 固定为 L0(仅本人)。
2.4 student-portal 特有视图模型
// 考试作答草稿(Zustand L3 + IDB,断网恢复用)
interface ExamTakingDraft {
examId: string;
studentId: string;
answers: Record<string, AnswerInput>; // questionId → answer
startedAt: number; // 客户端开始时间戳
serverStartedAt: number; // 服务器开始时间戳(用于倒计时校正)
lastSavedAt: number | null;
durationSeconds: number; // 考试时长(秒,来自服务器)
expiresAt: string; // ISO 8601 UTC,考试截止时间(来自服务器)
suspiciousBehaviors: SuspiciousBehaviorRecord[]; // 防作弊记录
}
// 答案输入(联合类型,按题型区分)
type AnswerInput =
| { type: "single-choice"; optionId: string }
| { type: "multiple-choice"; optionIds: string[] }
| { type: "fill-blank"; values: string[] }
| { type: "short-answer"; text: string }
| { type: "essay"; text: string; attachments?: AttachmentRef[] };
// 可疑行为记录(防作弊,前端仅记录,服务端最终判定)
interface SuspiciousBehaviorRecord {
type: "tab-switch" | "copy" | "paste" | "paste-essay" | "fullscreen-exit" | "multi-tab" | "window-blur";
timestamp: number;
duration?: number; // 持续时长(ms)
details?: string;
}
// 作业提交表单(react-hook-form L5)
interface HomeworkSubmitForm {
homeworkId: string;
answers: AnswerInput[];
attachments?: File[]; // 附件(图片/PDF)
note?: string; // 学生备注
}
// 学情诊断视图模型(P4)
interface DiagnosticViewModel {
examId: string;
examName: string;
score: number;
maxScore: number;
knowledgePoints: Array<{
id: string;
name: string;
mastery: number; // 0-1
questionCount: number;
correctCount: number;
}>;
radar: Array<{ axis: string; value: number }>; // 多维雷达图数据
trend: Array<{ date: string; score: number }>; // 趋势线数据
}
// 错题本视图模型(P4)
interface WeaknessViewModel {
questionId: string;
questionContent: string; // 题目内容(脱敏后)
knowledgePointId: string;
knowledgePointName: string;
mastery: number; // 0-1,掌握度
wrongCount: number;
lastWrongAt: string; // ISO 8601
recommendedAction: "review" | "practice" | "mastered";
}
// 学习路径节点(P4)
interface LearningPathNode {
id: string;
name: string;
knowledgePointId: string;
status: "locked" | "available" | "in-progress" | "completed";
dependencies: string[]; // 前置节点 ID
mastery: number;
recommendedOrder: number;
}
3. 数据模型(前端缓存层)
student-portal 无数据库,仅有 GraphQL cache(urql)+ TanStack Query cache + Zustand L3 + IDB。学生数据实时性要求高,缓存策略偏短:
| 数据类型 | 存储 | TTL | 失效策略 |
|---|---|---|---|
| Session(token + user) | localStorage + Zustand | — | 复用 Shell:access 15min / refresh 7day,401 自动 refresh |
| 权限列表 | urql cache | 5min | 复用 Shell:角色变更事件 invalidate |
| 视口列表(scope=student) | urql cache | 5min | 复用 Shell |
| 学生 Dashboard 聚合数据 | urql cache | 30s | staleTime 30s,WebSocket 事件触发 invalidate |
| 我的作业列表 | urql cache | 30s | staleTime 30s,提交后 invalidate |
| 我的考试列表 | urql cache | 30s | staleTime 30s,ExamPublished 事件 invalidate |
| 考试题目数据 | urql cache | 0s | 考试作答页禁缓存(防预渲染泄露答案);进入时强制 refetch |
| 考试作答草稿 | Zustand L3 + IDB | — | 卸载不销毁,自动保存(每 30s + blur 时);提交成功后清除 IDB |
| 学情诊断数据 | urql cache | 30s | staleTime 30s(实时性由 BFF 决定) |
| 错题本列表 | urql cache | 30s | staleTime 30s |
| 学习路径 | urql cache | 5min | staleTime 5min(低频变更) |
| 通知列表(P5) | urql cache | 30s | staleTime 30s,WebSocket 事件 invalidate |
| 服务器时间偏移 | Zustand L3(内存) | — | 每次进入考试页同步一次 |
| 断网重试队列 | IDB | — | 网络恢复后批量重试,成功后清除 |
| URL 状态(分页/筛选) | nuqs | — | 永久(可分享);考试作答页 URL 不携带答案(防泄露) |
| 表单临时态(作业提交) | react-hook-form | — | 卸载即销毁 |
| 防作弊行为记录 | Zustand L3(内存队列 100) | — | 队列满后批量上报,上报后清空 |
缓存策略说明:学生数据(作业/考试/成绩)频变且实时性要求高,30s 短缓存平衡新鲜度与 BFF 压力;考试题目数据强制 0s 禁缓存防止泄露;考试作答草稿用 IDB 持久化保证断网恢复。
4. API 设计(前端 → 后端)
前端不设计后端 API,仅声明消费的 GraphQL 操作。详见 01-understanding.md §3.1。
4.1 GraphQL 请求层(复用 Shell 暴露的 urql client 单例)
student-portal 不重复创建 GraphQL client,复用 Shell 暴露的 GraphQLProvider(ARB-002)。urql client 配置详见 teacher-portal 阶段 2 §4.1。
student-portal 仅封装业务 Hooks,消费 Shell 的 useGraphQLClient():
// apps/student-portal/src/lib/graphql.ts
import { useGraphQLClient } from "@edu/hooks";
import { useQuery, useMutation } from "@tanstack/react-query";
import { gql } from "urql";
// 学生 Dashboard 聚合查询
const STUDENT_DASHBOARD = gql`
query StudentDashboard {
studentDashboard {
upcomingHomework { id title dueAt subject { id name } status }
upcomingExams { id name startsAt expiresAt durationSeconds subject { id name } status }
recentGrades { id examName score maxScore grade submittedAt }
attendanceRate
learningStreakDays
}
}
`;
export function useStudentDashboard() {
const client = useGraphQLClient();
return useQuery({
queryKey: ["student", "dashboard"],
queryFn: () => client.query(STUDENT_DASHBOARD, {}).toPromise(),
staleTime: 30 * 1000, // 30s
});
}
// 考试作答相关
const MY_EXAMS = gql`
query MyExams($status: ExamStatus) {
myExams(status: $status) {
id name startsAt expiresAt durationSeconds
subject { id name }
status questionCount
}
}
`;
const EXAM_DETAIL = gql`
query ExamDetail($examId: ID!) {
exam(id: $examId) {
id name durationSeconds startsAt expiresAt
questions {
id type content options { id text }
score maxScore
}
}
}
`;
const SAVE_EXAM_DRAFT = gql`
mutation SaveExamDraft($examId: ID!, $questionId: ID!, $answer: AnswerInput!) {
saveExamDraft(examId: $examId, questionId: $questionId, answer: $answer) {
savedAt
ok
}
}
`;
const SUBMIT_EXAM = gql`
mutation SubmitExam($examId: ID!) {
submitExam(examId: $examId) {
submissionId submittedAt
}
}
`;
export function useMyExams(status?: ExamStatus) {
const client = useGraphQLClient();
return useQuery({
queryKey: ["student", "exams", { status }],
queryFn: () => client.query(MY_EXAMS, { status }).toPromise(),
staleTime: 30 * 1000,
});
}
export function useExamDetail(examId: string) {
const client = useGraphQLClient();
return useQuery({
queryKey: ["student", "exam", examId],
queryFn: () => client.query(EXAM_DETAIL, { examId }).toPromise(),
staleTime: 0, // 禁缓存,防泄露
});
}
4.2 GraphQL 操作清单(对齐 student-bff 契约)
| 操作类型 | 操作名 | 用途 | 阶段 |
|---|---|---|---|
| query | currentUser |
当前学生信息 + 权限 + 视口 | P3 |
| query | myClasses |
我的班级列表 | P3 |
| query | myExams |
我的考试列表 | P3 |
| query | exam |
考试详情(含题目) | P3 |
| query | examSubmissionStatus |
考试提交状态(防重复提交) | P3 |
| query | examResult |
考试结果 | P3 |
| query | myHomework |
我的作业列表 | P3 |
| query | homeworkDetail |
作业详情 | P3 |
| query | myGrades |
我的成绩列表 | P3 |
| query | myAttendance |
我的考勤 | P3 |
| query | studentDashboard |
学生仪表盘聚合 | P3 |
| query | serverTime |
服务器时间(倒计时校正) | P3 |
| mutation | submitHomework |
提交作业(含 attachmentUrls) | P3 |
| mutation | saveExamDraft |
保存考试草稿(自动保存) | P3 |
| mutation | submitExam |
提交考试 | P3 |
| mutation | recordExamViolation |
记录防作弊违规(ARB-019 §21.4) | P3 |
| mutation | recordPasteEvent |
记录粘贴事件(ARB-019 §21.4) | P3 |
| query | textbooks |
教材列表 | P4 |
| query | chapters |
章节列表 | P4 |
| query | learningPath |
学习路径 | P4 |
| query | myWeakness |
错题本 | P4 |
| query | myTrend |
学习趋势 | P4 |
| query | myNotifications |
通知列表 | P5 |
| mutation | markAsRead |
标记通知已读 | P5 |
| mutation | markAllAsRead |
全部标记已读 | P5 |
| mutation | updateNotificationPreference |
更新通知偏好 | P5 |
附件上传(ISSUE-014-05 已裁决方案 A):走 api-gateway REST
POST /api/v1/student/upload(multipart/form-data)→ 对象存储 → 返回 signed URL → GraphQLsubmitHomework(input: { attachmentUrls })提交 URL。不使用 GraphQL multipart mutation。
4.3 Query Key 命名约定
// GraphQL operation → TanStack Query key
queryKey: ["student", "dashboard"];
queryKey: ["student", "exams", { status }];
queryKey: ["student", "exam", examId]; // 考试详情
queryKey: ["student", "homework", { status, page }];
queryKey: ["student", "homework", homeworkId];
queryKey: ["student", "grades", { subjectId }];
queryKey: ["student", "attendance", { month }];
queryKey: ["student", "diagnostic", { examId }];
queryKey: ["student", "weakness", { knowledgePointId }];
queryKey: ["student", "learning-path"];
queryKey: ["student", "notifications", { unreadOnly }];
queryKey: ["session", "effective-permissions"]; // 复用 Shell
queryKey: ["session", "viewports", "student"]; // 复用 Shell
5. 事件设计
前端不发布 Kafka 事件,仅消费 WebSocket 推送(P5)+ 浏览器原生事件(防作弊)。
5.1 WebSocket 推送事件(P5)
| 事件 | 触发 | student-portal 前端动作 |
|---|---|---|
NotificationRequested |
msg 服务投递 | toast 提示 + 通知中心未读数 +1 + ["student","notifications"] invalidate |
ExamPublished |
教师发布考试 | toast + ["student","exams"] invalidate + ["student","dashboard"] invalidate |
ExamExtended |
教师延长考试 | 重新拉取 exam query → 更新 expiresAt → 重置倒计时(ARB-019 §21.6 已确认) |
ExamForceSubmitted |
教师强制收卷 | 立即触发 submitExam mutation → 跳转结果页(ARB-019 §21.6 已确认) |
ExamQuestionReordered |
教师调整题目顺序 | P3 不实现;P4 评估实时重排需求(ARB-019 §21.6 已确认) |
GradeRecorded |
教师录入成绩 | toast + ["student","grades"] invalidate + ["student","dashboard"] invalidate |
HomeworkDeadlineApproaching |
作业截止前提醒 | toast + 作业列表高亮 + ["student","homework"] invalidate |
5.2 WebSocket 连接管理(P5)
// apps/student-portal/src/lib/ws.ts(P5 实现)
import { useAuth } from "@edu/hooks";
import { useQueryClient } from "@tanstack/react-query";
const RECONNECT_STRATEGY = {
maxRetries: 5,
backoff: "exponential", // 1s, 2s, 4s, 8s, 16s
onMaxRetriesExceeded: "fallback-polling", // 降级为 HTTP 轮询 60s
};
export function useStudentWebSocket() {
const { getToken } = useAuth();
const queryClient = useQueryClient();
const retryCount = useRef(0);
useEffect(() => {
const connect = () => {
const ws = new WebSocket(
`${process.env.NEXT_PUBLIC_PUSH_GATEWAY_URL}/ws?token=${getToken()}`,
);
ws.onmessage = (event) => {
const msg = JSON.parse(event.data) as PushMessage;
handlePushMessage(msg, queryClient);
};
ws.onclose = () => {
if (retryCount.current < RECONNECT_STRATEGY.maxRetries) {
const delay = Math.pow(2, retryCount.current) * 1000;
setTimeout(connect, delay);
retryCount.current += 1;
} else {
// 降级为 HTTP 轮询
startPollingFallback(queryClient);
}
};
ws.onerror = () => ws.close();
return () => ws.close();
};
return connect();
}, [getToken, queryClient]);
}
function handlePushMessage(msg: PushMessage, queryClient: QueryClient) {
switch (msg.type) {
case "NotificationRequested":
toast.info(t(msg.payload.title));
queryClient.invalidateQueries({ queryKey: ["student", "notifications"] });
break;
case "ExamPublished":
toast.info(t("exam.published", { name: msg.payload.examName }));
queryClient.invalidateQueries({ queryKey: ["student", "exams"] });
queryClient.invalidateQueries({ queryKey: ["student", "dashboard"] });
break;
case "ExamExtended":
// 重新拉取考试详情,更新 expiresAt
queryClient.invalidateQueries({ queryKey: ["student", "exam", msg.payload.examId] });
queryClient.invalidateQueries({ queryKey: ["student", "exams"] });
break;
case "ExamForceSubmitted":
// 教师强制收卷:立即触发提交,跳转结果页
toast.warning(t("exam.forceSubmitted"));
queryClient.invalidateQueries({ queryKey: ["student", "exam", msg.payload.examId] });
// 触发 submitExam mutation(由 useExamTaking hook 内部处理)
window.dispatchEvent(new CustomEvent("exam-force-submit", { detail: { examId: msg.payload.examId } }));
break;
case "ExamQuestionReordered":
queryClient.invalidateQueries({ queryKey: ["student", "exam", msg.payload.examId] });
break;
case "GradeRecorded":
toast.success(t("grade.recorded"));
queryClient.invalidateQueries({ queryKey: ["student", "grades"] });
queryClient.invalidateQueries({ queryKey: ["student", "dashboard"] });
break;
case "HomeworkDeadlineApproaching":
toast.warning(t("homework.deadlineApproaching", { name: msg.payload.homeworkName }));
queryClient.invalidateQueries({ queryKey: ["student", "homework"] });
break;
}
}
5.3 浏览器原生事件(防作弊,考试作答页)
| 事件 | 用途 | 处理 |
|---|---|---|
visibilitychange |
切屏检测 | 记录切换次数 + 时长;超阈值警告;上报服务端 |
blur / focus |
窗口失焦检测 | 同上 |
copy |
复制检测 | 全局阻止 + 警告 + 记录 |
paste |
粘贴检测(按题型分层) | 客观题阻止 + 警告;主观题允许 + 记录长度;作文题允许不记录(ARB-019 §21.4) |
fullscreenchange |
全屏退出检测 | 警告 + 记录 |
contextmenu |
右键禁用 | 考试作答页 preventDefault() |
6. 横切关注点对齐清单
6.1 权限(前端等价)
| 路由 | requiredPermission |
|---|---|
/dashboard |
STUDENT_DASHBOARD_VIEW |
/my-homework |
HOMEWORK_READ_OWN |
/my-homework/:id/submit |
HOMEWORK_SUBMIT |
/my-exams |
EXAMS_READ_OWN |
/my-exams/:id/take |
EXAMS_TAKE |
/my-exams/:id/result |
EXAMS_RESULT_VIEW |
/my-grades |
GRADES_READ_OWN |
/my-attendance |
ATTENDANCE_READ_OWN |
/learning-path |
LEARNING_PATH_VIEW |
/diagnostic |
DIAGNOSTIC_READ_OWN |
/weakness |
WEAKNESS_READ_OWN |
/notifications |
NOTIFICATION_READ_OWN |
路由前缀不带
/student/(student-portal 是独立 dev server :4001)。权限点常量集中在packages/contracts/src/permissions.ts(coord 维护)。L3 组件级视口用<RequirePermission perm="HOMEWORK_SUBMIT"><Button>提交作业</Button></RequirePermission>。权限点后缀_OWN强调学生仅能操作自己的数据(DataScope L0)。
6.2 错误码清单(前端 i18n 路由)
| 前缀 | 来源服务 | i18n key 模式 |
|---|---|---|
IAM_ |
iam | iam.error.{{code}} |
CORE_EDU_ |
core-edu | coreEdu.error.{{code}} |
BFF_STUDENT_ |
student-bff | bffStudent.error.{{code}} |
GW_ |
api-gateway | gateway.error.{{code}} |
NETWORK_ |
前端网络层 | network.error.{{code}} |
错误码前缀统一(known-issues §2.15):core-edu 子域(考试/作业/成绩/考勤)统一用
CORE_EDU_,不再用EXAMS_/HOMEWORK_/GRADES_;student-bff 用BFF_STUDENT_,不再用STUDENT_BFF_。前端 GraphQL 请求层根据extensions.code前缀路由 i18n key。
6.3 Logger
复用 packages/shared-ts/src/logger.ts(同 teacher-portal),开发环境 console + 结构化,生产环境 → Sentry(P6)。必含字段:trace_id、user_id、scope=student、path。
// 使用示例(student-portal 内)
import { createLogger } from "@edu/shared-ts";
const logger = createLogger({ scope: "student" });
logger.info("exam taking started", { examId, durationSeconds });
logger.error("homework submit failed", { homeworkId, error: err.code });
6.4 Metrics(Web Vitals)
| 指标 | 类型 | 上报 |
|---|---|---|
student_portal_lcp_seconds |
LCP | next/web-vitals → POST /api/v1/admin/web-vitals |
student_portal_cls |
CLS | 同上 |
student_portal_fid_seconds |
FID | 同上 |
student_portal_ttfb_seconds |
TTFB | 同上 |
student_portal_exam_save_failed |
Counter | 考试自动保存失败计数 |
student_portal_offline_duration |
Histogram | 离线时长(秒) |
P6 接入,P3-P5 暂缓(考试作答自动保存失败率除外,P3 即上报)。
6.5 Tracer(OTel browser SDK,P6)
复用 packages/shared-ts/src/tracer.ts(同 teacher-portal)。BatchSpanProcessor → OTLP exporter → collector → Tempo。自动埋点:fetch、XMLHttpRequest、document load、user interaction。
6.6 健康检查
| 端点 | 用途 | 实现 |
|---|---|---|
GET /api/health |
Dockerfile HEALTHCHECK | Next.js Route Handler,返回 { status: 'ok', ts: Date.now() } |
GET /api/ready |
K8s readinessProbe | 检查 process.env.API_GATEWAY_URL 可达 + 内存 < 阈值 + Shell MF 可达 |
6.7 优雅关闭
Next.js 无长连接(除 WS),无需特殊处理。WS 在 P5 由 push-gateway 管理,前端断线自动重连(指数退避,最多 5 次,5 次后降级 HTTP 轮询)。
7. 共享组件库(复用 Shell 暴露 + student 特有)
7.1 复用 Shell 暴露的组件(packages/ui-components/)
| 组件 | 用途 | 来源 |
|---|---|---|
AppShell |
左侧栏 + 主内容区布局(按 scope=student 过滤视口) | teacher-portal Shell 暴露 |
RequirePermission |
L3 组件级视口控制(无权限不渲染 children) | Shell 暴露 |
ErrorBoundary |
React 渲染异常兜底(fallback UI) | Shell 暴露 |
Loading / Skeleton |
骨架屏 | Shell 暴露 |
Empty |
空态(插画 + 文案 + CTA) | Shell 暴露 |
Modal / Dialog |
全局 Modal(ModalRoot + Zustand ui-store) | Shell 暴露(shadcn/ui) |
Toast |
全局 toast(错误/成功/警告) | Shell 暴露(shadcn/ui sonner) |
Button / Input / Select / Textarea |
基础表单 | Shell 暴露(shadcn/ui) |
DataTable |
表格(排序/分页/筛选) | Shell 暴露 |
Chart |
图表封装(recharts) | Shell 暴露 |
A11y 工具集 |
useA11yId / mergeA11yProps / describeInput / focus-trap / skip-link / visually-hidden / aria-status | Shell 暴露 |
Form |
react-hook-form + zodResolver 封装 | Shell 暴露 |
GraphQLProvider |
urql client 单例(ARB-002) | Shell 暴露 |
7.2 student-portal 特有组件(不暴露给 Shell,除 ExamTaking)
| 组件 | 用途 | 来源 | 是否 MF 暴露 |
|---|---|---|---|
ExamTaking |
考试作答(倒计时 + 自动保存 + 断网恢复 + 防作弊) | 新建 | ✅ 暴露 ./ExamTaking(供 Shell 路由复用) |
HomeworkSubmit |
作业提交表单(react-hook-form + zodResolver) | 新建 | ❌ 内部使用 |
DiagnosticChart |
学情诊断图表(recharts 多维雷达 + 趋势线) | 新建 | ❌ 内部使用 |
WeaknessList |
错题本列表(按知识点聚合 + 掌握度标签) | 新建 | ❌ 内部使用 |
LearningPathMap |
学习路径图(知识点前置依赖可视化) | 新建 | ❌ 内部使用 |
ExamResultView |
考试结果页(得分 + 错题分析 + 知识点掌握度) | 新建 | ❌ 内部使用 |
CountdownTimer |
倒计时组件(基于服务器时间,避免客户端篡改) | 新建 | ❌ 内部使用 |
DraftRecovery |
草稿恢复弹窗(断网恢复后提示是否恢复作答) | 新建 | ❌ 内部使用 |
7.3 不使用的组件(与 teacher-portal 差异)
- 不使用
RichTextEditor(Tiptap):学生不作答富文本,作业提交用表单 - 不使用
SSEViewer:学生不参与 AI 出题 - 不使用
ChildSwitcher:学生无多子女切换(家长端特有) - 不使用
UserManagementTable:学生不管理用户 - 不使用
RolePermissionMatrix:学生不管理权限
8. 共享 Hooks(复用 Shell 暴露 + student 特有)
8.1 复用 Shell 暴露的 Hooks
| Hook | 职责 | 来源 |
|---|---|---|
useAuth() |
会话状态(user/token/refresh/login/logout) | Shell 暴露 |
usePermission() |
权限查询(hasPermission/hasAny/hasAll + dataScope) | Shell 暴露 |
useViewports(scope) |
视口列表(按 scope 过滤) | Shell 暴露 |
useGraphQLClient() |
urql client 实例(ARB-002 单例) | Shell 暴露 |
useA11yId() |
唯一 ARIA ID 生成 | Shell 暴露 |
useAriaLive() |
aria-live 区域管理 | Shell 暴露 |
useToast() |
全局 toast(Zustand ui-store) | Shell 暴露 |
8.2 student-portal 特有 Hooks
| Hook | 职责 |
|---|---|
useStudentDashboard() |
学生仪表盘聚合查询 |
useMyExams(status?) |
我的考试列表 |
useExamDetail(examId) |
考试详情(含题目,禁缓存) |
useExamTaking(examId) |
考试作答状态(草稿 + 倒计时 + 自动保存 + 断网队列) |
useSaveExamDraft() |
保存草稿 mutation(自动保存) |
useSubmitExam() |
提交考试 mutation |
useMyHomework(status?) |
我的作业列表 |
useSubmitHomework() |
提交作业 mutation |
useMyGrades(subjectId?) |
我的成绩列表 |
useMyAttendance(month?) |
我的考勤 |
useDiagnostic(examId?) |
学情诊断(P4) |
useMyWeakness(kpId?) |
错题本(P4) |
useLearningPath() |
学习路径(P4) |
useMyNotifications() |
通知列表(P5) |
useServerTime() |
服务器时间同步(倒计时校正) |
useExamTabGuard(examId) |
考试多标签检测(BroadcastChannel) |
useAntiCheat(examId) |
防作弊行为采集(visibilitychange/copy/paste 等) |
useOfflineQueue() |
断网恢复队列(IDB 读写 + 重连重试) |
9. 设计令牌三层(复用 Shell 提供的 packages/ui-tokens/)
student-portal 不独立维护设计令牌,复用 Shell 暴露的 packages/ui-tokens/(详见 teacher-portal 阶段 2 §9)。
强制规则(project_rules §3.10,与 Shell 一致):
- 禁止
#hex字面量(ESLintno-restricted-syntax) - 禁止
'Inter'/'Fraunces'/'JetBrains Mono'字面量(ESLintdesign-tokens/no-hardcoded-fonts) - 禁止
font-size: Npx(用var(--font-size-1~9)) - 禁止 Tailwind 任意值
w-[Npx](用--space-*或默认阶梯)
student-portal 的 ESLint flat config 与 Shell 共用,确保规则一致。
10. 与其他模块的交互点(契约清单)
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 | 阶段 |
|---|---|---|---|---|---|
| 调用 | api-gateway | GraphQL over HTTP | POST /api/v1/student/graphql |
全部业务请求 | P3+ |
| 调用 | api-gateway | HTTP | POST /api/auth/login |
学生登录 | P2+ |
| 调用 | push-gateway | WebSocket | ws://push-gateway/ws |
实时推送 | P5 |
| 被调用 | — | — | — | 前端不暴露接口给其他服务 | — |
| 消费 | student-bff | GraphQL | dashboard/myExams/myHomework 等 | 学生场景聚合 | P3+ |
| 消费 | iam | GraphQL(经 BFF) | currentUser(权限/视口) | 会话/权限 | P3+ |
| 消费 | core-edu | GraphQL(经 BFF) | exam/homework/grades 相关 | 教学核心(学生视角) | P3+ |
| 消费 | content | GraphQL(经 BFF) | textbooks/chapters/learningPath | 内容资源 | P4+ |
| 消费 | data-ana | GraphQL(经 BFF) | myWeakness/myTrend/diagnostic | 学情分析 | P4+ |
| 消费 | msg | GraphQL(经 BFF) | myNotifications/markAsRead | 通知中心 | P5+ |
| 依赖 | coord 维护 | — | packages/shared-proto |
TS 类型(仅 contracts 部分) | P1+ |
| 依赖 | coord 维护 | — | packages/shared-ts |
Logger/Tracer/通用工具 | P3+ |
| 依赖 | coord 维护 | — | packages/contracts |
Permissions 常量 + 类型 | P3+ |
| 依赖 | ai13 维护 | — | packages/ui-tokens |
三层设计令牌 | P3+ |
| 依赖 | ai13 维护 | — | packages/ui-components |
shadcn + 共享组件 | P3+ |
| 依赖 | ai13 维护 | — | packages/hooks |
usePermission/useAuth 等 | P3+ |
proto 不直接消费:前端不调用 gRPC,student-bff 把 gRPC 聚合为 GraphQL 暴露给前端。前端仅消费
packages/contracts/src/permissions.ts中的权限点常量(TS 文件,非 proto 生成)。
11. 风险与假设
11.1 假设
- 假设 coord 建立
packages/shared-ts、packages/contracts:包含 Logger、Tracer、Permissions 常量、通用类型。若 coord 未建立,ai14 自行在apps/student-portal/src/shared/内实现,后续提取到 packages。 - 假设 ai13 teacher-portal Shell 按 ARB-002 暴露 GraphQL 单例:
GraphQLProvider/useGraphQLClient/urql/graphqlsingleton。若 Shell 未暴露,student-portal 降级为自建 urql client(违反 ARB-002,需 coord 仲裁)。 - 假设 ai04 student-bff 提供 GraphQL endpoint
POST /graphql:含 §4.2 全部 query/mutation。错误码前缀BFF_STUDENT_(ARB-001 已确认)。 - 假设 teacher-portal Shell 已就绪:AppShell + 共享依赖暴露 + MF 配置 + GraphQLProvider(P2 收尾完成)。student-portal 作为 Remote 才能挂载。
- 假设 Next.js 14+ Module Federation 2.0 稳定:
@module-federation/nextjs-mf在 Next.js App Router + urql singleton 下可用。若不稳定,降级为 4 端独立部署 + 各自 Shell(重复实现 AppShell + GraphQLProvider)。 - 假设 ai10 msg 提供 WebSocket 事件:
ExamExtended/ExamForceSubmitted/ExamQuestionReordered事件名已由 ARB-019 §21.6 裁决确认(见 issues/contracts)。
11.2 技术风险
| 风险 | 影响 | 缓解 |
|---|---|---|
| MF Remote SSR 对齐复杂 | Remote 在 SSR 时需 Shell 提供上下文 | 优先 CSR,SSR 仅用于首屏 dashboard;考试作答页强制 CSR(防缓存) |
| GraphQL singleton 分裂 | Remote 与 Shell 的 urql client 不是同一实例,cache 分裂 | MF shared.singleton: true + ARB-002 强制 + CI 检查 |
| 共享依赖版本漂移 | Remote 与 Shell 的 react/urql 版本不一致导致运行时错误 | MF shared.singleton: true + requiredVersion + CI 检查版本对齐 |
| 考试作答断网丢失 | 学生作答过程中断网,草稿丢失 | Zustand L3 + IDB 双写,每 30s + blur 时自动保存,重连后批量重试 |
| 考试倒计时不准 | 学生端时间与服务器时间偏差 | 倒计时基于服务器返回的 expiresAt,前端仅做展示,提交以服务器时间为准 |
| 考试题目缓存泄露 | 考试题目被缓存到 CDN/Service Worker,泄露答案 | 考试作答页 ssr: false + urql cache staleTime 0 + Security Header 防缓存 |
| 缓存陈旧导致看到旧作业 | 学生看到已截止的作业 | staleTime 30s + 截止时间客户端校验 + 提交时服务端二次校验 |
| Token 刷新竞态 | 多请求同时 401 触发多次 refresh | urql auth exchange 全局单例 + refresh promise 复用(复用 Shell) |
| 权限缓存陈旧 | 角色变更后前端 5min 内仍用旧权限 | iam 角色变更发 Kafka 事件 → msg 推送 WebSocket → 前端 invalidate |
| IDB 存储空间不足 | 考试草稿 + 断网队列累积超过浏览器配额 | 提交成功后立即清除 IDB 草稿;定期清理 7 天前的未提交草稿 |
| BroadcastChannel 兼容性 | Safari < 15.4 不支持 | 降级为 localStorage 事件(storage event listener) |
11.3 未决设计决策(需 coord 仲裁)
| 决策 | 当前方案 | 状态 |
|---|---|---|
| GraphQL vs REST | GraphQL(ARB-001 已裁决) | ✅ 已裁决 |
| MF Shell 暴露清单 | GraphQLProvider + urql singleton | ✅ 已裁决(ARB-002) |
| packages 归属 | ai13 维护 ui-*/hooks,coord 维护 shared-ts/contracts | ✅ 已确认 |
| 考试作答防作弊事件名 | ExamExtended/ExamForceSubmitted/ExamQuestionReordered |
✅ 已裁决(ARB-019 §21.6) |
| 附件上传协议 | REST POST /api/v1/student/upload + 对象存储 + GraphQL 提交 URL |
✅ 已裁决(ARB-019 §21.5,方案 A) |
| 主观题粘贴策略 | 客观题禁止/主观题允许记录/作文题允许不记录 | ✅ 已裁决(ARB-019 §21.4) |
| 考试作答页全屏策略 | P3 不强制(提示"建议全屏作答");P4 评估升级 | ✅ 已裁决(ARB-019 §21.4) |
12. coord 交叉审查所需信息
12.1 端口矩阵
| 端 | dev 端口 | 生产端口 | 备注 |
|---|---|---|---|
| student-portal | 4001 | 4001 | MF Remote 子应用(P3 首个 Remote) |
与 port-allocation §4 对齐(4000-4003 强制 4 端 portal)。
12.2 依赖的共享包(需 coord / ai13 建立)
| 包 | 路径 | 维护方 | 内容 |
|---|---|---|---|
shared-ts |
packages/shared-ts/ |
coord | Logger、Tracer、通用工具 |
contracts |
packages/contracts/ |
coord | Permissions 常量、ActionState 类型、UserInfo 类型 |
ui-tokens |
packages/ui-tokens/ |
ai13 | 三层设计令牌 |
ui-components |
packages/ui-components/ |
ai13 | shadcn + ErrorBoundary + RequirePermission + GraphQLProvider |
hooks |
packages/hooks/ |
ai13 | usePermission、useAuth、useViewports、useGraphQLClient |
12.3 依赖的后端契约(需对应 AI 确认)
| 契约 | 提供方 | 当前状态 |
|---|---|---|
POST /api/auth/login |
api-gateway | ✅ 已实现 |
GraphQL POST /api/v1/student/graphql(§4.2 全部操作) |
student-bff | 📐 待 ai04 设计 |
/exams/* /homework/* /grades/*(gRPC,经 BFF) |
core-edu | ✅ 已实现(P3) |
/analytics/*(gRPC,经 BFF) |
data-ana | ✅ 已实现(P4) |
/notifications/* + WebSocket 推送 |
msg + push-gateway | 📐 待 P5 |
WebSocket 事件 ExamExtended/ExamForceSubmitted |
msg + push-gateway | ✅ 已裁决(ARB-019 §21.6) |
12.4 错误码前缀(前端 i18n 路由依赖)
前端不产生错误码,仅消费。需各服务确认错误码前缀不重叠:
| 前缀 | 服务 | 状态 |
|---|---|---|
IAM_ |
iam | ✅ 已用 |
CORE_EDU_ |
core-edu | ✅ 已确认(§2.15) |
BFF_STUDENT_ |
student-bff | ✅ 已确认(ARB-001) |
GW_ |
api-gateway | ✅ 已用 |
NETWORK_ |
前端 | ai14 自有 |
12.5 不产生 Kafka 事件
前端不发布/消费 Kafka 事件。WebSocket 推送由 push-gateway 消费 Kafka 转发。
13. 实施路线(ai14 自用)
P3(student-portal 起步 — 核心教学)
- 建
apps/student-portal/(Remote 角色),配置 MF(ARB-002:urql/graphql singleton) - 实现 GraphQL Hooks 层(§8.2 全部 P3 Hooks),复用 Shell
useGraphQLClient() - 实现 Dashboard + 我的作业 + 提交作业 + 我的考试 + 作答考试 + 考试结果 + 我的成绩 + 我的考勤
- 考试作答核心(§14 详述):CountdownTimer + 自动保存 + IDB 断网恢复 + 防作弊采集 + DraftRecovery
- 复用 Shell 的 AppShell + 共享组件(RequirePermission/ErrorBoundary/Loading/Empty/DataTable/Form/Chart)
- 配置 Dockerfile 多阶段构建 +
/api/health+/api/readyroute - 补 Vitest 单测 + Playwright E2E(覆盖率 ≥ 80%,含考试作答边界场景 6 个 E2E)
- 接入 MSW Mock(GraphQL handlers + mock-socket)
P4(学情诊断 + 错题本 + 学习路径)
- 实现学情诊断页面(DiagnosticChart 多维雷达 + 趋势线)
- 实现错题本页面(WeaknessList 按知识点聚合 + 掌握度标签)
- 实现学习路径页面(LearningPathMap 知识点前置依赖可视化)
- 接入 content 服务 GraphQL(textbooks/chapters/learningPath)
P5(推送 + 通知中心)
- student-portal 接入 WebSocket(push-gateway),指数退避重连 + HTTP 轮询降级
- 实现通知中心页面(通知列表 + 未读数 + 标记已读)
- 处理 6 类推送事件(NotificationRequested/ExamPublished/ExamExtended/ExamQuestionReordered/GradeRecorded/HomeworkDeadlineApproaching)
P6(硬化)
- Web Vitals + OTel browser SDK 接入
- A11y WCAG 2.2 AA 审计(考试作答页键盘可操作 + 屏幕阅读器友好)
- 性能优化(MF shared 单例验证、bundle 分析、代码分割)
- PWA 配置(manifest + Service Worker,考试作答页 Network Only)
- 隐私合规完善(PIPL/未成年人保护法强制项)
- 多语言(en-US/zh-TW)100% 完成度
14. 考试作答架构设计(ai14 新增 — 核心复杂场景)
考试作答是学生端最复杂、最易出问题的场景,必须在架构中预留所有边界场景的处理。本节是 ai14 新增,对齐 01-understanding.md §11。
14.1 考试作答状态机
stateDiagram-v2
[*] --> NotStarted: 进入 /my-exams/:id/take(早于 startsAt)
[*] --> InProgress: 进入(startsAt ≤ now ≤ expiresAt)
NotStarted --> InProgress: 到达 startsAt
InProgress --> AutoSaving: 每 30s / blur
AutoSaving --> InProgress: 保存成功
AutoSaving --> OfflineMode: 保存失败(网络中断)
OfflineMode --> InProgress: 网络恢复 + 队列重试成功
OfflineMode --> AutoSubmit: 倒计时归零
InProgress --> ConfirmSubmit: 学生点击提交
ConfirmSubmit --> Submitting: 确认提交
ConfirmSubmit --> InProgress: 取消提交
Submitting --> Submitted: 提交成功
Submitting --> Submitting: 重试(查询提交状态,防重复)
Submitting --> IDBQueue: 提交失败入队
AutoSubmit --> Submitting: 倒计时归零自动提交
IDBQueue --> Submitting: 网络恢复后重试
Submitted --> [*]: 跳转 /my-exams/:id/result
Submitted --> ClearDraft: 清除 IDB 草稿
ClearDraft --> [*]
14.2 倒计时与服务器时间对齐
// apps/student-portal/src/lib/serverTimeSync.ts
import { useGraphQLClient } from "@edu/hooks";
const SERVER_TIME_QUERY = gql`
query ServerTime {
serverTime
}
`;
export function useServerTimeSync() {
const client = useGraphQLClient();
const [offset, setOffset] = useState(0); // serverTime - clientTime(ms)
const sync = useCallback(async () => {
const requestStart = Date.now();
const result = await client.query(SERVER_TIME_QUERY, {}).toPromise();
const requestEnd = Date.now();
if (result.data?.serverTime) {
const serverTime = new Date(result.data.serverTime).getTime();
const roundTrip = requestEnd - requestStart;
const estimatedClientTimeAtServer = requestStart + roundTrip / 2;
setOffset(serverTime - estimatedClientTimeAtServer);
}
}, [client]);
useEffect(() => {
sync();
// 每 5 分钟重新同步一次
const interval = setInterval(sync, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [sync]);
// 获取校正后的当前时间
const getCorrectedTime = () => Date.now() + offset;
return { offset, sync, getCorrectedTime };
}
// 倒计时 Hook
export function useCountdownTimer(expiresAt: string, serverTimeOffset: number) {
const [remaining, setRemaining] = useState(0);
useEffect(() => {
const calculate = () => {
const correctedNow = Date.now() + serverTimeOffset;
const expiry = new Date(expiresAt).getTime();
const diff = expiry - correctedNow;
setRemaining(Math.max(0, Math.floor(diff / 1000)));
};
calculate();
const interval = setInterval(calculate, 1000);
return () => clearInterval(interval);
}, [expiresAt, serverTimeOffset]);
return {
remaining,
formatted: formatDuration(remaining),
isExpired: remaining === 0,
};
}
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
14.3 自动保存与断网恢复(IDB 队列)
// apps/student-portal/src/lib/examQueue.ts
import { openDB } from "idb";
const DB_NAME = "student-exam";
const DB_VERSION = 1;
const DRAFT_STORE = "drafts";
const QUEUE_STORE = "queue";
async function getDB() {
return openDB(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains(DRAFT_STORE)) {
db.createObjectStore(DRAFT_STORE, { keyPath: "examId" });
}
if (!db.objectStoreNames.contains(QUEUE_STORE)) {
db.createObjectStore(QUEUE_STORE, { keyPath: "id", autoIncrement: true });
}
},
});
}
// 保存草稿到 IDB(断网恢复用)
export async function saveDraftToIDB(draft: ExamTakingDraft): Promise<void> {
const db = await getDB();
await db.put(DRAFT_STORE, draft);
}
// 从 IDB 加载草稿
export async function loadDraftFromIDB(examId: string): Promise<ExamTakingDraft | undefined> {
const db = await getDB();
return db.get(DRAFT_STORE, examId);
}
// 清除草稿(提交成功后)
export async function clearDraftFromIDB(examId: string): Promise<void> {
const db = await getDB();
await db.delete(DRAFT_STORE, examId);
}
// 入队失败的保存请求(断网重试用)
export async function enqueueFailedSave(item: {
examId: string;
questionId: string;
answer: AnswerInput;
queuedAt: number;
}): Promise<void> {
const db = await getDB();
await db.add(QUEUE_STORE, item);
}
// 出队并重试
export async function processQueue(
retryFn: (item: { examId: string; questionId: string; answer: AnswerInput }) => Promise<boolean>,
): Promise<void> {
const db = await getDB();
const tx = db.transaction(QUEUE_STORE, "readwrite");
const allItems = await tx.store.getAll();
for (const item of allItems) {
const success = await retryFn({
examId: item.examId,
questionId: item.questionId,
answer: item.answer,
});
if (success) {
await tx.store.delete(item.id);
}
}
await tx.done;
}
// 监听网络恢复事件
export function useOfflineQueue_recovery(retryFn: (item: any) => Promise<boolean>) {
useEffect(() => {
const handleOnline = () => {
processQueue(retryFn);
};
window.addEventListener("online", handleOnline);
// 页面可见时也尝试处理队列
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible" && navigator.onLine) {
processQueue(retryFn);
}
});
return () => {
window.removeEventListener("online", handleOnline);
};
}, [retryFn]);
}
14.4 考试作答自动保存策略
// apps/student-portal/src/hooks/useExamTaking.ts
export function useExamTaking(examId: string) {
const client = useGraphQLClient();
const { offset, getCorrectedTime } = useServerTimeSync();
const [draft, setDraft] = useState<ExamTakingDraft | null>(null);
const [isOnline, setIsOnline] = useState(navigator.onLine);
// 进入考试页时加载草稿
useEffect(() => {
loadDraftFromIDB(examId).then((saved) => {
if (saved) {
// 检测到未完成草稿,弹 DraftRecovery
showDraftRecoveryDialog(saved).then((restore) => {
if (restore) setDraft(saved);
else setDraft(createNewDraft(examId));
});
} else {
setDraft(createNewDraft(examId));
}
});
}, [examId]);
// 自动保存(每 30s + blur)
useEffect(() => {
if (!draft) return;
const autoSave = async () => {
if (!isOnline) {
// 离线:入 IDB 队列
await saveDraftToIDB(draft);
return;
}
try {
const result = await client
.mutation(SAVE_EXAM_DRAFT, {
examId,
questionId: "batch", // 批量保存
answer: draft.answers,
})
.toPromise();
if (result.data?.saveExamDraft?.ok) {
setDraft((prev) => ({ ...prev!, lastSavedAt: Date.now() }));
await saveDraftToIDB(draft); // 同步到 IDB
}
} catch (err) {
// 保存失败,入队列
await enqueueFailedSave({
examId,
questionId: "batch",
answer: draft.answers as any,
queuedAt: Date.now(),
});
logger.warn("exam auto-save failed, queued", { examId, error: err });
}
};
const interval = setInterval(autoSave, 30 * 1000); // 每 30s
const onBlur = () => autoSave(); // blur 时
window.addEventListener("blur", onBlur);
return () => {
clearInterval(interval);
window.removeEventListener("blur", onBlur);
};
}, [draft, client, examId, isOnline]);
// 监听网络状态
useEffect(() => {
const updateOnline = () => setIsOnline(navigator.onLine);
window.addEventListener("online", updateOnline);
window.addEventListener("offline", updateOnline);
return () => {
window.removeEventListener("online", updateOnline);
window.removeEventListener("offline", updateOnline);
};
}, []);
// 网络恢复后处理队列
useOfflineQueue_recovery(async (item) => {
try {
const result = await client
.mutation(SAVE_EXAM_DRAFT, {
examId: item.examId,
questionId: item.questionId,
answer: item.answer,
})
.toPromise();
return result.data?.saveExamDraft?.ok ?? false;
} catch {
return false;
}
});
// 更新答案
const updateAnswer = useCallback((questionId: string, answer: AnswerInput) => {
setDraft((prev) => {
if (!prev) return prev;
return {
...prev,
answers: { ...prev.answers, [questionId]: answer },
};
});
}, []);
return {
draft,
isOnline,
serverTimeOffset: offset,
correctedNow: getCorrectedTime(),
updateAnswer,
};
}
14.5 防作弊采集(前端配合,服务端最终判定)
// apps/student-portal/src/hooks/useAntiCheat.ts
export function useAntiCheat(examId: string) {
const behaviorsRef = useRef<SuspiciousBehaviorRecord[]>([]);
const [warning, setWarning] = useState<string | null>(null);
const record = useCallback(
(type: SuspiciousBehaviorRecord["type"], details?: string) => {
const record_item: SuspiciousBehaviorRecord = {
type,
timestamp: Date.now(),
details,
};
behaviorsRef.current.push(record_item);
// 队列满 100 条后批量上报
if (behaviorsRef.current.length >= 100) {
flushBehaviors();
}
},
[examId],
);
const flushBehaviors = useCallback(async () => {
if (behaviorsRef.current.length === 0) return;
const batch = behaviorsRef.current.splice(0);
try {
await client
.mutation(RECORD_EXAM_VIOLATION, {
examId,
behaviors: batch,
})
.toPromise();
} catch (err) {
// 上报失败,重新入队
behaviorsRef.current.unshift(...batch);
}
}, [examId]);
// 切屏检测
useEffect(() => {
let switchCount = 0;
const handleVisibility = () => {
if (document.visibilityState === "hidden") {
switchCount += 1;
record("tab-switch", `switch #${switchCount}`);
if (switchCount >= 3) {
setWarning(t("exam.antiCheat.tooManyTabSwitches"));
toast.warning(t("exam.antiCheat.tooManyTabSwitches"));
}
}
};
document.addEventListener("visibilitychange", handleVisibility);
return () => document.removeEventListener("visibilitychange", handleVisibility);
}, [record]);
// 复制检测(全局阻止)
useEffect(() => {
const handleCopy = (e: ClipboardEvent) => {
e.preventDefault();
record("copy", "copy attempted");
toast.warning(t("exam.antiCheat.copyDisabled"));
};
document.addEventListener("copy", handleCopy);
return () => document.removeEventListener("copy", handleCopy);
}, [record]);
// 粘贴检测(按题型分层,ARB-019 §21.4 ISSUE-014-04 已裁决)
// - 客观题(single-choice/multiple-choice/fill-blank):禁止粘贴
// - 主观题(short-answer):允许粘贴,记录粘贴事件 + 内容长度
// - 作文题(essay):允许粘贴,不记录
useEffect(() => {
const handlePaste = (e: ClipboardEvent) => {
const activeQuestion = getActiveQuestion(); // 当前焦点题目
if (!activeQuestion) return;
const questionType = activeQuestion.answerType;
const pastedLength = e.clipboardData?.getData("text")?.length ?? 0;
switch (questionType) {
case "single-choice":
case "multiple-choice":
case "fill-blank":
// 客观题:禁止粘贴
e.preventDefault();
record("paste", `paste blocked on ${questionType}, length=${pastedLength}`);
toast.warning(t("exam.antiCheat.pasteDisabled"));
break;
case "short-answer":
// 主观题:允许粘贴,记录事件 + 内容长度
record("paste", `paste allowed on short-answer, length=${pastedLength}`);
// 走 recordPasteEvent mutation 上报(ARB-019 §21.4)
client.mutation(RECORD_PASTE_EVENT, {
examId,
questionId: activeQuestion.id,
pasteLength: pastedLength,
}).toPromise().catch(() => {});
break;
case "essay":
// 作文题:允许粘贴,不记录
break;
}
};
document.addEventListener("paste", handlePaste);
return () => document.removeEventListener("paste", handlePaste);
}, [record, examId, client]);
// 全屏退出检测
useEffect(() => {
const handleFullscreen = () => {
if (!document.fullscreenElement) {
record("fullscreen-exit");
toast.warning(t("exam.antiCheat.fullscreenExit"));
}
};
document.addEventListener("fullscreenchange", handleFullscreen);
return () => document.removeEventListener("fullscreenchange", handleFullscreen);
}, [record]);
// 窗口失焦检测
useEffect(() => {
const handleBlur = () => {
record("window-blur");
};
window.addEventListener("blur", handleBlur);
return () => window.removeEventListener("blur", handleBlur);
}, [record]);
// 右键禁用
useEffect(() => {
const handleContext = (e: MouseEvent) => {
e.preventDefault();
};
document.addEventListener("contextmenu", handleContext);
return () => document.removeEventListener("contextmenu", handleContext);
}, []);
// 卸载时 flush
useEffect(() => {
return () => {
flushBehaviors();
};
}, [flushBehaviors]);
return { warning, flushBehaviors };
}
14.6 考试提交防重复
// apps/student-portal/src/hooks/useSubmitExam.ts
export function useSubmitExam(examId: string) {
const client = useGraphQLClient();
const queryClient = useQueryClient();
const [submitting, setSubmitting] = useState(false);
const submit = useCallback(async () => {
setSubmitting(true);
try {
// 提交前先查询提交状态,防重复
const statusResult = await client
.query(EXAM_SUBMISSION_STATUS, { examId })
.toPromise();
if (statusResult.data?.examSubmissionStatus?.submitted) {
// 已提交,跳转结果页
toast.info(t("exam.alreadySubmitted"));
router.push(`/my-exams/${examId}/result`);
return;
}
// 执行提交
const result = await client
.mutation(SUBMIT_EXAM, { examId })
.toPromise();
if (result.data?.submitExam) {
// 提交成功,清除 IDB 草稿
await clearDraftFromIDB(examId);
queryClient.invalidateQueries({ queryKey: ["student", "exams"] });
queryClient.invalidateQueries({ queryKey: ["student", "dashboard"] });
toast.success(t("exam.submitSuccess"));
router.push(`/my-exams/${examId}/result`);
} else if (result.error) {
// 提交失败,入 IDB 队列
toast.error(t("exam.submitFailed"));
// 入队,稍后重试
}
} catch (err) {
// 网络错误,入 IDB 队列
toast.error(t("exam.submitFailedNetwork"));
} finally {
setSubmitting(false);
}
}, [examId, client, queryClient]);
return { submit, submitting };
}
15. 详细组件设计(ai14 新增)
15.1 ExamTaking 组件
// apps/student-portal/src/components/ExamTaking.tsx
interface ExamTakingProps {
examId: string;
}
export function ExamTaking({ examId }: ExamTakingProps) {
const { draft, isOnline, serverTimeOffset, updateAnswer } = useExamTaking(examId);
const { data: exam } = useExamDetail(examId);
const { remaining, formatted, isExpired } = useCountdownTimer(
exam?.expiresAt ?? "",
serverTimeOffset,
);
const { warning } = useAntiCheat(examId);
const { submit, submitting } = useSubmitExam(examId);
const [showConfirmSubmit, setShowConfirmSubmit] = useState(false);
// 倒计时归零自动提交
useEffect(() => {
if (isExpired && !submitting) {
toast.info(t("exam.timeUp"));
submit();
}
}, [isExpired, submitting, submit]);
// 考试延时事件
useEffect(() => {
// 由 WebSocket 事件触发 exam query invalidate,exam.expiresAt 更新后倒计时自动重置
}, [exam?.expiresAt]);
if (!draft || !exam) return <Skeleton rows={20} />;
const unansweredCount = exam.questions.filter((q) => !draft.answers[q.id]).length;
return (
<div className="exam-taking-container" data-testid="exam-taking">
{/* 顶部固定:倒计时 + 在线状态 */}
<div className="sticky top-0 z-10 bg-background border-b">
<div className="flex items-center justify-between p-4">
<CountdownTimer
remaining={remaining}
formatted={formatted}
isExpired={isExpired}
/>
<OnlineStatus isOnline={isOnline} />
</div>
{warning && <div className="bg-warning/10 p-2 text-sm">{warning}</div>}
</div>
{/* 题目区域 */}
<div className="p-4 space-y-6">
{exam.questions.map((q, index) => (
<QuestionCard
key={q.id}
question={q}
index={index}
answer={draft.answers[q.id]}
onChange={(answer) => updateAnswer(q.id, answer)}
/>
))}
</div>
{/* 底部固定:提交按钮 */}
<div className="sticky bottom-0 bg-background border-t p-4">
<Button
onClick={() => setShowConfirmSubmit(true)}
disabled={submitting}
className="w-full"
>
{t("exam.submit")}
</Button>
</div>
{/* 提交确认弹窗 */}
<Dialog open={showConfirmSubmit} onOpenChange={setShowConfirmSubmit}>
<DialogContent>
{unansweredCount > 0 && (
<p className="text-warning">
{t("exam.unansweredWarning", { count: unansweredCount })}
</p>
)}
<p>{t("exam.confirmSubmit")}</p>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setShowConfirmSubmit(false)}>
{t("common.cancel")}
</Button>
<Button onClick={submit} disabled={submitting}>
{t("common.confirm")}
</Button>
</div>
</DialogContent>
</Dialog>
{/* 草稿恢复弹窗由 useExamTaking 内部触发 */}
<DraftRecovery examId={examId} />
</div>
);
}
15.2 CountdownTimer 组件
// apps/student-portal/src/components/CountdownTimer.tsx
interface CountdownTimerProps {
remaining: number; // 秒
formatted: string; // HH:mm:ss
isExpired: boolean;
}
export function CountdownTimer({ remaining, formatted, isExpired }: CountdownTimerProps) {
const isUrgent = remaining <= 300 && remaining > 0; // 最后 5 分钟
const isCritical = remaining <= 60 && remaining > 0; // 最后 1 分钟
return (
<div
className={cn(
"font-mono text-2xl tabular-nums",
isExpired && "text-destructive",
isCritical && "text-destructive animate-pulse",
isUrgent && "text-warning",
!isUrgent && !isCritical && !isExpired && "text-foreground",
)}
role="timer"
aria-live="polite"
aria-label={t("exam.timeRemaining", { formatted })}
>
{isExpired ? t("exam.timeUp") : formatted}
</div>
);
}
15.3 DraftRecovery 组件
// apps/student-portal/src/components/DraftRecovery.tsx
interface DraftRecoveryProps {
examId: string;
}
export function DraftRecovery({ examId }: DraftRecoveryProps) {
const [open, setOpen] = useState(false);
const [savedDraft, setSavedDraft] = useState<ExamTakingDraft | null>(null);
useEffect(() => {
loadDraftFromIDB(examId).then((draft) => {
if (draft && Object.keys(draft.answers).length > 0) {
setSavedDraft(draft);
setOpen(true);
}
});
}, [examId]);
const handleRestore = () => {
// 父组件通过 context 接收恢复的草稿
setOpen(false);
};
const handleDiscard = async () => {
await clearDraftFromIDB(examId);
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<h2>{t("exam.draftRecovery.title")}</h2>
<p>
{t("exam.draftRecovery.description", {
answeredCount: savedDraft ? Object.keys(savedDraft.answers).length : 0,
lastSavedAt: savedDraft?.lastSavedAt,
})}
</p>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={handleDiscard}>
{t("exam.draftRecovery.discard")}
</Button>
<Button onClick={handleRestore}>
{t("exam.draftRecovery.restore")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
15.4 HomeworkSubmit 组件
// apps/student-portal/src/components/HomeworkSubmit.tsx
interface HomeworkSubmitProps {
homeworkId: string;
}
const homeworkSchema = z.object({
answers: z.array(
z.object({
questionId: z.string(),
answer: answerInputSchema,
}),
),
note: z.string().max(500).optional(),
});
export function HomeworkSubmit({ homeworkId }: HomeworkSubmitProps) {
const { data: homework } = useHomeworkDetail(homeworkId);
const { mutate, isPending } = useSubmitHomework();
const form = useForm<HomeworkSubmitForm>({
resolver: zodResolver(homeworkSchema),
});
const onSubmit = (data: HomeworkSubmitForm) => {
mutate({ homeworkId, ...data });
};
if (!homework) return <Skeleton rows={10} />;
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{homework.questions.map((q, index) => (
<QuestionField
key={q.id}
question={q}
index={index}
control={form.control}
name={`answers.${index}.answer`}
/>
))}
{/* 附件上传 */}
<AttachmentUpload
maxFiles={5}
maxSize={10 * 1024 * 1024} // 10MB
accept={["image/jpeg", "image/png", "application/pdf"]}
/>
{/* 学生备注 */}
<Textarea
{...form.register("note")}
placeholder={t("homework.notePlaceholder")}
maxLength={500}
/>
<Button type="submit" disabled={isPending} className="w-full sticky bottom-0">
{isPending ? t("common.submitting") : t("homework.submit")}
</Button>
</form>
</Form>
);
}
15.5 DiagnosticChart 组件(P4)
// apps/student-portal/src/components/DiagnosticChart.tsx
interface DiagnosticChartProps {
data: DiagnosticViewModel;
}
export function DiagnosticChart({ data }: DiagnosticChartProps) {
return (
<div className="space-y-6">
{/* 多维雷达图:知识点掌握度 */}
<Chart type="radar" data={data.radar} options={radarOptions} />
{/* 趋势线:历次考试得分 */}
<Chart type="line" data={data.trend} options={trendOptions} />
{/* 知识点掌握度列表 */}
<DataTable
columns={[
{ key: "name", header: t("diagnostic.knowledgePoint") },
{
key: "mastery",
header: t("diagnostic.mastery"),
render: (row) => <MasteryBar value={row.mastery} />,
},
{ key: "correctCount", header: t("diagnostic.correctRate") },
]}
data={data.knowledgePoints}
/>
</div>
);
}
16. 跨标签同步实现(ai14 新增)
16.1 考试作答防多标签(BroadcastChannel)
// apps/student-portal/src/lib/examTabGuard.ts
const EXAM_CHANNEL_NAME = "student-exam-guard";
export function useExamTabGuard(examId: string) {
useEffect(() => {
// 降级:Safari < 15.4 不支持 BroadcastChannel
if (typeof BroadcastChannel === "undefined") {
// 降级为 localStorage 事件
return useLocalStorageFallback(examId);
}
const channel = new BroadcastChannel(EXAM_CHANNEL_NAME);
const tabId = `tab-${Date.now()}-${Math.random().toString(36).slice(2)}`;
// 广播:我正在作答此考试
channel.postMessage({ type: "exam-entered", examId, tabId, ts: Date.now() });
const onMessage = (event: MessageEvent) => {
const msg = event.data;
if (msg.tabId === tabId) return;
if (msg.type === "exam-entered" && msg.examId === examId) {
// 已有其他标签在作答,警告
toast.warning(t("exam.multiTabDetected"));
recordSuspiciousBehavior("multi-tab-exam");
// 回应:我也在作答
channel.postMessage({ type: "exam-exists", examId, tabId, ts: Date.now() });
}
if (msg.type === "exam-exists" && msg.examId === examId) {
// 收到其他标签的回应,说明我也不是第一个
toast.warning(t("exam.multiTabDetected"));
recordSuspiciousBehavior("multi-tab-exam");
}
};
channel.addEventListener("message", onMessage);
return () => {
channel.postMessage({ type: "exam-left", examId, tabId });
channel.close();
};
}, [examId]);
}
// localStorage 降级方案
function useLocalStorageFallback(examId: string) {
useEffect(() => {
const key = `exam-active-${examId}`;
const tabId = `tab-${Date.now()}-${Math.random().toString(36).slice(2)}`;
localStorage.setItem(key, JSON.stringify({ tabId, ts: Date.now() }));
const onStorage = (e: StorageEvent) => {
if (e.key === key && e.newValue) {
const otherTab = JSON.parse(e.newValue);
if (otherTab.tabId !== tabId) {
toast.warning(t("exam.multiTabDetected"));
recordSuspiciousBehavior("multi-tab-exam");
}
}
};
window.addEventListener("storage", onStorage);
return () => {
localStorage.removeItem(key);
window.removeEventListener("storage", onStorage);
};
}, [examId]);
}
16.2 通知未读数跨标签同步
// apps/student-portal/src/lib/notificationSync.ts
const NOTIFICATION_CHANNEL = "student-notifications";
export function useNotificationSync() {
const queryClient = useQueryClient();
useEffect(() => {
if (typeof BroadcastChannel === "undefined") return;
const channel = new BroadcastChannel(NOTIFICATION_CHANNEL);
const onMessage = (event: MessageEvent) => {
if (event.data.type === "unread-count-updated") {
queryClient.invalidateQueries({ queryKey: ["student", "notifications"] });
}
};
channel.addEventListener("message", onMessage);
return () => channel.close();
}, [queryClient]);
const broadcastUnreadUpdate = useCallback(() => {
if (typeof BroadcastChannel !== "undefined") {
const channel = new BroadcastChannel(NOTIFICATION_CHANNEL);
channel.postMessage({ type: "unread-count-updated", ts: Date.now() });
channel.close();
}
}, []);
return { broadcastUnreadUpdate };
}
16.3 考试草稿跨标签恢复
若学生在 Tab A 开始作答,关闭 Tab A 后在 Tab B 重新打开:
- IDB 草稿跨标签共享(同源策略保证)
- Tab B 进入考试页时检测 IDB 草稿
- 弹
DraftRecovery提示"检测到未完成的作答草稿,是否恢复?" - 用户确认后从 IDB 加载草稿
// 在 ExamTaking 组件内
useEffect(() => {
loadDraftFromIDB(examId).then((saved) => {
if (saved && Object.keys(saved.answers).length > 0) {
// 检测到草稿,触发恢复弹窗
setPendingDraft(saved);
}
});
}, [examId]);
17. API 契约版本管理(ai14 新增)
17.1 版本兼容矩阵
| API 版本 | 引入阶段 | 弃用阶段 | 移除阶段 | student-portal 兼容性 |
|---|---|---|---|---|
| v1 | P3 | - | - | ✅ P3+ 必须支持 |
| v2 | P6+ | - | - | ⚠️ P6+ 通过 Feature Flag 切换 |
| 字段裁剪 | P5+ | - | - | ⚠️ 可选,移动端低带宽时启用 |
17.2 GraphQL schema 演进策略
| 变更类型 | 策略 | 前端处理 |
|---|---|---|
| 新增字段 | 非破坏性,直接新增 | 前端忽略未知字段(Zod 默认行为) |
| 废弃字段 | @deprecated 标注 |
前端监控使用率,< 1% 后移除调用 |
| 新增 Query/Mutation | 非破坏性 | 前端按需消费 |
| 废弃 Query/Mutation | 先 @deprecated → 1 个迭代周期后移除 |
前端迁移到替代操作 |
| 破坏性字段变更 | 新增字段 + 废弃旧字段 → 1 个迭代周期后移除旧字段 | 前端 Feature Flag 切换 |
| Enum 值新增 | 非破坏性 | 前端 default 分支兜底 |
17.3 urql client 版本协商
// urql client 自动处理 GraphQL schema 演进
// 通过 @deprecated 标注 + schema registry 监控
const client = createClient({
url: "/api/v1/student/graphql",
exchanges: [
dedupExchange,
cacheExchange,
authExchange,
errorExchange({
onError: (error) => {
// 检测 deprecation 警告
const deprecationWarnings = error.graphQLErrors?.filter(
(e) => e.extensions?.code === "DEPRECATED_FIELD",
);
if (deprecationWarnings?.length) {
logger.warn("GraphQL deprecated field used", {
fields: deprecationWarnings.map((w) => w.path),
});
// 上报埋点,跟踪使用率
metrics.increment("graphql.deprecated_field_used", {
fields: deprecationWarnings.map((w) => w.path?.join(".")),
});
}
},
}),
fetchExchange,
],
});
17.4 字段裁剪使用场景(P5+)
| 场景 | 裁剪策略 | 节省带宽 |
|---|---|---|
| 移动端 4G 查看考试列表 | myExams { id name startsAt expiresAt status } |
~60% |
| 通知列表快速浏览 | myNotifications { id title read createdAt } |
~50% |
| Dashboard 概览 | studentDashboard { upcomingHomework { id title dueAt } } |
~40% |
| 完整考试详情(默认) | 不裁剪 | 0% |
18. 监控与降级(ai14 新增)
18.1 前端监控指标
| 指标 | 类型 | 采集方式 | 告警阈值 |
|---|---|---|---|
student_portal_lcp_seconds |
LCP | next/web-vitals |
P95 > 2.5s |
student_portal_cls |
CLS | 同上 | P95 > 0.1 |
student_portal_ttfb_seconds |
TTFB | 同上 | P95 > 0.8s |
student_portal_mf_load_failed |
Counter | MF Remote 加载错误捕获 | > 1% |
student_portal_api_error_rate |
Counter | urql errorExchange 拦截 | 5xx > 1% |
student_portal_ws_reconnect |
Counter | WebSocket 重连计数 | 单用户 > 5 次/小时 |
student_portal_exam_save_failed |
Counter | 考试自动保存失败计数 | > 5% |
student_portal_exam_submit_failed |
Counter | 考试提交失败计数 | > 1% |
student_portal_offline_duration |
Histogram | 网络中断时长 | P95 > 30s |
student_portal_draft_recovery_used |
Counter | 草稿恢复使用次数 | 异常增长告警 |
student_portal_anti_cheat_triggered |
Counter | 防作弊触发次数 | 单考试 > 10 次 |
student_portal_cache_hit_rate |
Gauge | urql cache 命中率 | < 60%(异常) |
student_portal_a11y_violations |
Counter | axe-core 自动扫描 | 严重违规 > 0 |
18.2 降级策略矩阵
| 触发条件 | 降级动作 | 用户感知 |
|---|---|---|
| MF Remote 加载失败(10s 超时) | 显示 Shell 内置的最小化静态引导页 | "学生端加载失败,请稍后重试" |
| BFF 5xx 错误率 > 5% | 隐藏 mutation 按钮(提交作业/提交考试);展示只读模式 banner | 顶部黄色 banner:"只读模式" |
| BFF P95 延迟 > 5s | 缩短缓存 TTL 至 5s;展示"加载缓慢"提示 | 顶部提示条 |
| WebSocket 连接失败 5 次 | 降级为 HTTP 轮询(60s 拉取通知列表) | 通知延迟最多 60s |
| 考试列表加载失败 3 次 | 显示错误页 + 重试按钮 | 错误页 |
| 考试作答自动保存失败 | 入 IDB 队列;UI 显示"离线模式"标识 | 顶部黄色标识 |
| 考试提交失败 | 自动重试 + 入 IDB 队列;3 次失败后人工介入提示 | "提交失败,正在重试" |
| i18n message 加载失败 | Fallback 到 key 本身作为文案 | 显示英文 key,不影响功能 |
| 设计令牌加载失败 | Fallback 到 Tailwind 默认色板 | 视觉风格降级,不影响功能 |
| BroadcastChannel 不支持(Safari < 15.4) | 降级为 storage 事件 | 跨 tab 同步延迟 ~500ms |
| IDB 存储空间不足 | 清理 7 天前的未提交草稿;提示用户 | toast 提示 |
| 服务器时间同步失败 | 使用客户端时间(偏差可能较大);提示"时间同步失败" | 顶部警告条 |
18.3 错误恢复流程
flowchart TD
Error[发生错误] --> Classify{错误类型}
Classify -->|Network| Retry[指数退避重试 3 次]
Classify -->|401 Unauthorized| Refresh[刷新 token]
Classify -->|403 Forbidden| Toast[toast 提示无权限]
Classify -->|404 Not Found| Empty[显示空态]
Classify -->|5xx Server Error| Fallback[降级策略]
Classify -->|MF Load Failed| ShellFallback[Shell 兜底页]
Classify -->|GraphQL Partial| PartialRender[展示部分数据 + 降级标识]
Retry -->|成功| Recover[恢复正常]
Retry -->|失败| Fallback
Refresh -->|成功| Recover
Refresh -->|失败| Logout[跳转登录页]
Fallback --> ReadOnlyMode[只读模式]
Fallback --> CachedData[展示缓存数据]
Fallback --> ErrorPage[错误页 + 重试]
Fallback --> IDBQueue[入 IDB 队列等待恢复]
Toast --> Continue[继续当前操作]
Empty --> Continue
ShellFallback --> RetryMF[10s 后重试 MF]
ReadOnlyMode --> WaitForRecover[等待 BFF 恢复]
WaitForRecover --> Recover
IDBQueue --> Recover
18.4 GraphQL 降级模式(ARB-001 §3.4 方案 B)
student-bff 支持降级模式(ARB-001),部分上游服务不可用时返回部分数据 + extensions.degraded: true:
// urql errorExchange 处理降级标记
errorExchange({
onError: (error) => {
const degradedErrors = error.graphQLErrors?.filter(
(e) => e.extensions?.degraded,
);
if (degradedErrors?.length) {
// 展示部分数据 + 降级标识
toast.warning(t("common.degradedMode"));
// UI 顶部显示"部分数据可能不是最新"
}
},
}),
19. 模块演化与解耦(ai14 新增)
19.1 拆分触发条件
| 触发条件 | 拆分方向 | 阶段 |
|---|---|---|
| student-portal bundle > 200KB(gzipped) | 按场景域拆分为 student-core-remote + student-exam-remote |
P7+ |
| 团队规模 > 5 人同时维护 student-portal | 同上 | P7+ |
| 考试作答功能复杂度提升(lockdown/AI 监考) | 拆出 student-exam-remote(考试作答专用) |
P7+ |
| AI 辅导功能引入 | 拆出 student-ai-remote(AI 辅导 + 个性化推荐) |
P7+ |
| 多租户支持 | URL 前缀 /{tenantId}/student/*;TanStack Query key 加 tenantId |
P8+ |
19.2 拆分后的 MF 配置
// 拆分后:student-core-remote(核心场景:dashboard/homework/grades)
new NextFederationPlugin({
name: "student_core_app",
filename: "static/chunks/remoteEntry.js",
exposes: {
"./pages": "./src/pages", // dashboard/my-homework/my-grades/my-attendance
},
// ... shared 同原配置
});
// 拆分后:student-exam-remote(考试作答专用)
new NextFederationPlugin({
name: "student_exam_app",
filename: "static/chunks/remoteEntry.js",
exposes: {
"./pages": "./src/pages", // my-exams/take/result
"./ExamTaking": "./src/components/ExamTaking",
},
// ... shared 同原配置
});
19.3 技术栈演化路径
| 演化方向 | 触发条件 | 迁移策略 |
|---|---|---|
| student-portal 拆分为多个 Remote | bundle > 200KB 或团队规模 > 5 人 | 按场景域拆分:student-core-remote + student-exam-remote |
| MF 2.0 → 3.0 升级 | MF 3.0 稳定且解决 SSR 问题 | Shell 端 @module-federation/nextjs-mf 升级;student-portal 仅改 name/filename 字段 |
| 切换为原生 SSR(脱离 MF) | SEO 需求强烈或 MF 维护成本过高 | 保留 GraphQL 请求层和组件库;移除 MF 配置;独立部署为完整 Next.js 应用 |
| 状态管理迁移(Zustand → Jotai) | Zustand 性能瓶颈或团队偏好 | 逐 slice 迁移;Hook 接口保持不变 |
| urql → Apollo Client | urql 功能不足 | 替换 GraphQLProvider;业务 Hook 接口保持不变 |
| React 18 → 19 | React 19 稳定 | 跟随 Shell 升级;use hook / Suspense 改进可简化 TanStack Query 集成 |
20. 长远架构愿景(ai14 新增)
20.1 三年架构演进路线
graph LR
Y1[Year 1<br/>P3-P6 单体 Remote<br/>MF 2.0 + GraphQL] --> Y2[Year 2<br/>P7+ 多 Remote 拆分<br/>AI 辅导+错题推荐+学习计划]
Y2 --> Y3[Year 3<br/>P8+ 多租户+多区域<br/>MF 3.0 / 原生 SSR / 移动端原生壳]
20.2 架构原则(始终不变)
- Remote 角色不变:student-portal 始终作为 teacher-portal Shell 的 Remote,不独立 Shell
- 契约先行:所有 API 变更先 proto/contracts,后实现
- 无状态前端:所有持久化状态走 localStorage/IDB,不依赖服务端 session
- 复用优先:组件/Hook/工具优先复用 Shell 暴露的,避免重复实现
- 可降级:任何依赖(BFF/WebSocket/Shell)故障都有降级方案
- 可观测:所有用户行为、错误、性能指标都可观测
- 可测试:所有组件/Hook/页面都有单测 + E2E 覆盖
- 可演化:模块可拆分、状态管理可迁移、框架可升级
- 考试作答可靠性:断网不丢草稿、时间对齐服务器、防作弊采集、防重复提交
- 学生隐私保护:PIPL/未成年人保护法合规,敏感数据最小化
20.3 与其他 portal 的协同演化
| 演化方向 | student-portal 角色 | 协同端 |
|---|---|---|
| 共享组件抽取到 packages/ | 消费方(不再在 Remote 内自建组件) | teacher-portal(Shell)+ 其他 Remote |
| 共享 Hook 抽取到 packages/hooks/ | 消费方 | 同上 |
| 共享业务模型抽取到 packages/contracts/ | 消费方(类型定义) | 同上 |
| 多 portal 间通信(如教师↔学生) | 通过后端 BFF/gRPC,不直连其他 Remote | teacher-portal / parent-portal |
| 跨 portal 路由跳转 | 通过 Shell 统一路由表 | 所有 portal |
20.4 与后端架构的协同演化
| 后端演化 | student-portal 适配 |
|---|---|
| BFF GraphQL schema 演进 | @deprecated 标注 + Feature Flag 切换 |
| BFF → BFF + 微前端 API Gateway | student-portal 不感知;urql client URL 不变 |
| 引入 Service Mesh(Istio) | student-portal 不感知;网络层透明 |
| 引入 Feature Flag 服务 | 集成 FeatureFlagProvider;组件按 flag 渲染 |
| 引入 A/B Testing 平台 | 集成 ABTestProvider;UI 按 variant 渲染 |
| 后端多租户 | URL 加 /{tenantId}/student/*;Query key 加维度 |
| 后端多区域 | urql client 按 region 路由;CDN 边缘缓存 |
| AI 辅导服务(P7) | 新增 SSE 流式消费;复用 teacher-portal AI 模式 |
20.5 关键技术债务预警
| 债务项 | 当前状态 | 紧急度 | 清理建议 |
|---|---|---|---|
| ai07 初稿协议错误(REST vs GraphQL) | ✅ 已修订 | 高 | v2 全面对齐 ARB-001 |
| ai07 初稿端口错误(3001 vs 4001) | ✅ 已修订 | 高 | v2 对齐 port-allocation §4 |
| ai07 初稿所有权错误(ai07 vs ai14) | ✅ 已修订 | 高 | 004 §15 文档矩阵已记录 ai14 = student-portal |
| MF SSR 对齐 | 待验证 | 中 | P3 启动前 PoC 验证 MF 2.0 SSR;若失败降级 CSR |
| GraphQL singleton 分裂风险 | 待验证 | 高 | P3 启动前验证 ARB-002 singleton 生效 |
| shared-ts / contracts 未建立 | 待 coord | 高 | P3 启动前必须建立;否则 ai14 自行实现后续提取 |
| ui-tokens / ui-components / hooks 未建立 | 待 ai13 | 高 | P3 启动前必须建立;ai13 在 P2 收尾交付 |
| MF Remote 加载失败的兜底 | 待实现 | 中 | P3 实现期落地 Shell 内置兜底页 |
| 跨 tab 同步在 Safari 的兼容性 | 待验证 | 低 | P6 硬化期验证;降级为 storage 事件 |
| 考试作答 IDB 存储空间管理 | 待实现 | 中 | P3 实现期落地定期清理逻辑 |
| WebSocket 事件名已裁决 | 已裁决 | 中 | ✅ ARB-019 §21.6 确认 ExamExtended/ExamForceSubmitted |
AI Agent: ai14 (student-portal remote) Branch: feat-review-student-portal-docs-9yN6Av Coordinator: coord-ai Predecessor: ai07(初版起草,ai14 接管审计与补全)