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
108 lines
3.0 KiB
TypeScript
108 lines
3.0 KiB
TypeScript
"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) {
|
||
// 空 slot:main 区显示占位,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>
|
||
);
|
||
}
|