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:
75
apps/portal-shell/src/lib/apollo-client.ts
Normal file
75
apps/portal-shell/src/lib/apollo-client.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Apollo Client(v2.1 M8 验收点)
|
||||
*
|
||||
* 所有 portal-shell 查询走 apollo-router(GraphQL 联邦入口):
|
||||
* portal-shell → apollo-router :3000/graphql → 各子图(iam/core-edu/content/msg/data-ana/ai/config-service)
|
||||
*
|
||||
* 双端使用:
|
||||
* - 服务端(RSC):createApolloClient() 每次请求新建实例,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;
|
||||
}
|
||||
117
apps/portal-shell/src/lib/config-fetcher.ts
Normal file
117
apps/portal-shell/src/lib/config-fetcher.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 配置获取(服务端 RSC 预取)
|
||||
*
|
||||
* 通过 apollo-router 查询 config-service 子图的 pluginConfig(userId, role),
|
||||
* 返回三层合并后的 PluginConfigResponse(M8 验收点:查询走 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: [],
|
||||
};
|
||||
}
|
||||
131
apps/portal-shell/src/lib/types.ts
Normal file
131
apps/portal-shell/src/lib/types.ts
Normal 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 schema(admin 配置面板用) */
|
||||
propsSchema?: JsonSchema;
|
||||
/** 系统默认 props */
|
||||
defaultProps?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
86
apps/portal-shell/src/lib/usePluginConfig.ts
Normal file
86
apps/portal-shell/src/lib/usePluginConfig.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 插件配置 SWR 静默刷新(v2.1 M8)
|
||||
*
|
||||
* 抛弃 Kafka + WebSocket 推送链路,改用 SWR 静默后台刷新配置:
|
||||
* - revalidateOnFocus:用户切回 Tab 时静默刷新
|
||||
* - refreshInterval:5 分钟轮询
|
||||
* - 检测到配置变化时回调通知上层 Toast 提示
|
||||
*
|
||||
* 刷新请求仍走 apollo-router(M8:portal-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]);
|
||||
}
|
||||
29
apps/portal-shell/src/lib/useWidgetMutation.ts
Normal file
29
apps/portal-shell/src/lib/useWidgetMutation.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 统一 GraphQL 变更 Hook(v2.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 };
|
||||
}
|
||||
51
apps/portal-shell/src/lib/useWidgetQuery.ts
Normal file
51
apps/portal-shell/src/lib/useWidgetQuery.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 统一 GraphQL 查询 Hook(v2.1 M8)
|
||||
*
|
||||
* 所有 widget 插件通过此 Hook 查询数据,查询经 Apollo Client → apollo-router → 子图。
|
||||
* 这是 M8 验收点的客户端侧:portal-shell 查询走 apollo-router。
|
||||
*
|
||||
* 特性:
|
||||
* - 自动注入 Apollo Client(来自 ApolloProvider)
|
||||
* - 支持 fallbackData(RSC 预取的 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user