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

@@ -0,0 +1,74 @@
# ------------------------------------------------------
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
# ------------------------------------------------------
type PluginRegistry {
pluginId: ID!
category: String!
version: String!
displayName: String!
description: String!
isBuiltin: Boolean!
isActive: Boolean!
}
type LayoutTemplateGql {
layoutId: ID!
displayName: String!
description: String
isActive: Boolean!
}
type UserLayoutOverrideGql {
userId: ID!
activeLayout: String!
updatedAt: String!
}
type PluginConfigLayoutGql {
layoutId: ID!
displayName: String!
description: String!
availableSlots: [String!]!
layoutSchemaJson: String!
}
type PluginConfigSlotGql {
slotName: String!
navItems: [String!]!
}
type PluginConfigPlacementGql {
pluginId: ID!
slot: String!
sortOrder: Int!
sizeJson: String!
propsJson: String!
isVisible: Boolean!
}
type PluginConfigRegistryItemGql {
pluginId: ID!
category: String!
version: String!
displayName: String!
description: String!
requiredRoles: [String!]!
isBuiltin: Boolean!
isActive: Boolean!
}
type PluginConfigResponseGql {
activeLayout: PluginConfigLayoutGql
slots: [PluginConfigSlotGql!]!
plugins: [PluginConfigPlacementGql!]!
registry: [PluginConfigRegistryItemGql!]!
}
type Query {
plugin(pluginId: ID!): PluginRegistry
plugins: [PluginRegistry!]!
layoutTemplates: [LayoutTemplateGql!]!
userLayoutOverride(userId: ID!): UserLayoutOverrideGql
pluginConfig(userId: ID!, role: String = "student"): PluginConfigResponseGql!
}

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 { ConfigModule } from "../config-config/config.module.js";
@@ -27,7 +27,7 @@ import { DataLoaderService } from "./dataloader.service.js";
imports: [
ConfigModule,
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";

View File

@@ -14,6 +14,14 @@ export class GlobalErrorFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalErrorFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
// GraphQL 上下文GqlContextType无 HTTP req/res需单独处理。
// NestJS ContextType 类型未包含 'graphql'(由 @nestjs/graphql 扩展),
// 需断言为 string 比较。
if ((host.getType() as string) === "graphql") {
this.handleGraphQlError(exception);
return;
}
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
@@ -83,4 +91,25 @@ export class GlobalErrorFilter implements ExceptionFilter {
}
return exception.message;
}
/**
* GraphQL 错误处理NestJS Apollo 会自动把 resolver 抛出的异常包装为
* GraphQL error 响应,无需 filter 手动写 response。这里仅记录日志
* 原始异常会继续传播给 Apollo Server 的默认格式化器。
*/
private handleGraphQlError(exception: unknown): void {
if (exception instanceof ApplicationError) {
this.logger.warn(`[GraphQL] ${exception.code}: ${exception.message}`);
} else if (exception instanceof ZodError) {
this.logger.warn(`[GraphQL] Validation error: ${exception.message}`);
} else if (exception instanceof Error) {
this.logger.error(
`[GraphQL] Unhandled: ${exception.message}`,
exception.stack,
);
} else {
this.logger.error(`[GraphQL] Unknown exception: ${String(exception)}`);
}
// 不抛出,让 Apollo Server 的默认错误格式化器处理响应
}
}