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,414 +1,63 @@
# 自动生成的 GraphQL Federation 子图v2.1 M0
# 源文件content.proto
# 请勿手动修改;如需调整,改 proto 后重新运行 pnpm run proto:gen-graphql
# ------------------------------------------------------
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
# ------------------------------------------------------
extend type Query
type Textbook @key(fields: "id") {
id: String
title: String
subject_id: String
grade_id: String
version: String
status: String
tenant_id: String
metadata: Struct
created_at: String
updated_at: String
type Textbook {
id: ID!
title: String!
subjectId: String!
gradeId: String!
version: String!
status: String!
tenantId: String
createdAt: DateTime!
updatedAt: DateTime!
}
type Chapter @key(fields: "id") {
id: String
textbook_id: String
title: String
order: Int
parent_id: String
status: String
created_at: String
updated_at: String
"""
A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
"""
scalar DateTime
type Chapter {
id: ID!
textbookId: String!
title: String!
order: Float!
parentId: String
status: String!
createdAt: DateTime!
updatedAt: DateTime!
}
type KnowledgePoint @key(fields: "id") {
id: String
chapter_id: String
title: String
type KnowledgePoint {
id: ID!
chapterId: String!
title: String!
description: String
difficulty: Int
metadata: Struct
created_at: String
updated_at: String
difficulty: Float!
createdAt: DateTime!
updatedAt: DateTime!
}
type Question @key(fields: "id") {
id: String
knowledge_point_id: String
type: String
content: String
options: Struct
answer: String
type Question {
id: ID!
knowledgePointId: String!
type: String!
content: String!
answer: String!
explanation: String
difficulty: Int
status: String
source: String
created_by: String
metadata: Struct
created_at: String
updated_at: String
}
type GetTextbookRequest @key(fields: "id") {
id: String
}
type UpdateTextbookRequest @key(fields: "id") {
id: String
title: String
status: String
metadata: Struct
}
type DeleteTextbookRequest @key(fields: "id") {
id: String
}
type GetChapterRequest @key(fields: "id") {
id: String
}
type UpdateChapterRequest @key(fields: "id") {
id: String
title: String
order: Int
status: String
}
type DeleteChapterRequest @key(fields: "id") {
id: String
}
type GetQuestionRequest @key(fields: "id") {
id: String
}
type UpdateQuestionRequest @key(fields: "id") {
id: String
content: String
answer: String
status: String
options: Struct
explanation: String
difficulty: Int
}
type DeleteQuestionRequest @key(fields: "id") {
id: String
}
type PublishQuestionRequest @key(fields: "id") {
id: String
}
type ElectiveCourse @key(fields: "id") {
id: String
title: String
subject_id: String
teacher_id: String
capacity: Int
enrolled_count: Int
status: String
description: String
created_at: String
updated_at: String
}
type ElectiveSelection @key(fields: "id") {
id: String
student_id: String
course_id: String
status: String
selected_at: String
dropped_at: String
}
type LessonPlan @key(fields: "id") {
id: String
teacher_id: String
class_id: String
subject_id: String
title: String
content: String
status: String
metadata: Struct
created_at: String
updated_at: String
}
type GetLessonPlanRequest @key(fields: "id") {
id: String
}
type CoursePlan @key(fields: "id") {
id: String
student_id: String
class_id: String
subject_id: String
title: String
plan_type: String
content: String
status: String
metadata: Struct
created_at: String
updated_at: String
}
type GetCoursePlanRequest @key(fields: "id") {
id: String
}
type Empty {
}
input CreateTextbookRequestInput {
title: String
subject_id: String
grade_id: String
version: String
metadata: Struct
}
input ListTextbooksRequestInput {
subject_id: String
grade_id: String
page_token: String
page_size: Int
}
input ListTextbooksResponseInput {
textbooks: Textbook
next_page_token: String
}
input CreateChapterRequestInput {
textbook_id: String
title: String
order: Int
parent_id: String
}
input ListChaptersRequestInput {
textbook_id: String
parent_id: String
}
input ListChaptersResponseInput {
chapters: Chapter
}
input GetPrerequisitesRequestInput {
knowledge_point_id: String
depth: Int
}
input KnowledgePointsResponseInput {
points: KnowledgePoint
}
input GetLearningPathRequestInput {
student_id: String
subject_id: String
}
type LearningPath {
points: KnowledgePoint
recommended_order: String
}
input AddPrerequisiteRequestInput {
kp_id: String
prerequisite_id: String
}
input RemovePrerequisiteRequestInput {
kp_id: String
prerequisite_id: String
}
input CreateQuestionRequestInput {
knowledge_point_id: String
type: String
content: String
options: Struct
answer: String
explanation: String
difficulty: Int
source: String
created_by: String
metadata: Struct
}
input BatchCreateQuestionsRequestInput {
questions: CreateQuestionRequest
}
input BatchCreateQuestionsResponseInput {
ids: String
failed: BatchCreateFailure
}
type BatchCreateFailure {
index: Int
error: String
}
input ListQuestionsRequestInput {
knowledge_point_id: String
type: String
difficulty: Int
status: String
page_token: String
page_size: Int
}
input ListQuestionsResponseInput {
questions: Question
next_page_token: String
}
input SearchQuestionsRequestInput {
q: String
type: String
difficulty: Int
knowledge_point_id: String
page_token: String
page_size: Int
}
input SearchQuestionsResponseInput {
questions: Question
total: Int
next_page_token: String
}
input GetKnowledgePathRequestInput {
class_id: String
subject_id: String
}
input ListAvailableElectiveCoursesRequestInput {
subject_id: String
page_size: Int
page_token: String
}
input ListElectiveCoursesResponseInput {
courses: ElectiveCourse
next_page_token: String
}
input ListElectiveSelectionsByStudentRequestInput {
student_id: String
status: String
}
input ListElectiveSelectionsResponseInput {
selections: ElectiveSelection
}
input SelectCourseRequestInput {
student_id: String
course_id: String
}
input DropCourseRequestInput {
student_id: String
course_id: String
}
input ListLessonPlansByTeacherRequestInput {
teacher_id: String
class_id: String
subject_id: String
}
input ListLessonPlansByStudentRequestInput {
student_id: String
class_id: String
}
input ListLessonPlansResponseInput {
lesson_plans: LessonPlan
}
input ListCoursePlansByStudentRequestInput {
student_id: String
class_id: String
plan_type: String
}
input ListCoursePlansResponseInput {
course_plans: CoursePlan
}
extend type Query {
textbook: Textbook
}
extend type Query {
textbooks: [ListTextbooksResponse!]!
}
extend type Query {
chapter: Chapter
}
extend type Query {
chapters: [ListChaptersResponse!]!
}
extend type Query {
prerequisites: KnowledgePointsResponse
}
extend type Query {
learningPath: LearningPath
}
extend type Query {
knowledgePath: LearningPath
}
extend type Query {
question: Question
}
extend type Query {
questions: [ListQuestionsResponse!]!
}
extend type Query {
availableElectiveCourses: [ListElectiveCoursesResponse!]!
}
extend type Query {
electiveSelectionsByStudent: [ListElectiveSelectionsResponse!]!
}
extend type Query {
lessonPlansByTeacher: [ListLessonPlansResponse!]!
}
extend type Query {
lessonPlansByStudent: [ListLessonPlansResponse!]!
}
extend type Query {
lessonPlan: LessonPlan
}
extend type Query {
coursePlansByStudent: [ListCoursePlansResponse!]!
}
extend type Query {
coursePlan: CoursePlan
}
difficulty: Float!
status: String!
source: String!
createdBy: String!
createdAt: DateTime!
updatedAt: DateTime!
}
type Query {
textbook(id: ID!): Textbook
chapter(id: ID!): Chapter
knowledgePoint(id: ID!): KnowledgePoint
question(id: ID!): Question
}

View File

@@ -10,7 +10,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 { TextbooksModule } from "../textbooks/textbooks.module.js";
@@ -31,7 +31,7 @@ import { DataLoaderService } from "./dataloader.service.js";
KnowledgePointsModule,
QuestionsModule,
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
driver: ApolloFederationDriver,
// Federation 2 子图
autoSchemaFile: {
path: join(process.cwd(), "src/graphql/generated/schema.graphql"),

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 "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { Transport, MicroserviceOptions } from "@nestjs/microservices";