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,75 @@
/**
* Apollo Clientv2.1 M8 验收点)
*
* 所有 portal-shell 查询走 apollo-routerGraphQL 联邦入口):
* portal-shell → apollo-router :3000/graphql → 各子图iam/core-edu/content/msg/data-ana/ai/config-service
*
* 双端使用:
* - 服务端RSCcreateApolloClient() 每次请求新建实例ssrMode=true
* - 客户端getApolloClient() 单例,复用 InMemoryCache
*
* 关联portal-shell spec §5.5 RSC 预取、§5.6 统一 Hook、M8 验收标准
*/
import { ApolloClient, InMemoryCache, HttpLink, from } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
const APOLLO_ROUTER_URL =
process.env.NEXT_PUBLIC_APOLLO_ROUTER_URL ||
process.env.APOLLO_ROUTER_URL ||
"http://localhost:3000/graphql";
/**
* 创建 Apollo Client 实例。
*
* @param getAuthToken 可选,返回 JWT 用于注入 Authorization 头(客户端从 cookie/localStorage 读取)
*/
export function createApolloClient(
getAuthToken?: () => string | null,
): ApolloClient<unknown> {
const httpLink = new HttpLink({
uri: APOLLO_ROUTER_URL,
credentials: "include",
});
const authLink = setContext((_, { headers }) => {
const token = getAuthToken?.() ?? null;
return {
headers: {
...headers,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
};
});
return new ApolloClient({
link: from([authLink, httpLink]),
cache: new InMemoryCache(),
ssrMode: typeof window === "undefined",
defaultOptions: {
query: {
errorPolicy: "all",
fetchPolicy: typeof window === "undefined" ? "no-cache" : "cache-first",
},
watchQuery: {
errorPolicy: "all",
},
},
});
}
let clientSingleton: ApolloClient<unknown> | null = null;
/**
* 获取客户端 Apollo Client 单例(浏览器侧复用缓存)。
*/
export function getApolloClient(
getAuthToken?: () => string | null,
): ApolloClient<unknown> {
if (typeof window === "undefined") {
return createApolloClient(getAuthToken);
}
if (!clientSingleton) {
clientSingleton = createApolloClient(getAuthToken);
}
return clientSingleton;
}

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: [],
};
}

View File

@@ -0,0 +1,131 @@
/**
* portal-shell 共享类型v2.1 M8
*
* 对应 config-service PluginConfigResponse三层合并后的插件配置
* 类型与 services/config-service/src/config-config/config.service.ts 的接口对齐,
* 通过 apollo-router GraphQL 查询 pluginConfig(userId, role) 获取。
*
* 关联portal-shell spec §5.1 PluginProps 契约、§6.2 PluginConfigResponse
*/
/** 用户角色 */
export type Role = "admin" | "teacher" | "student" | "parent";
/** Layout 模板信息(对应 LayoutTemplateInfo */
export interface LayoutTemplateInfo {
layoutId: string;
displayName: string;
description: string;
availableSlots: string[];
layoutSchemaJson: string;
}
/** Slot 配置(对应 SlotConfig */
export interface SlotConfig {
slotName: string;
navItems: string[];
}
/** 插件放置(对应 PluginPlacement三层合并后 */
export interface PluginPlacement {
pluginId: string;
slot: string;
sortOrder: number;
sizeJson: string;
propsJson: string;
isVisible: boolean;
}
/** 插件注册项(对应 PluginRegistryItem */
export interface PluginRegistryItem {
pluginId: string;
category: string;
version: string;
displayName: string;
description: string;
requiredRoles: string[];
isBuiltin: boolean;
isActive: boolean;
}
/** 三层合并后的插件配置响应(对应 PluginConfigResponse */
export interface PluginConfigResponse {
activeLayout: LayoutTemplateInfo | null;
slots: SlotConfig[];
plugins: PluginPlacement[];
registry: PluginRegistryItem[];
}
/** 插件尺寸colSpan / rowSpan */
export interface PluginSize {
colSpan: number;
rowSpan: number;
}
/** 插件分类 */
export type PluginCategory =
| "universal"
| "sidebar"
| "topbar"
| "teacher"
| "student"
| "parent"
| "admin";
/** 插件 Props 契约spec §5.1 */
export interface PluginProps<TProps = Record<string, unknown>> {
/** 插件实例 ID同一插件多实例时区分 */
instanceId: string;
/** 当前用户角色 */
role: Role;
/** 当前用户信息 */
user: {
id: string;
name: string;
email: string;
dataScope: string;
};
/** 当前 slot 信息 */
slot: {
name: string;
layoutId: string;
size?: PluginSize;
};
/** 插件自定义 props三层合并后的最终值 */
props: TProps;
/** 服务端预取的初始数据RSC 直出) */
initialData?: unknown;
}
/** 简易 JSON Schema 类型(用于 propsSchema 声明) */
export interface JsonSchema {
type?: string;
properties?: Record<string, JsonSchema>;
items?: JsonSchema;
description?: string;
default?: unknown;
[key: string]: unknown;
}
/** 插件清单spec §5.1 PluginManifest */
export interface PluginManifest {
pluginId: string;
version: string;
/** 兼容的 Shell 版本范围semver range */
requiredShellVersion: string;
/** React 组件(默认导出) */
Component: React.ComponentType<PluginProps>;
/** 插件元数据 */
metadata: {
displayName: string;
description: string;
category: PluginCategory;
requiredRoles: Role[];
defaultSlot: string;
defaultSize: PluginSize;
/** 插件可配置的 props schemaadmin 配置面板用) */
propsSchema?: JsonSchema;
/** 系统默认 props */
defaultProps?: Record<string, unknown>;
};
}

View File

@@ -0,0 +1,86 @@
"use client";
/**
* 插件配置 SWR 静默刷新v2.1 M8
*
* 抛弃 Kafka + WebSocket 推送链路,改用 SWR 静默后台刷新配置:
* - revalidateOnFocus用户切回 Tab 时静默刷新
* - refreshInterval5 分钟轮询
* - 检测到配置变化时回调通知上层 Toast 提示
*
* 刷新请求仍走 apollo-routerM8portal-shell 查询走 Router
*
* 关联portal-shell spec §6.4、M8 验收标准
*/
import useSWR from "swr";
import { getApolloClient } from "./apollo-client";
import { GET_PLUGIN_CONFIG } from "./config-fetcher";
import type { PluginConfigResponse, Role } from "./types";
export interface UsePluginConfigOptions {
/** RSC 直出的初始配置fallbackData */
initialConfig: PluginConfigResponse;
/** 当前用户 ID */
userId: string;
/** 当前用户角色 */
role: Role;
/** 配置变化回调(上层用于 Toast 提示) */
onChanged?: () => void;
}
/**
* 静默刷新插件配置。
* fetcher 经 Apollo Client 查询 apollo-router 的 pluginConfig(userId, role)。
*/
export function usePluginConfig(options: UsePluginConfigOptions): {
config: PluginConfigResponse;
refresh: () => void;
} {
const { initialConfig, userId, role, onChanged } = options;
const { data, mutate } = useSWR<PluginConfigResponse>(
["plugin-config", userId, role],
async () => {
const client = getApolloClient();
const { data } = await client.query<{
pluginConfig: PluginConfigResponse;
}>({
query: GET_PLUGIN_CONFIG,
variables: { userId, role },
fetchPolicy: "network-only",
});
return data.pluginConfig;
},
{
fallbackData: initialConfig,
revalidateOnFocus: true,
revalidateOnReconnect: true,
refreshInterval: 300_000,
dedupingInterval: 60_000,
onSuccess: (newData) => {
if (hasConfigChanged(initialConfig, newData)) {
onChanged?.();
}
},
},
);
return { config: data ?? initialConfig, refresh: () => void mutate() };
}
/** 浅比较配置是否变化layoutId / 插件集合 / 可见性) */
function hasConfigChanged(
prev: PluginConfigResponse,
next: PluginConfigResponse | undefined,
): boolean {
if (!next) return false;
if (prev.activeLayout?.layoutId !== next.activeLayout?.layoutId) return true;
if (prev.plugins.length !== next.plugins.length) return true;
const prevIds = prev.plugins
.map((p) => `${p.pluginId}:${p.isVisible}`)
.sort();
const nextIds = next.plugins
.map((p) => `${p.pluginId}:${p.isVisible}`)
.sort();
return prevIds.some((id, i) => id !== nextIds[i]);
}

View File

@@ -0,0 +1,29 @@
"use client";
/**
* 统一 GraphQL 变更 Hookv2.1 M8
*
* widget 插件通过此 Hook 发起变更,经 Apollo Client → apollo-router → 子图。
*
* 关联portal-shell spec §5.6、M8 验收标准
*/
import {
useMutation,
type DocumentNode,
type TypedDocumentNode,
} from "@apollo/client";
export function useWidgetMutation<TData, TVars extends Record<string, unknown>>(
mutation: DocumentNode | TypedDocumentNode<TData, TVars>,
) {
const [mutate, result] = useMutation<TData, TVars>(mutation, {
errorPolicy: "all",
});
const run = async (variables: TVars): Promise<TData | undefined> => {
const res = await mutate({ variables });
return res.data ?? undefined;
};
return { run, ...result };
}

View File

@@ -0,0 +1,51 @@
"use client";
/**
* 统一 GraphQL 查询 Hookv2.1 M8
*
* 所有 widget 插件通过此 Hook 查询数据,查询经 Apollo Client → apollo-router → 子图。
* 这是 M8 验收点的客户端侧portal-shell 查询走 apollo-router。
*
* 特性:
* - 自动注入 Apollo Client来自 ApolloProvider
* - 支持 fallbackDataRSC 预取的 initialData消除首屏瀑布流
* - 支持 enabled / pollInterval 控制
*
* 关联portal-shell spec §5.6、M8 验收标准
*/
import { useQuery, type DocumentNode, type FetchPolicy } from "@apollo/client";
import type { TypedDocumentNode } from "@apollo/client";
import { useMemo } from "react";
export interface UseWidgetQueryOptions<TData> {
/** RSC 预取的初始数据(首次渲染无需客户端请求) */
fallbackData?: TData;
/** 轮询间隔ms */
pollInterval?: number;
/** 是否启用查询false 时跳过) */
enabled?: boolean;
/** Apollo fetchPolicy */
fetchPolicy?: FetchPolicy;
}
export function useWidgetQuery<TData, TVars extends Record<string, unknown>>(
query: DocumentNode | TypedDocumentNode<TData, TVars>,
variables: TVars,
options?: UseWidgetQueryOptions<TData>,
) {
const { data, loading, error, refetch } = useQuery<TData, TVars>(query, {
variables,
skip: options?.enabled === false,
fetchPolicy: options?.fetchPolicy ?? "cache-first",
pollInterval: options?.pollInterval,
errorPolicy: "all",
});
// RSC 预取数据作为首次渲染兜底,消除客户端瀑布流
const mergedData = useMemo(
() => data ?? options?.fallbackData,
[data, options?.fallbackData],
);
return { data: mergedData, loading, error, refetch };
}