feat(teacher-portal): 完整实现 teacher-portal 微前端
包含 settings/students/api、graphql、mocks、ui-tokens 设计令牌等
This commit is contained in:
12
apps/teacher-portal/src/mocks/browser.ts
Normal file
12
apps/teacher-portal/src/mocks/browser.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* MSW Browser Setup - 浏览器端 Service Worker 注册
|
||||
*
|
||||
* 用法:在客户端入口(如 providers.tsx 或 layout)调用 setupWorker()
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { setupWorker } from "msw/browser";
|
||||
import { handlers } from "./handlers";
|
||||
|
||||
export const worker = setupWorker(...handlers);
|
||||
33
apps/teacher-portal/src/mocks/fixtures/classes.ts
Normal file
33
apps/teacher-portal/src/mocks/fixtures/classes.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Mock 班级数据(classes 域)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import type { Class } from "@/lib/graphql";
|
||||
|
||||
export const mockClasses: Class[] = [
|
||||
{
|
||||
id: "550e8400-e29b-41d4-a716-446655440010",
|
||||
name: "高三(1)班",
|
||||
gradeId: "grade-12",
|
||||
studentCount: 32,
|
||||
},
|
||||
{
|
||||
id: "550e8400-e29b-41d4-a716-446655440011",
|
||||
name: "高三(2)班",
|
||||
gradeId: "grade-12",
|
||||
studentCount: 30,
|
||||
},
|
||||
{
|
||||
id: "550e8400-e29b-41d4-a716-446655440012",
|
||||
name: "高三(3)班",
|
||||
gradeId: "grade-12",
|
||||
studentCount: 28,
|
||||
},
|
||||
];
|
||||
|
||||
/** Mock 班级详情查找(按 id) */
|
||||
export function findClass(id: string): Class | null {
|
||||
return mockClasses.find((c) => c.id === id) ?? null;
|
||||
}
|
||||
43
apps/teacher-portal/src/mocks/fixtures/exams.ts
Normal file
43
apps/teacher-portal/src/mocks/fixtures/exams.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Mock 考试数据(core-edu 域)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import type { ExamItem } from "@/lib/graphql";
|
||||
|
||||
export function generateMockExams(classId: string): ExamItem[] {
|
||||
const now = Date.now();
|
||||
return [
|
||||
{
|
||||
id: `${classId}-exam-001`,
|
||||
classId,
|
||||
title: "2026 春季期中考试",
|
||||
description: "覆盖第 1-6 章内容",
|
||||
examDate: new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
duration: 120,
|
||||
totalScore: 150,
|
||||
status: "PUBLISHED",
|
||||
},
|
||||
{
|
||||
id: `${classId}-exam-002`,
|
||||
classId,
|
||||
title: "单元测试 - 函数与导数",
|
||||
description: null,
|
||||
examDate: new Date(now + 14 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
duration: 60,
|
||||
totalScore: 100,
|
||||
status: "DRAFT",
|
||||
},
|
||||
{
|
||||
id: `${classId}-exam-003`,
|
||||
classId,
|
||||
title: "月考 - 数学综合",
|
||||
description: "全册综合测试",
|
||||
examDate: new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
duration: 90,
|
||||
totalScore: 120,
|
||||
status: "SCORED",
|
||||
},
|
||||
];
|
||||
}
|
||||
26
apps/teacher-portal/src/mocks/fixtures/grades.ts
Normal file
26
apps/teacher-portal/src/mocks/fixtures/grades.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Mock 成绩数据(core-edu 域)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import type { GradeItem } from "@/lib/graphql";
|
||||
|
||||
export function generateMockGrades(examId: string): GradeItem[] {
|
||||
const grades: GradeItem[] = [];
|
||||
for (let i = 1; i <= 30; i++) {
|
||||
const score = Math.floor(Math.random() * 40) + 60;
|
||||
const feedback =
|
||||
score < 70 ? "需要加强基础练习" : score > 90 ? "表现出色" : null;
|
||||
grades.push({
|
||||
id: `${examId}-grade-${i}`,
|
||||
studentId: `${examId.slice(0, -3)}${String(i).padStart(3, "0")}`,
|
||||
studentName: `学生${String(i).padStart(2, "0")}`,
|
||||
examId,
|
||||
homeworkId: null,
|
||||
score,
|
||||
feedback,
|
||||
});
|
||||
}
|
||||
return grades;
|
||||
}
|
||||
37
apps/teacher-portal/src/mocks/fixtures/homework.ts
Normal file
37
apps/teacher-portal/src/mocks/fixtures/homework.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Mock 作业数据(core-edu 域)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import type { HomeworkItem } from "@/lib/graphql";
|
||||
|
||||
export function generateMockHomework(classId: string): HomeworkItem[] {
|
||||
const now = Date.now();
|
||||
return [
|
||||
{
|
||||
id: `${classId}-hw-001`,
|
||||
classId,
|
||||
title: "函数练习题",
|
||||
description: "课本 P45 第 1-10 题",
|
||||
dueDate: new Date(now + 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: "NOT_SUBMITTED",
|
||||
},
|
||||
{
|
||||
id: `${classId}-hw-002`,
|
||||
classId,
|
||||
title: "导数应用题",
|
||||
description: null,
|
||||
dueDate: new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: "SUBMITTED",
|
||||
},
|
||||
{
|
||||
id: `${classId}-hw-003`,
|
||||
classId,
|
||||
title: "月考错题订正",
|
||||
description: "订正月考全部错题,并写解题思路",
|
||||
dueDate: new Date(now - 2 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: "GRADED",
|
||||
},
|
||||
];
|
||||
}
|
||||
22
apps/teacher-portal/src/mocks/fixtures/students.ts
Normal file
22
apps/teacher-portal/src/mocks/fixtures/students.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Mock 学生名单(iam 数据,经 teacher-bff 聚合)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import type { Student } from "@/lib/graphql";
|
||||
|
||||
/** 按班级 ID 生成学生名单(固定 30 名) */
|
||||
export function generateMockStudents(classId: string): Student[] {
|
||||
const students: Student[] = [];
|
||||
for (let i = 1; i <= 30; i++) {
|
||||
const id = `${classId.slice(0, -3)}${String(i).padStart(3, "0")}`;
|
||||
students.push({
|
||||
id,
|
||||
name: `学生${String(i).padStart(2, "0")}`,
|
||||
email: `student${i}@edu.test`,
|
||||
avatarUrl: null,
|
||||
});
|
||||
}
|
||||
return students;
|
||||
}
|
||||
39
apps/teacher-portal/src/mocks/fixtures/user.ts
Normal file
39
apps/teacher-portal/src/mocks/fixtures/user.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Mock 用户数据(iam 域)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:contract.md §4.2 MSW 拦截策略
|
||||
*/
|
||||
|
||||
import type { User } from "@/lib/graphql";
|
||||
|
||||
export const mockUser: User = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440001",
|
||||
email: "teacher@edu.test",
|
||||
name: "张老师",
|
||||
roles: ["teacher"],
|
||||
dataScope: "GRADE",
|
||||
};
|
||||
|
||||
/** localStorage 中存储的 UserInfo(含 permissions 字段) */
|
||||
export const mockUserInfo = {
|
||||
...mockUser,
|
||||
permissions: [
|
||||
"DASHBOARD_READ",
|
||||
"VIEWPORT_READ",
|
||||
"CLASS_READ",
|
||||
"CLASS_READ_OWN",
|
||||
"STUDENT_READ_OWN",
|
||||
"EXAM_READ_OWN",
|
||||
"HOMEWORK_READ_OWN",
|
||||
"GRADE_READ_OWN",
|
||||
"GRADE_READ_CHILD",
|
||||
"USER_READ_OWN",
|
||||
],
|
||||
};
|
||||
|
||||
/** Mock JWT token(与 api-gateway DEV_MODE 公钥配对) */
|
||||
export const mockToken =
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." +
|
||||
btoa(JSON.stringify(mockUserInfo)) +
|
||||
".mock-signature";
|
||||
58
apps/teacher-portal/src/mocks/fixtures/viewports.ts
Normal file
58
apps/teacher-portal/src/mocks/fixtures/viewports.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Mock 视口数据(iam 4 层视口模型,contract.md §2.4)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import type { ViewportItem } from "@/lib/graphql";
|
||||
|
||||
export const mockViewports: ViewportItem[] = [
|
||||
{
|
||||
key: "dashboard",
|
||||
label: "仪表盘",
|
||||
route: "/dashboard",
|
||||
icon: null,
|
||||
sortOrder: "01",
|
||||
requiredPermission: "DASHBOARD_READ",
|
||||
},
|
||||
{
|
||||
key: "classes",
|
||||
label: "班级管理",
|
||||
route: "/classes",
|
||||
icon: null,
|
||||
sortOrder: "02",
|
||||
requiredPermission: "CLASS_READ",
|
||||
},
|
||||
{
|
||||
key: "exams",
|
||||
label: "考试管理",
|
||||
route: "/exams",
|
||||
icon: null,
|
||||
sortOrder: "03",
|
||||
requiredPermission: "EXAM_READ_OWN",
|
||||
},
|
||||
{
|
||||
key: "homework",
|
||||
label: "作业管理",
|
||||
route: "/homework",
|
||||
icon: null,
|
||||
sortOrder: "04",
|
||||
requiredPermission: "HOMEWORK_READ_OWN",
|
||||
},
|
||||
{
|
||||
key: "grades",
|
||||
label: "成绩查询",
|
||||
route: "/grades",
|
||||
icon: null,
|
||||
sortOrder: "05",
|
||||
requiredPermission: "GRADE_READ_OWN",
|
||||
},
|
||||
{
|
||||
key: "settings",
|
||||
label: "个人设置",
|
||||
route: "/settings",
|
||||
icon: null,
|
||||
sortOrder: "99",
|
||||
requiredPermission: "USER_READ_OWN",
|
||||
},
|
||||
];
|
||||
174
apps/teacher-portal/src/mocks/handlers.ts
Normal file
174
apps/teacher-portal/src/mocks/handlers.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* MSW Handlers - 拦截所有 HTTP / GraphQL 请求返回 mock 数据
|
||||
*
|
||||
* 拦截路径(contract.md §2.3 + §4.2):
|
||||
* - POST /api/auth/login → 返回固定 JWT + UserInfo
|
||||
* - POST /api/v1/teacher/graphql → 按 operationName 返回对应 GraphQL 响应
|
||||
*
|
||||
* 切换:NEXT_PUBLIC_API_MOCKING=enabled 时启用(见 src/mocks/index.ts)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { mockUser, mockUserInfo, mockToken } from "./fixtures/user";
|
||||
import { mockViewports } from "./fixtures/viewports";
|
||||
import { mockClasses, findClass } from "./fixtures/classes";
|
||||
import { generateMockStudents } from "./fixtures/students";
|
||||
import { generateMockExams } from "./fixtures/exams";
|
||||
import { generateMockHomework } from "./fixtures/homework";
|
||||
import { generateMockGrades } from "./fixtures/grades";
|
||||
|
||||
/** ActionState 信封(coord-final-decisions F9 + 总裁 §3.4 方案 B) */
|
||||
function envelope<T>(data: T) {
|
||||
return { success: true, errors: null, data };
|
||||
}
|
||||
|
||||
/** GraphQL 响应体(urql 期望 { data: ... }) */
|
||||
function graphqlData<T>(data: T) {
|
||||
return HttpResponse.json({ data });
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 operationName 路由 GraphQL mock 响应
|
||||
*
|
||||
* 对应 graphql.ts 的 5 个 P2 Query + 4 个 P3 Query + 1 个 Mutation。
|
||||
*/
|
||||
function handleGraphQL(
|
||||
operationName: string,
|
||||
variables: Record<string, unknown>,
|
||||
) {
|
||||
switch (operationName) {
|
||||
case "Dashboard":
|
||||
return graphqlData({
|
||||
dashboard: {
|
||||
user: mockUser,
|
||||
classes: mockClasses,
|
||||
viewports: mockViewports,
|
||||
stats: {
|
||||
totalExams: 8,
|
||||
pendingGrading: 12,
|
||||
todayHomework: 3,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
case "Viewports":
|
||||
return graphqlData({ viewports: mockViewports });
|
||||
|
||||
case "Me":
|
||||
return graphqlData({ me: mockUser });
|
||||
|
||||
case "Classes":
|
||||
return graphqlData({ classes: mockClasses });
|
||||
|
||||
case "Class": {
|
||||
const id = variables.id as string;
|
||||
return graphqlData({ class: findClass(id) });
|
||||
}
|
||||
|
||||
case "ClassStudents": {
|
||||
const classId = variables.classId as string;
|
||||
return graphqlData({ classStudents: generateMockStudents(classId) });
|
||||
}
|
||||
|
||||
case "ClassExams": {
|
||||
const classId = variables.classId as string;
|
||||
return graphqlData({ classExams: generateMockExams(classId) });
|
||||
}
|
||||
|
||||
case "ClassHomework": {
|
||||
const classId = variables.classId as string;
|
||||
return graphqlData({ classHomework: generateMockHomework(classId) });
|
||||
}
|
||||
|
||||
case "ExamGrades": {
|
||||
const examId = variables.examId as string;
|
||||
return graphqlData({ examGrades: generateMockGrades(examId) });
|
||||
}
|
||||
|
||||
case "UpdateUser": {
|
||||
const input = variables.input as { name?: string; email?: string };
|
||||
return graphqlData({
|
||||
updateUser: { ...mockUser, ...input },
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
return HttpResponse.json(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
message: `Mock 未覆盖的 GraphQL operation: ${operationName}`,
|
||||
extensions: { code: "BFF_TEACHER_NOT_IMPLEMENTED" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const handlers = [
|
||||
// POST /api/auth/login → 登录
|
||||
http.post("*/api/auth/login", async ({ request }) => {
|
||||
const body = (await request.json()) as {
|
||||
email?: string;
|
||||
password?: string;
|
||||
};
|
||||
if (!body.email || !body.password) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { code: "GW_UNAUTHORIZED", message: "邮箱和密码必填" },
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
return HttpResponse.json(
|
||||
envelope({
|
||||
user: mockUserInfo,
|
||||
tokens: { accessToken: mockToken, refreshToken: "mock-refresh" },
|
||||
}),
|
||||
);
|
||||
}),
|
||||
|
||||
// POST /api/v1/teacher/graphql → GraphQL endpoint
|
||||
http.post("*/api/v1/teacher/graphql", async ({ request }) => {
|
||||
const body = (await request.json()) as {
|
||||
operationName?: string;
|
||||
query?: string;
|
||||
variables?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const operationName =
|
||||
body.operationName ?? extractOperationName(body.query);
|
||||
const variables = body.variables ?? {};
|
||||
|
||||
return handleGraphQL(operationName, variables);
|
||||
}),
|
||||
|
||||
// GET /api/health → liveness
|
||||
http.get("*/api/health", () =>
|
||||
HttpResponse.json({ status: "ok", service: "teacher-portal" }),
|
||||
),
|
||||
|
||||
// GET /api/ready → readiness
|
||||
http.get("*/api/ready", () =>
|
||||
HttpResponse.json({
|
||||
status: "ok",
|
||||
service: "teacher-portal",
|
||||
checks: { msw: true },
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
/**
|
||||
* 从 query 字符串提取 operationName
|
||||
* 兜底:当客户端未传 operationName 时从 query 文本提取
|
||||
*/
|
||||
function extractOperationName(query?: string): string {
|
||||
if (!query) return "Unknown";
|
||||
const match = query.match(/(?:query|mutation)\s+(\w+)/);
|
||||
return match?.[1] ?? "Unknown";
|
||||
}
|
||||
37
apps/teacher-portal/src/mocks/index.ts
Normal file
37
apps/teacher-portal/src/mocks/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* MSW 启用入口 - 按 NEXT_PUBLIC_API_MOCKING 环境变量条件启用
|
||||
*
|
||||
* 启用方式:在 .env 中设置 NEXT_PUBLIC_API_MOCKING=enabled
|
||||
* 关闭方式:删除该变量或设为 disabled
|
||||
*
|
||||
* 在客户端入口(providers.tsx)调用 initMocks() 触发注册。
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
const MOCKING_ENABLED = process.env.NEXT_PUBLIC_API_MOCKING === "enabled";
|
||||
|
||||
async function initMocks(): Promise<void> {
|
||||
if (!MOCKING_ENABLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
// 服务端:启用 node server
|
||||
const { server } = await import("./server");
|
||||
server.listen({
|
||||
onUnhandledRequest: "bypass",
|
||||
});
|
||||
} else {
|
||||
// 客户端:注册 Service Worker
|
||||
const { worker } = await import("./browser");
|
||||
await worker.start({
|
||||
onUnhandledRequest: "bypass",
|
||||
serviceWorker: {
|
||||
url: "/mockServiceWorker.js",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { initMocks, MOCKING_ENABLED };
|
||||
13
apps/teacher-portal/src/mocks/server.ts
Normal file
13
apps/teacher-portal/src/mocks/server.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* MSW Server Setup - Node.js 端 mock(用于 SSR + 测试)
|
||||
*
|
||||
* Next.js App Router 在服务端渲染时会调用 GraphQL(urql 支持 SSR),
|
||||
* 需要在服务端也启用 MSW 拦截。
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
import { setupServer } from "msw/node";
|
||||
import { handlers } from "./handlers";
|
||||
|
||||
export const server = setupServer(...handlers);
|
||||
Reference in New Issue
Block a user