feat(portal-shell): implement portal-shell with apollo-router integration

M8: portal-shell unified frontend shell (Modular Monolith + micro-kernel).

- Apollo Client -> apollo-router (port 4010, RSC prefetch)

- 5 layouts: classic/focus/split/triple/canvas

- Registry + PluginLoader (dynamic import ssr:false)

- 3-layer props merge, Zustand PluginStore

- 4 widgets: grades/notification-bell/user-menu/class-selector

- config-service: new pluginConfig GraphQL resolver

- apollo-router: CORS + header propagation for portal-shell

- docker-compose.yml: portal-shell service block
This commit is contained in:
SpecialX
2026-07-15 08:06:09 +08:00
parent 47e950c664
commit 514e26ebb4
49 changed files with 2802 additions and 6 deletions

View File

@@ -20,6 +20,7 @@ import { ConfigModule } from "../config-config/config.module.js";
import { PluginResolver } from "./resolvers/plugin.resolver.js";
import { LayoutTemplateResolver } from "./resolvers/layout-template.resolver.js";
import { UserLayoutResolver } from "./resolvers/user-layout.resolver.js";
import { PluginConfigResolver } from "./resolvers/plugin-config.resolver.js";
import { DataLoaderService } from "./dataloader.service.js";
@Module({
@@ -50,6 +51,7 @@ import { DataLoaderService } from "./dataloader.service.js";
PluginResolver,
LayoutTemplateResolver,
UserLayoutResolver,
PluginConfigResolver,
DataLoaderService,
],
exports: [DataLoaderService],

View File

@@ -0,0 +1,138 @@
/**
* PluginConfig Resolverv2.1 M8
*
* Apollo Federation 子图统一查询入口portal-shell 通过 apollo-router
* 查询 `pluginConfig(userId, role)` 获取三层合并后的 PluginConfigResponse。
*
* 设计意图portal-shell spec §5.5 / §6.2
* - portal-shell 是 Next.js 前端,不直接调 gRPC统一走 apollo-router GraphQL
* - 本 Resolver 仅做 GraphQL 出参适配,业务逻辑复用 ConfigService.getPluginConfig
* - role 由调用方portal-shell RSC 从 x-user-role 头)传入,避免依赖 Router 头透传
*
* 关联ADR-026 双入口策略、M8 验收点portal-shell 查询走 apollo-router
*/
import {
Resolver,
Query,
Args,
ID,
ObjectType,
Field,
Int,
} from "@nestjs/graphql";
import { ConfigService } from "../../config-config/config.service.js";
@ObjectType()
export class PluginConfigLayoutGql {
@Field(() => ID)
layoutId!: string;
@Field()
displayName!: string;
@Field()
description!: string;
@Field(() => [String])
availableSlots!: string[];
@Field()
layoutSchemaJson!: string;
}
@ObjectType()
export class PluginConfigSlotGql {
@Field()
slotName!: string;
@Field(() => [String])
navItems!: string[];
}
@ObjectType()
export class PluginConfigPlacementGql {
@Field(() => ID)
pluginId!: string;
@Field()
slot!: string;
@Field(() => Int)
sortOrder!: number;
@Field()
sizeJson!: string;
@Field()
propsJson!: string;
@Field()
isVisible!: boolean;
}
@ObjectType()
export class PluginConfigRegistryItemGql {
@Field(() => ID)
pluginId!: string;
@Field()
category!: string;
@Field()
version!: string;
@Field()
displayName!: string;
@Field()
description!: string;
@Field(() => [String])
requiredRoles!: string[];
@Field()
isBuiltin!: boolean;
@Field()
isActive!: boolean;
}
@ObjectType()
export class PluginConfigResponseGql {
@Field(() => PluginConfigLayoutGql, { nullable: true })
activeLayout!: PluginConfigLayoutGql | null;
@Field(() => [PluginConfigSlotGql])
slots!: PluginConfigSlotGql[];
@Field(() => [PluginConfigPlacementGql])
plugins!: PluginConfigPlacementGql[];
@Field(() => [PluginConfigRegistryItemGql])
registry!: PluginConfigRegistryItemGql[];
}
@Resolver()
export class PluginConfigResolver {
constructor(private readonly service: ConfigService) {}
/**
* 获取用户合并后的插件配置(三层合并)。
*
* portal-shell RSC 调用:传入 userId来自 x-user-id 头)与 role
* (来自 x-user-role 头),返回与 gRPC GetPluginConfig 等价的结果。
*/
@Query(() => PluginConfigResponseGql)
async pluginConfig(
@Args("userId", { type: () => ID }) userId: string,
@Args("role", {
type: () => String,
nullable: true,
defaultValue: "student",
})
role: string,
): Promise<PluginConfigResponseGql> {
const result = await this.service.getPluginConfig(userId, role);
return result as PluginConfigResponseGql;
}
}