feat(teacher-portal): 完整实现 teacher-portal 微前端

包含 settings/students/api、graphql、mocks、ui-tokens 设计令牌等
This commit is contained in:
SpecialX
2026-07-10 19:10:20 +08:00
parent 14d836beb0
commit 1eacd1ed87
48 changed files with 3897 additions and 1056 deletions

View File

@@ -0,0 +1,12 @@
/**
* MSW Browser Setup - 浏览器端 Service Worker 注册
*
* 用法:在客户端入口(如 providers.tsx 或 layout调用 setupWorker()
*
* 维护者ai13teacher-portal
*/
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";
export const worker = setupWorker(...handlers);

View File

@@ -0,0 +1,33 @@
/**
* Mock 班级数据classes 域)
*
* 维护者ai13teacher-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;
}

View File

@@ -0,0 +1,43 @@
/**
* Mock 考试数据core-edu 域)
*
* 维护者ai13teacher-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",
},
];
}

View File

@@ -0,0 +1,26 @@
/**
* Mock 成绩数据core-edu 域)
*
* 维护者ai13teacher-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;
}

View File

@@ -0,0 +1,37 @@
/**
* Mock 作业数据core-edu 域)
*
* 维护者ai13teacher-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",
},
];
}

View File

@@ -0,0 +1,22 @@
/**
* Mock 学生名单iam 数据,经 teacher-bff 聚合)
*
* 维护者ai13teacher-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;
}

View File

@@ -0,0 +1,39 @@
/**
* Mock 用户数据iam 域)
*
* 维护者ai13teacher-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";

View File

@@ -0,0 +1,58 @@
/**
* Mock 视口数据iam 4 层视口模型contract.md §2.4
*
* 维护者ai13teacher-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",
},
];

View 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
*
* 维护者ai13teacher-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";
}

View File

@@ -0,0 +1,37 @@
/**
* MSW 启用入口 - 按 NEXT_PUBLIC_API_MOCKING 环境变量条件启用
*
* 启用方式:在 .env 中设置 NEXT_PUBLIC_API_MOCKING=enabled
* 关闭方式:删除该变量或设为 disabled
*
* 在客户端入口providers.tsx调用 initMocks() 触发注册。
*
* 维护者ai13teacher-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 };

View File

@@ -0,0 +1,13 @@
/**
* MSW Server Setup - Node.js 端 mock用于 SSR + 测试)
*
* Next.js App Router 在服务端渲染时会调用 GraphQLurql 支持 SSR
* 需要在服务端也启用 MSW 拦截。
*
* 维护者ai13teacher-portal
*/
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);