Files
Edu/apps/student-portal/docs/02-architecture-design.md
SpecialX 24c2860b41 docs(student-portal): add arbitration check and new objections to issue record
add verification of ARB-001 and ARB-002 impacts, and submit seven new disputed issues for coord arbitration
2026-07-10 15:10:16 +08:00

2050 lines
95 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 模块架构设计文档 — student-portal
> AIai14TS/React · 学习场景域前端 remote
> 阶段:阶段 2 交付物v2 — ai14 接管审计与补全版)
> 初版日期2026-07-09ai07 起草)
> 审计日期2026-07-10ai14 修订:协议 REST→GraphQL、端口 3001→4001、所有权 ai07→ai14、MF 配置 ARB-002、路由前缀、错误码前缀、长远架构补全
> 关联:[阶段 1 理解确认书](./01-understanding.md)、[004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md) §5.4、[pending-features P3](../../../docs/architecture/roadmap/pending-features.md)、[teacher-portal 阶段 2 架构设计](../../teacher-portal/docs/02-architecture-design.md)、[teacher-portal 阶段 3 长远架构](../../teacher-portal/docs/03-long-term-architecture.md)、[known-issues §2.15](../../../docs/troubleshooting/known-issues.md)、[coord 仲裁 ARB-001](../../../docs/architecture/issues/coord.md#1-arb-001teacher-bff-graphql-schema-第一版)、[coord 仲裁 ARB-002](../../../docs/architecture/issues/coord.md#2-arb-002mf-shell-暴露清单)、[student-bff 契约](../../../docs/architecture/issues/contracts/student-bff_contract.md)、[student-portal 契约](../../../docs/architecture/issues/contracts/student-portal_contract.md)
> 状态:待 coord 交叉审查
> **v2 审计修订摘要**ai14 → ai07 初稿):
>
> 1. **协议修订**REST → **GraphQL over HTTP**ARB-001 已裁决 student-bff 走 GraphQL Yoga + ActionState 信封 + DataLoader前端 all-in GraphQL复用 Shell 暴露的 urql client 单例)
> 2. **端口修订**3001 → **4001**004 §1.2 强制 4 端 4000-4003[port-allocation](../../../infra/port-allocation.md) §4 硬约束3001 已被 classes 历史占用)
> 3. **所有权修订**ai07 → **ai14**ai-allocation.md §3.2 L94ai07=classes/core-edu 交接)
> 4. **MF 配置修订**:按 ARB-002Shell 暴露 `GraphQLProvider`/`useGraphQLClient`/`urql`/`graphql` 单例student-portal 不再实现 ApiClient不重复创建 GraphQL client
> 5. **路由修订**`/student/*` → `/*`(无前缀,与 student-portal_contract.md §1.2 对齐student-portal 是独立 dev server :4001
> 6. **登录路径修订**`/iam/login` → `POST /api/auth/login`iam 仅 gRPC登录由 api-gateway 聚合)
> 7. **错误码前缀修订**`EXAMS_`/`HOMEWORK_`/`GRADES_` → **`CORE_EDU_`**known-issues §2.15`STUDENT_BFF_` → **`BFF_STUDENT_`**
> 8. **遗漏补全**:考试作答架构设计(断网恢复/时间对齐/防作弊/IDB 队列、详细组件设计、跨标签同步、API 版本管理、监控与降级、模块演化、长远架构愿景
> 9. **新增章节**§14 考试作答架构设计、§15 详细组件设计、§16 跨标签同步实现、§17 API 版本管理、§18 监控与降级、§19 模块演化与解耦、§20 长远架构愿景
---
## 1. 模块内部分层图student-portal Remote 视角)
```mermaid
graph TB
subgraph Browser["浏览器(学生)"]
URL[URL 路由 /*<br/>无 /student 前缀]
end
subgraph Shell["teacher-portalShell 宿主 :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-portalRemote :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 :8081P5"]
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 的职责边界
| 职责 | Shellteacher-portal | Remotestudent-portal |
| -------------------------------- | ----------------------- | ------------------------ |
| RootLayout字体/令牌/Provider | ✅ 提供 | ❌ 复用 |
| AppShell左栏 + 主内容区) | ✅ 提供 | ❌ 复用 |
| GraphQLProviderurql client | ✅ 提供ARB-002 | ❌ 复用单例 |
| 共享依赖暴露singleton | ✅ 提供 | ❌ 消费 |
| 登录页 | ✅ 提供P2 不走 MF | ❌ 复用 |
| rewrites `/api/v1/*` | ✅ 提供 | ❌ 依赖 Shell |
| 路由表 `/*`(无 /student 前缀) | ❌ 由 Remote 暴露 | ✅ 提供 |
| 学习场景页面 | ❌ | ✅ |
| GraphQL 业务 Hooks | ❌ | ✅ |
| 考试作答组件ExamTaking 等) | ❌ | ✅ |
| IDB 断网恢复队列 | ❌ | ✅ |
### 1.2 MF 配置student-portal/next.config.jsRemote 角色)
```javascript
// apps/student-portal/next.config.jsRemote
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-002GraphQL 相关依赖由 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 URLstudent-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](../../teacher-portal/docs/02-architecture-design.md#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 特有视图模型
```typescript
// 考试作答草稿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" | "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 cacheurql+ TanStack Query cache + Zustand L3 + IDB。**学生数据实时性要求高,缓存策略偏短**
| 数据类型 | 存储 | TTL | 失效策略 |
| ------------------------- | ------------------------- | ------- | ------------------------------------------------------------------ |
| Sessiontoken + user | localStorage + Zustand | — | 复用 Shellaccess 15min / refresh 7day401 自动 refresh |
| 权限列表 | urql cache | 5min | 复用 Shell角色变更事件 invalidate |
| 视口列表scope=student | urql cache | 5min | 复用 Shell |
| 学生 Dashboard 聚合数据 | urql cache | 30s | staleTime 30sWebSocket 事件触发 invalidate |
| 我的作业列表 | urql cache | 30s | staleTime 30s提交后 invalidate |
| 我的考试列表 | urql cache | 30s | staleTime 30sExamPublished 事件 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 30sWebSocket 事件 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](./01-understanding.md#31-消费的后端-ap经-api-gateway-代理)。
### 4.1 GraphQL 请求层(复用 Shell 暴露的 urql client 单例)
student-portal **不重复创建 GraphQL client**,复用 Shell 暴露的 `GraphQLProvider`ARB-002。urql client 配置详见 [teacher-portal 阶段 2 §4.1](../../teacher-portal/docs/02-architecture-design.md#41-统一-api-请求层libapits)。
student-portal 仅封装业务 Hooks消费 Shell 的 `useGraphQLClient()`
```typescript
// 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_ANSWER = gql`
mutation SaveExamAnswer($examId: ID!, $questionId: ID!, $answer: AnswerInput!) {
saveExamAnswer(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 |
| mutation | `submitHomework` | 提交作业 | P3 |
| query | `myGrades` | 我的成绩列表 | P3 |
| query | `myAttendance` | 我的考勤 | P3 |
| query | `studentDashboard` | 学生仪表盘聚合 | P3 |
| query | `serverTime` | 服务器时间(倒计时校正) | P3 |
| mutation | `saveExamAnswer` | 保存考试答案(自动保存) | P3 |
| mutation | `submitExam` | 提交考试 | P3 |
| mutation | `uploadAttachment` | 上传附件 | P3 |
| mutation | `recordExamSuspiciousBehavior` | 记录可疑行为 | P3 |
| query | `textbooks` | 教材列表 | P4 |
| query | `chapters` | 章节列表 | P4 |
| query | `learningPath` | 学习路径 | P4 |
| query | `myWeakness` | 错题本 | P4 |
| query | `myTrend` | 学习趋势 | P4 |
| query | `myNotifications` | 通知列表 | P5 |
| mutation | `markAsRead` | 标记通知已读 | P5 |
### 4.3 Query Key 命名约定
```typescript
// 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` → 重置倒计时(待 ai10 msg 确认事件名) |
| `ExamQuestionReordered` | 教师调整题目顺序 | 重新拉取 `exam` query → 草稿按 questionId 映射(不依赖序号)(待 ai10 确认) |
| `GradeRecorded` | 教师录入成绩 | toast + `["student","grades"]` invalidate + `["student","dashboard"]` invalidate |
| `HomeworkDeadlineApproaching` | 作业截止前提醒 | toast + 作业列表高亮 + `["student","homework"]` invalidate |
### 5.2 WebSocket 连接管理P5
```typescript
// apps/student-portal/src/lib/ws.tsP5 实现)
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 "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` | 复制粘贴检测 | 阻止默认行为 + 警告;主观题允许粘贴自己输入(待确认)|
| `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.15core-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 + 结构化,生产环境 → SentryP6。必含字段`trace_id``user_id``scope=student``path`
```typescript
// 使用示例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 MetricsWeb 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 TracerOTel browser SDKP6
复用 `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` | 全局 ModalModalRoot + 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()` | 全局 toastZustand ui-store | Shell 暴露 |
### 8.2 student-portal 特有 Hooks
| Hook | 职责 |
| ------------------------- | ----------------------------------------------------- |
| `useStudentDashboard()` | 学生仪表盘聚合查询 |
| `useMyExams(status?)` | 我的考试列表 |
| `useExamDetail(examId)` | 考试详情(含题目,禁缓存) |
| `useExamTaking(examId)` | 考试作答状态(草稿 + 倒计时 + 自动保存 + 断网队列) |
| `useSaveExamAnswer()` | 保存答案 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](../../teacher-portal/docs/02-architecture-design.md#9-设计令牌三层packagesui-tokens待建立ai07-维护))。
**强制规则**project_rules §3.10,与 Shell 一致):
- 禁止 `#hex` 字面量ESLint `no-restricted-syntax`
- 禁止 `'Inter'`/`'Fraunces'`/`'JetBrains Mono'` 字面量ESLint `design-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 不直接消费**:前端不调用 gRPCstudent-bff 把 gRPC 聚合为 GraphQL 暴露给前端。前端仅消费 `packages/contracts/src/permissions.ts` 中的权限点常量TS 文件,非 proto 生成)。
## 11. 风险与假设
### 11.1 假设
1. **假设 coord 建立 `packages/shared-ts`、`packages/contracts`**:包含 Logger、Tracer、Permissions 常量、通用类型。若 coord 未建立ai14 自行在 `apps/student-portal/src/shared/` 内实现,后续提取到 packages。
2. **假设 ai13 teacher-portal Shell 按 ARB-002 暴露 GraphQL 单例**`GraphQLProvider`/`useGraphQLClient`/`urql`/`graphql` singleton。若 Shell 未暴露student-portal 降级为自建 urql client违反 ARB-002需 coord 仲裁)。
3. **假设 ai04 student-bff 提供 GraphQL endpoint `POST /graphql`**:含 §4.2 全部 query/mutation。错误码前缀 `BFF_STUDENT_`ARB-001 已确认)。
4. **假设 teacher-portal Shell 已就绪**AppShell + 共享依赖暴露 + MF 配置 + GraphQLProviderP2 收尾完成。student-portal 作为 Remote 才能挂载。
5. **假设 Next.js 14+ Module Federation 2.0 稳定**`@module-federation/nextjs-mf` 在 Next.js App Router + urql singleton 下可用。若不稳定,降级为 4 端独立部署 + 各自 Shell重复实现 AppShell + GraphQLProvider
6. **假设 ai10 msg 提供 WebSocket 事件**`ExamExtended`/`ExamQuestionReordered` 事件名待 ai10 确认(见 issues/contracts
### 11.2 技术风险
| 风险 | 影响 | 缓解 |
| ----------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------- |
| MF Remote SSR 对齐复杂 | Remote 在 SSR 时需 Shell 提供上下文 | 优先 CSRSSR 仅用于首屏 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-*/hookscoord 维护 shared-ts/contracts | ✅ 已确认 |
| 考试作答防作弊事件名 | `ExamExtended`/`ExamQuestionReordered`| ⚠️ 待 ai10 msg 确认 |
| 附件上传协议 | GraphQL mutation `uploadAttachment` | ⚠️ 待 ai04 确认是否走 multipart |
| 主观题是否允许粘贴 | 阻止粘贴(待产品确认) | ⚠️ 待产品决策 |
| 考试作答页是否强制全屏 | 不强制P6+ lockdown 浏览器才强制) | ⚠️ 待产品决策 |
## 12. coord 交叉审查所需信息
### 12.1 端口矩阵
| 端 | dev 端口 | 生产端口 | 备注 |
| -------------- | -------- | -------- | ------------------------------------- |
| student-portal | **4001** | **4001** | MF Remote 子应用P3 首个 Remote |
> 与 [port-allocation](../../../infra/port-allocation.md) §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`/`ExamQuestionReordered` | msg | ⚠️ 待 ai10 确认 |
### 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 自用)
### P3student-portal 起步 — 核心教学)
1.`apps/student-portal/`Remote 角色),配置 MFARB-002urql/graphql singleton
2. 实现 GraphQL Hooks 层§8.2 全部 P3 Hooks复用 Shell `useGraphQLClient()`
3. 实现 Dashboard + 我的作业 + 提交作业 + 我的考试 + 作答考试 + 考试结果 + 我的成绩 + 我的考勤
4. **考试作答核心**§14 详述CountdownTimer + 自动保存 + IDB 断网恢复 + 防作弊采集 + DraftRecovery
5. 复用 Shell 的 AppShell + 共享组件RequirePermission/ErrorBoundary/Loading/Empty/DataTable/Form/Chart
6. 配置 Dockerfile 多阶段构建 + `/api/health` + `/api/ready` route
7. 补 Vitest 单测 + Playwright E2E覆盖率 ≥ 80%,含考试作答边界场景 6 个 E2E
8. 接入 MSW MockGraphQL handlers + mock-socket
### P4学情诊断 + 错题本 + 学习路径)
1. 实现学情诊断页面DiagnosticChart 多维雷达 + 趋势线)
2. 实现错题本页面WeaknessList 按知识点聚合 + 掌握度标签)
3. 实现学习路径页面LearningPathMap 知识点前置依赖可视化)
4. 接入 content 服务 GraphQLtextbooks/chapters/learningPath
### P5推送 + 通知中心)
1. student-portal 接入 WebSocketpush-gateway指数退避重连 + HTTP 轮询降级
2. 实现通知中心页面(通知列表 + 未读数 + 标记已读)
3. 处理 6 类推送事件NotificationRequested/ExamPublished/ExamExtended/ExamQuestionReordered/GradeRecorded/HomeworkDeadlineApproaching
### P6硬化
1. Web Vitals + OTel browser SDK 接入
2. A11y WCAG 2.2 AA 审计(考试作答页键盘可操作 + 屏幕阅读器友好)
3. 性能优化MF shared 单例验证、bundle 分析、代码分割)
4. PWA 配置manifest + Service Worker考试作答页 Network Only
5. 隐私合规完善PIPL/未成年人保护法强制项)
6. 多语言en-US/zh-TW100% 完成度
---
## 14. 考试作答架构设计ai14 新增 — 核心复杂场景)
> 考试作答是学生端最复杂、最易出问题的场景,必须在架构中预留所有边界场景的处理。本节是 ai14 新增,对齐 [01-understanding.md §11](./01-understanding.md#11-考试作答边界场景学生端特有必须覆盖)。
### 14.1 考试作答状态机
```mermaid
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 倒计时与服务器时间对齐
```typescript
// 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 - clientTimems
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 队列)
```typescript
// 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 考试作答自动保存策略
```typescript
// 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_ANSWER, {
examId,
questionId: "batch", // 批量保存
answer: draft.answers,
})
.toPromise();
if (result.data?.saveExamAnswer?.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_ANSWER, {
examId: item.examId,
questionId: item.questionId,
answer: item.answer,
})
.toPromise();
return result.data?.saveExamAnswer?.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 防作弊采集(前端配合,服务端最终判定)
```typescript
// 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_SUSPICIOUS_BEHAVIOR, {
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-paste", "copy attempted");
toast.warning(t("exam.antiCheat.copyDisabled"));
};
const handlePaste = (e: ClipboardEvent) => {
e.preventDefault();
record("copy-paste", "paste attempted");
toast.warning(t("exam.antiCheat.pasteDisabled"));
};
document.addEventListener("copy", handleCopy);
document.addEventListener("paste", handlePaste);
return () => {
document.removeEventListener("copy", handleCopy);
document.removeEventListener("paste", handlePaste);
};
}, [record]);
// 全屏退出检测
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 考试提交防重复
```typescript
// 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 组件
```typescript
// 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 invalidateexam.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 组件
```typescript
// 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 组件
```typescript
// 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 组件
```typescript
// 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
```typescript
// 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
```typescript
// 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 通知未读数跨标签同步
```typescript
// 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 重新打开:
1. IDB 草稿跨标签共享(同源策略保证)
2. Tab B 进入考试页时检测 IDB 草稿
3.`DraftRecovery` 提示"检测到未完成的作答草稿,是否恢复?"
4. 用户确认后从 IDB 加载草稿
```typescript
// 在 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 版本协商
```typescript
// 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 错误恢复流程
```mermaid
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`
```typescript
// 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 > 200KBgzipped| 按场景域拆分为 `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 配置
```javascript
// 拆分后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 三年架构演进路线
```mermaid
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 架构原则(始终不变)
1. **Remote 角色不变**student-portal 始终作为 teacher-portal Shell 的 Remote不独立 Shell
2. **契约先行**:所有 API 变更先 proto/contracts后实现
3. **无状态前端**:所有持久化状态走 localStorage/IDB不依赖服务端 session
4. **复用优先**:组件/Hook/工具优先复用 Shell 暴露的,避免重复实现
5. **可降级**任何依赖BFF/WebSocket/Shell故障都有降级方案
6. **可观测**:所有用户行为、错误、性能指标都可观测
7. **可测试**:所有组件/Hook/页面都有单测 + E2E 覆盖
8. **可演化**:模块可拆分、状态管理可迁移、框架可升级
9. **考试作答可靠性**:断网不丢草稿、时间对齐服务器、防作弊采集、防重复提交
10. **学生隐私保护**PIPL/未成年人保护法合规,敏感数据最小化
### 20.3 与其他 portal 的协同演化
| 演化方向 | student-portal 角色 | 协同端 |
| -------------------------------------- | ------------------------------------ | ------------------------------------ |
| 共享组件抽取到 packages/ | 消费方(不再在 Remote 内自建组件) | teacher-portalShell+ 其他 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 MeshIstio | student-portal 不感知;网络层透明 |
| 引入 Feature Flag 服务 | 集成 FeatureFlagProvider组件按 flag 渲染 |
| 引入 A/B Testing 平台 | 集成 ABTestProviderUI 按 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 事件名待 ai10 确认 | 待 ai10 | 中 | P5 启动前确认 `ExamExtended`/`ExamQuestionReordered` |
---
**AI Agent**: ai14 (student-portal remote)
**Branch**: feat-review-student-portal-docs-9yN6Av
**Coordinator**: coord-ai
**Predecessor**: ai07初版起草ai14 接管审计与补全)