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:
107
apps/portal-shell/src/shell/SlotRenderer.tsx
Normal file
107
apps/portal-shell/src/shell/SlotRenderer.tsx
Normal 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) {
|
||||
// 空 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user