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

@@ -0,0 +1,117 @@
/**
* 配置获取(服务端 RSC 预取)
*
* 通过 apollo-router 查询 config-service 子图的 pluginConfig(userId, role)
* 返回三层合并后的 PluginConfigResponseM8 验收点:查询走 apollo-router
*
* 设计意图portal-shell spec §5.5
* - portal-shell 是 Next.js 前端,不直接调 config-service gRPC
* - 统一走 apollo-router GraphQL由 Router 路由到 config-service 子图
* - RSC 服务端预取消除 CSR 瀑布流Config 随 HTML 直出
*
* 关联portal-shell spec §5.5、§6.2、M8 验收标准
*/
import { gql } from "@apollo/client";
import { createApolloClient } from "./apollo-client";
import type { PluginConfigResponse, Role } from "./types";
/** 查询用户合并后的插件配置(走 apollo-router → config-service 子图) */
export const GET_PLUGIN_CONFIG = gql`
query GetPluginConfig($userId: ID!, $role: String) {
pluginConfig(userId: $userId, role: $role) {
activeLayout {
layoutId
displayName
description
availableSlots
layoutSchemaJson
}
slots {
slotName
navItems
}
plugins {
pluginId
slot
sortOrder
sizeJson
propsJson
isVisible
}
registry {
pluginId
category
version
displayName
description
requiredRoles
isBuiltin
isActive
}
}
}
`;
/**
* 获取用户合并后的插件配置(服务端调用)。
*
* @param userId 用户 ID来自 RSC 的 x-user-id 头)
* @param role 用户角色(来自 RSC 的 x-user-role 头)
* @returns 三层合并后的 PluginConfigResponse查询失败时返回默认 classic 配置
*/
export async function fetchPluginConfig(
userId: string,
role: Role,
): Promise<PluginConfigResponse> {
const client = createApolloClient();
try {
const { data, error } = await client.query<{
pluginConfig: PluginConfigResponse;
}>({
query: GET_PLUGIN_CONFIG,
variables: { userId, role },
});
if (error) {
console.warn(
`[portal-shell] fetchPluginConfig partial error: ${error.message}`,
);
}
if (data?.pluginConfig) {
return data.pluginConfig;
}
return getDefaultConfig();
} catch (err) {
// Router 未就绪时降级为默认配置,保证 Shell 可渲染(开发态友好)
console.warn(
`[portal-shell] fetchPluginConfig failed, falling back to default config: ${
err instanceof Error ? err.message : String(err)
}`,
);
return getDefaultConfig();
}
}
/**
* 默认 classic 布局配置Router 未就绪 / 查询失败时降级)。
* 保证 Shell 始终可渲染,不因下游不可用而白屏。
*/
export function getDefaultConfig(): PluginConfigResponse {
return {
activeLayout: {
layoutId: "classic",
displayName: "经典三栏",
description: "TopBar + SideNav + Main",
availableSlots: ["top", "side", "main"],
layoutSchemaJson: JSON.stringify({
grid: { rows: 1, cols: 1, areas: [["main"]] },
}),
},
slots: [
{ slotName: "top", navItems: [] },
{ slotName: "side", navItems: [] },
{ slotName: "main", navItems: [] },
],
plugins: [],
registry: [],
};
}