feat(portal-shell): v2.0 P0 shadcn standardization + security + streaming + error handling

- shadcn/ui 标准化:废弃纸感令牌,统一 bg-background/text-foreground 等
- Tailwind v4 + @theme inline,移除 tailwind.config.js
- React 19 use() + Suspense 流式渲染,首屏骨架秒出
- 三级错误边界:Route → Section → Widget 层层兜底
- 错误上报:useErrorReport → sendBeacon → /api/log mock 端点
- 三层安全边界:L1 角色门禁 / L2 权限点门禁 / L3 数据范围
- 权限位图 base36 压缩:67 权限点 → ~14 字符,JWT 体积减少 ≥ 99%
- notify 统一 Toast 封装,禁止业务直接 import sonner
- PluginBoundary 替代 PluginLoader(错误边界 + Suspense + Skeleton 三件套)

验证:typecheck 0 错误 / lint 0 错误 / build 6 路由生成成功
This commit is contained in:
SpecialX
2026-07-17 16:10:05 +08:00
parent f7e52b5b7f
commit 9cedf0c437
140 changed files with 10872 additions and 3192 deletions

View File

@@ -1,724 +1,69 @@
# 自动生成的 GraphQL Federation 子图v2.1 M0
# 源文件core_edu.proto
# 请勿手动修改;如需调整,改 proto 后重新运行 pnpm run proto:gen-graphql
# ------------------------------------------------------
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
# ------------------------------------------------------
extend type Query
type Exam @key(fields: "id") {
id: String
class_id: String
subject_id: String
title: String
type Exam {
id: ID!
classId: String!
subjectId: String!
title: String!
description: String
exam_date: String
duration: Int
total_score: String
status: String
status_changed_at: String
status_changed_by: String
school_id: String
created_by: String
archived_at: String
created_at: String
updated_at: String
examDate: String!
duration: Float!
totalScore: String!
status: String!
statusChangedAt: String!
statusChangedBy: String
schoolId: String!
createdBy: String!
archivedAt: String
createdAt: String!
updatedAt: String!
}
type ExamQuestion @key(fields: "id") {
id: String
exam_id: String
question_id: String
order: Int
score: String
question_type: String
}
type ExamSubmission @key(fields: "id") {
id: String
exam_id: String
student_id: String
status: String
submitted_at: String
graded_at: String
graded_by: String
total_score: String
}
type CreateExamResponse @key(fields: "id") {
id: String
}
type GetExamRequest @key(fields: "id") {
id: String
}
type UpdateExamRequest @key(fields: "id") {
id: String
title: String
type Homework {
id: ID!
classId: String!
subjectId: String!
title: String!
description: String
exam_date: String
duration: Int
total_score: String
status: String
dueDate: String!
gracePeriod: Float!
status: String!
schoolId: String!
createdBy: String!
createdAt: String!
updatedAt: String!
}
type DeleteExamRequest @key(fields: "id") {
id: String
}
type PublishExamRequest @key(fields: "id") {
id: String
published_by: String
}
type Homework @key(fields: "id") {
id: String
class_id: String
subject_id: String
title: String
description: String
due_date: String
grace_period: Int
status: String
school_id: String
created_by: String
created_at: String
updated_at: String
}
type HomeworkSubmission @key(fields: "id") {
id: String
homework_id: String
student_id: String
status: String
submitted_at: String
graded_at: String
graded_by: String
total_score: String
type Grade {
id: ID!
studentId: String!
examId: String
homeworkId: String
score: String!
totalScore: String!
feedback: String
gradedBy: String!
schoolId: String!
idempotencyKey: String
createdAt: String!
updatedAt: String!
}
type AssignHomeworkResponse @key(fields: "id") {
id: String
}
type GetHomeworkRequest @key(fields: "id") {
id: String
}
type Grade @key(fields: "id") {
id: String
student_id: String
exam_id: String
homework_id: String
score: String
total_score: String
feedback: String
graded_by: String
school_id: String
idempotency_key: String
created_at: String
updated_at: String
}
type RecordGradeResponse @key(fields: "id") {
id: String
}
type GetGradeRequest @key(fields: "id") {
id: String
}
type UpdateGradeRequest @key(fields: "id") {
id: String
score: String
feedback: String
updated_by: String
}
type ClassInfo @key(fields: "id") {
id: String
name: String
grade_id: String
head_teacher_id: String
type ClassInfo {
id: ID!
name: String!
gradeId: String!
headTeacherId: String
description: String
created_at: String
updated_at: String
createdAt: String!
updatedAt: String!
}
type StudentInfo @key(fields: "id") {
id: String
name: String
class_id: String
}
type GetClassRequest @key(fields: "id") {
id: String
}
type Attendance @key(fields: "id") {
id: String
schedule_id: String
student_id: String
status: String
remark: String
recorded_by: String
school_id: String
created_at: String
updated_at: String
}
type RecordAttendanceResponse @key(fields: "id") {
id: String
}
type GetAttendanceRequest @key(fields: "id") {
id: String
}
type ScheduleSlotInfo @key(fields: "id") {
id: String
course_id: String
course_name: String
teacher_id: String
class_id: String
room_id: String
start_time: String
end_time: String
subject_id: String
}
type LeaveRequest @key(fields: "id") {
id: String
student_id: String
class_id: String
leave_type: String
start_date: String
end_date: String
reason: String
status: String
submitted_by: String
reviewed_by: String
review_comment: String
school_id: String
created_at: String
updated_at: String
}
type CreateLeaveRequestResponse @key(fields: "id") {
id: String
}
type CancelLeaveRequestRequest @key(fields: "id") {
id: String
cancelled_by: String
}
type SchoolInfo @key(fields: "id") {
id: String
name: String
address: String
principal_id: String
created_at: String
}
type GradeLevelInfo @key(fields: "id") {
id: String
name: String
school_id: String
order: Int
}
type DepartmentInfo @key(fields: "id") {
id: String
name: String
school_id: String
head_id: String
created_at: String
}
type AcademicYearInfo @key(fields: "id") {
id: String
name: String
school_id: String
start_date: String
end_date: String
is_current: Boolean
}
input CreateExamRequestInput {
class_id: String
subject_id: String
title: String
description: String
exam_date: String
duration: Int
total_score: String
school_id: String
created_by: String
}
input ListExamsByClassRequestInput {
class_id: String
}
input ListExamsResponseInput {
exams: Exam
}
input UpdateExamResponseInput {
success: Boolean
}
input DeleteExamResponseInput {
success: Boolean
}
input PublishExamResponseInput {
success: Boolean
}
input SubmitExamRequestInput {
exam_id: String
student_id: String
answers: AnswerInput
}
type AnswerInput {
question_id: String
answer: String
}
input SubmitExamResponseInput {
submission_id: String
}
input GradeExamRequestInput {
exam_id: String
submission_id: String
scores: ScoreInput
graded_by: String
}
type ScoreInput {
question_id: String
score: String
teacher_comment: String
}
input GradeExamResponseInput {
success: Boolean
total_score: String
}
input AssignHomeworkRequestInput {
class_id: String
subject_id: String
title: String
description: String
due_date: String
grace_period: Int
school_id: String
created_by: String
}
input ListHomeworkByClassRequestInput {
class_id: String
}
input ListHomeworkResponseInput {
homework: Homework
}
input SubmitHomeworkRequestInput {
homework_id: String
student_id: String
answers: AnswerInput
}
input SubmitHomeworkResponseInput {
submission_id: String
}
input GradeHomeworkRequestInput {
homework_id: String
submission_id: String
scores: ScoreInput
feedback: String
graded_by: String
}
input GradeHomeworkResponseInput {
success: Boolean
total_score: String
}
input RecordGradeRequestInput {
student_id: String
exam_id: String
homework_id: String
score: String
total_score: String
feedback: String
graded_by: String
school_id: String
idempotency_key: String
}
input ListGradesByStudentRequestInput {
student_id: String
}
input ListGradesByExamRequestInput {
exam_id: String
}
input ListGradesByHomeworkRequestInput {
homework_id: String
}
input ListGradesResponseInput {
grades: Grade
}
input UpdateGradeResponseInput {
success: Boolean
}
input GetClassesByTeacherRequestInput {
teacher_id: String
}
input GetClassesByTeacherResponseInput {
classes: ClassInfo
}
input BatchGetClassesRequestInput {
ids: String
}
input BatchGetClassesResponseInput {
classes: ClassInfo
}
input ListStudentsByClassRequestInput {
class_id: String
}
input ListStudentsByClassResponseInput {
students: StudentInfo
}
input RecordAttendanceRequestInput {
schedule_id: String
student_id: String
status: String
remark: String
recorded_by: String
school_id: String
}
input ListAttendanceByStudentRequestInput {
student_id: String
}
input ListAttendanceByClassRequestInput {
class_id: String
}
input ListAttendanceResponseInput {
attendance: Attendance
}
input SaveExamDraftRequestInput {
exam_id: String
student_id: String
answers: AnswerInput
}
input SaveExamDraftResponseInput {
draft_id: String
}
input RecordExamViolationRequestInput {
exam_id: String
student_id: String
violation_type: String
detail: String
severity: Int
}
input RecordExamViolationResponseInput {
violation_id: String
}
input ExtendExamRequestInput {
exam_id: String
extension_seconds: Int
extended_by: String
}
input ExtendExamResponseInput {
success: Boolean
new_duration: Int
}
input ForceSubmitExamRequestInput {
exam_id: String
student_ids: String
forced_by: String
}
input ForceSubmitExamResponseInput {
affected_count: Int
}
type QuestionOrderInput {
question_id: String
order: Int
}
input ReorderExamQuestionsRequestInput {
exam_id: String
orders: QuestionOrderInput
reordered_by: String
}
input ReorderExamQuestionsResponseInput {
success: Boolean
}
input GetReportCardRequestInput {
student_id: String
term_id: String
}
type ReportCard {
student_id: String
term_id: String
entries: ReportCardEntry
overall_grade: String
class_rank: String
created_at: String
}
type ReportCardEntry {
subject_id: String
subject_name: String
exam_score: String
exam_total: String
homework_score: String
homework_total: String
final_score: String
grade_level: String
teacher_comment: String
}
input GetScheduleByStudentRequestInput {
student_id: String
week_start: String
}
input GetScheduleByStudentResponseInput {
slots: ScheduleSlotInfo
}
input ListLeaveRequestsByStudentRequestInput {
student_id: String
status: String
}
input ListLeaveRequestsResponseInput {
leave_requests: LeaveRequest
}
input CreateLeaveRequestRequestInput {
student_id: String
class_id: String
leave_type: String
start_date: String
end_date: String
reason: String
submitted_by: String
school_id: String
}
input CancelLeaveRequestResponseInput {
success: Boolean
}
input GetDashboardRequestInput {
teacher_id: String
}
type DashboardData {
teacher_id: String
total_classes: Int
total_students: Int
pending_homework: Int
upcoming_exams: Int
ungraded_submissions: Int
classes: DashboardClassCard
upcoming_exam_list: DashboardExamCard
generated_at: String
}
type DashboardClassCard {
class_id: String
class_name: String
student_count: Int
}
type DashboardExamCard {
exam_id: String
title: String
exam_date: String
class_id: String
class_name: String
}
input GetClassPerformanceRequestInput {
class_id: String
subject_id: String
}
type ClassPerformance {
class_id: String
class_name: String
student_count: Int
average_score: String
highest_score: String
lowest_score: String
median_score: String
subjects: ClassPerformanceSubject
generated_at: String
}
type ClassPerformanceSubject {
subject_id: String
subject_name: String
average_score: String
student_count: Int
}
input ListSchoolsRequestInput {
}
input ListSchoolsResponseInput {
schools: SchoolInfo
}
input ListGradeLevelsRequestInput {
school_id: String
}
input ListGradeLevelsResponseInput {
grade_levels: GradeLevelInfo
}
input ListDepartmentsRequestInput {
school_id: String
}
input ListDepartmentsResponseInput {
departments: DepartmentInfo
}
input ListAcademicYearsRequestInput {
school_id: String
}
input ListAcademicYearsResponseInput {
academic_years: AcademicYearInfo
}
extend type Query {
exam: Exam
}
extend type Query {
examsByClass: [ListExamsResponse!]!
}
extend type Query {
homework: Homework
}
extend type Query {
homeworkByClass: [ListHomeworkResponse!]!
}
extend type Query {
grade: Grade
}
extend type Query {
gradesByStudent: [ListGradesResponse!]!
}
extend type Query {
gradesByExam: [ListGradesResponse!]!
}
extend type Query {
gradesByHomework: [ListGradesResponse!]!
}
extend type Query {
reportCard: ReportCard
}
extend type Query {
class: ClassInfo
}
extend type Query {
classesByTeacher: GetClassesByTeacherResponse
}
extend type Query {
studentsByClass: [ListStudentsByClassResponse!]!
}
extend type Query {
attendance: Attendance
}
extend type Query {
attendanceByStudent: [ListAttendanceResponse!]!
}
extend type Query {
attendanceByClass: [ListAttendanceResponse!]!
}
extend type Query {
scheduleByStudent: GetScheduleByStudentResponse
}
extend type Query {
leaveRequestsByStudent: [ListLeaveRequestsResponse!]!
}
extend type Query {
dashboard: DashboardData
}
extend type Query {
classPerformance: ClassPerformance
}
extend type Query {
schools: [ListSchoolsResponse!]!
}
extend type Query {
gradeLevels: [ListGradeLevelsResponse!]!
}
extend type Query {
departments: [ListDepartmentsResponse!]!
}
extend type Query {
academicYears: [ListAcademicYearsResponse!]!
}
type Query {
exam(id: ID!): Exam
homework(id: ID!): Homework
grade(id: ID!): Grade
classInfo(id: ID!): ClassInfo
}

View File

@@ -13,7 +13,7 @@
*/
import { Module } from "@nestjs/common";
import { GraphQLModule } from "@nestjs/graphql";
import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo";
import { ApolloFederationDriver, ApolloDriverConfig } from "@nestjs/apollo";
import { join } from "node:path";
import { GraphqlContext } from "@edu/shared-ts/federation";
import { ExamsModule } from "../exams/exams.module.js";
@@ -23,11 +23,7 @@ import { ClassesModule } from "../classes/classes.module.js";
import { ExamResolver } from "./resolvers/exam.resolver.js";
import { HomeworkResolver } from "./resolvers/homework.resolver.js";
import { GradeResolver } from "./resolvers/grade.resolver.js";
import {
ClassResolver,
StudentInfoResolver,
} from "./resolvers/class.resolver.js";
import { DataScopeResolver } from "./resolvers/datascope.resolver.js";
import { ClassResolver } from "./resolvers/class.resolver.js";
import { DataLoaderService } from "./dataloader.service.js";
import { getRedisClient } from "../config/redis.js";
@@ -38,7 +34,7 @@ import { getRedisClient } from "../config/redis.js";
GradesModule,
ClassesModule,
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
driver: ApolloFederationDriver,
// Federation 2 子图
autoSchemaFile: {
path: join(process.cwd(), "src/graphql/generated/schema.graphql"),
@@ -63,8 +59,6 @@ import { getRedisClient } from "../config/redis.js";
HomeworkResolver,
GradeResolver,
ClassResolver,
StudentInfoResolver,
DataScopeResolver,
DataLoaderService,
{
// 提供 Redis 实例供后续 ScopeToken / 缓存场景使用

View File

@@ -20,12 +20,17 @@ export class RouterAuthGuard extends BaseRouterAuthGuard {
}
override canActivate(ctx: ExecutionContext): boolean {
// DEV_MODE 优先放行(开发态跳过所有校验)
if (process.env.DEV_MODE === "true") {
return true;
}
const req = ctx.switchToHttp().getRequest<{
url: string;
}>();
// 仅 GraphQL 端点需要校验REST 路由放行
if (!req.url?.startsWith("/graphql")) {
if (!req?.url?.startsWith("/graphql")) {
return true;
}

View File

@@ -1,3 +1,4 @@
import "@edu/shared-ts/env-loader";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module.js";
import { env } from "./config/env.js";

View File

@@ -13,6 +13,14 @@ export interface AuthenticatedRequest extends Request {
@Injectable()
export class AuthMiddleware implements NestMiddleware {
use(req: AuthenticatedRequest, _res: Response, next: NextFunction): void {
// DEV_MODE 跳过鉴权(开发态允许 apollo-router 内省 / GraphQL playground
if (process.env.DEV_MODE === "true") {
req.userId = (req.headers["x-user-id"] as string) ?? "dev-user-001";
req.userRoles = [];
next();
return;
}
const userIdHeader = req.headers["x-user-id"];
const userId = typeof userIdHeader === "string" ? userIdHeader : undefined;
const rolesHeaderRaw = req.headers["x-user-roles"];