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,107 @@
"use client";
/**
* SlotRenderer - 按 Config 渲染插件列表v2.1 M8
*
* 给定一个 slot 名称,从配置中过滤出该 slot 的可见插件,按 sortOrder 排序,
* 查 Registry 取组件,注入 PluginProps经 PluginLoader 挂载(含 ErrorBoundary
*
* 关联portal-shell spec §2.2、§5.1
*/
import { useMemo, type ReactNode } from "react";
import { REGISTRY, isPluginRegistered } from "./Registry";
import { PluginLoader, PluginSkeleton } from "./PluginLoader";
import { parsePropsJson, parseSizeJson } from "./PropsMerger";
import type { PluginPlacement, PluginProps, Role } from "@/lib/types";
import type { AuthUser } from "@/providers/AuthProvider";
export interface SlotRendererProps {
slotName: string;
layoutId: string;
plugins: PluginPlacement[];
user: AuthUser;
role: Role;
userId: string;
}
export function SlotRenderer({
slotName,
layoutId,
plugins,
user,
role,
userId,
}: SlotRendererProps): ReactNode {
const visible = useMemo(
() =>
plugins
.filter((p) => p.slot === slotName && p.isVisible)
.sort((a, b) => a.sortOrder - b.sortOrder),
[plugins, slotName],
);
if (visible.length === 0) {
// 空 slotmain 区显示占位topbar/side 区不渲染
if (slotName === "top" || slotName === "side") return null;
return (
<div className="rounded-card border border-rule bg-surface p-md text-ink-muted text-small">
</div>
);
}
return (
<div className="space-y-md">
{visible.map((placement) => {
if (!isPluginRegistered(placement.pluginId)) {
return (
<div
key={placement.pluginId}
className="rounded-card border border-rule bg-surface p-md text-ink-muted text-small"
>
{placement.pluginId}
</div>
);
}
const manifest = REGISTRY[placement.pluginId];
if (!manifest) {
return null;
}
const pluginProps: PluginProps = {
instanceId: `${placement.pluginId}-${slotName}-${placement.sortOrder}`,
role,
user: {
id: userId,
name: user.name,
email: user.email,
dataScope: user.dataScope,
},
slot: {
name: slotName,
layoutId,
size: parseSizeJson(placement.sizeJson),
},
props: parsePropsJson(placement.propsJson),
};
return (
<PluginLoader
key={pluginProps.instanceId}
Component={manifest.Component}
pluginProps={pluginProps}
/>
);
})}
</div>
);
}
/** Slot 加载态占位layout 切换瞬间) */
export function SlotSkeleton({ count = 1 }: { count?: number }): ReactNode {
return (
<div className="space-y-md">
{Array.from({ length: count }).map((_, i) => (
<PluginSkeleton key={i} variant="card" />
))}
</div>
);
}