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:
74
services/config-service/src/graphql/generated/schema.graphql
Normal file
74
services/config-service/src/graphql/generated/schema.graphql
Normal 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!
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "@edu/shared-ts/env-loader";
|
||||
import "reflect-metadata";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { Transport, MicroserviceOptions } from "@nestjs/microservices";
|
||||
|
||||
@@ -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 的默认错误格式化器处理响应
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user