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,13 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
/**
* Liveness 健康检查project_rules §12
* GET /api/health — 进程存活即返回 200。
*/
export function GET(_request: NextRequest): NextResponse {
return NextResponse.json(
{ status: "ok", service: "portal-shell", timestamp: Date.now() },
{ status: 200 },
);
}

View File

@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
/**
* Readiness 健康检查project_rules §12
* GET /api/ready — 检查下游 apollo-router 是否可达。
*/
export async function GET(_request: NextRequest): Promise<NextResponse> {
const routerUrl =
process.env.NEXT_PUBLIC_APOLLO_ROUTER_URL ||
process.env.APOLLO_ROUTER_URL ||
"http://localhost:3000/graphql";
try {
const res = await fetch(routerUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: "{ __typename }",
}),
signal: AbortSignal.timeout(3000),
});
if (!res.ok) {
return NextResponse.json(
{ status: "degraded", router: routerUrl, code: res.status },
{ status: 503 },
);
}
return NextResponse.json(
{ status: "ready", router: routerUrl, timestamp: Date.now() },
{ status: 200 },
);
} catch (err) {
return NextResponse.json(
{
status: "not-ready",
router: routerUrl,
error: err instanceof Error ? err.message : String(err),
},
{ status: 503 },
);
}
}

View File

@@ -0,0 +1,55 @@
/**
* portal-shell 全局样式
*
* 引入 @edu/ui-tokens 三层设计令牌primitive → semantic → tailwind-theme
*
* 禁止规则ESLint + project_rules §3.10
* - 禁止 #hex 字面量(用 hsl(var(--*)) 或 Tailwind bg-* 类)
* - 禁止字体名字面量(用 var(--font-family-*))
* - 禁止 font-size: Npx用 var(--font-size-*) 或 Tailwind text-* 类)
*/
@import "@edu/ui-tokens/all.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html,
body {
background: var(--bg-paper);
color: var(--color-ink);
font-family: var(--font-family-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-family-serif);
font-weight: var(--font-weight-semibold);
letter-spacing: var(--letter-spacing-tight);
}
}
@layer components {
/* 纸感分隔线 */
.rule {
border-top: 1px solid var(--color-rule);
}
.rule-thin {
border-top: 2px solid var(--color-rule);
}
/* 左侧竖线标记 */
.mark-left {
border-left: 2px solid var(--color-rule);
padding-left: var(--space-md);
}
}

View File

@@ -0,0 +1,55 @@
import "./globals.css";
import type { Metadata } from "next";
import { Inter, Fraunces, JetBrains_Mono } from "next/font/google";
import type { ReactNode } from "react";
/**
* 字体加载next/font/google self-host
*
* 通过 CSS 变量暴露字体族(--font-inter / --font-fraunces / --font-jetbrains-mono
* ui-tokens 的 semantic 层将它们映射为 --font-family-sans/serif/mono。
* 禁止字体名字面量project_rules §3.10)。
*/
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
display: "swap",
});
const fraunces = Fraunces({
subsets: ["latin"],
variable: "--font-fraunces",
display: "swap",
});
const mono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains-mono",
display: "swap",
});
export const metadata: Metadata = {
title: "Edu Portal Shell",
description: "K12 智慧教务平台 - 插件化仪表盘",
};
/**
* RootLayout
*
* 仅负责 <html>/<body> 与字体变量。需要 RSC 数据的 Providers
* Apollo/Auth/ThemeI18n在 ClientShell 中挂载spec §5.5)。
*/
export default function RootLayout({
children,
}: {
children: ReactNode;
}): ReactNode {
return (
<html
lang="zh-CN"
className={`${inter.variable} ${fraunces.variable} ${mono.variable}`}
>
<body>{children}</body>
</html>
);
}

View File

@@ -0,0 +1,8 @@
import { redirect } from "next/navigation";
/**
* 根路径重定向到 /shellportal-shell spec §8.2:路由前缀 /shell/*)。
*/
export default function RootPage(): never {
redirect("/shell");
}

View File

@@ -0,0 +1,29 @@
import { headers } from "next/headers";
import { fetchPluginConfig } from "@/lib/config-fetcher";
import { ClientShell } from "@/shell/ClientShell";
import type { Role } from "@/lib/types";
/**
* Shell 入口RSC Server Componentv2.1 M8 验收点)
*
* 数据流portal-shell spec §5.5
* ① 从请求头获取 userId / roleapi-gateway 注入 x-user-id / x-user-role
* ② 服务端调 apollo-router 查询 config-service 子图的 pluginConfig三层合并
* ③ Config 作为 props 传给 ClientShell随 HTML 直出,消除 CSR 瀑布流
*
* M8 验收portal-shell 查询走 apollo-routerfetchPluginConfig 经 Apollo Client
*
* 关联portal-shell spec §5.5、§6.2、M8 验收标准
*/
export default async function ShellPage(): Promise<React.ReactElement> {
const headerList = headers();
const userId =
headerList.get("x-user-id") ||
(process.env.NEXT_PUBLIC_DEV_MODE === "true" ? "dev-user" : "anonymous");
const role = (headerList.get("x-user-role") || "teacher") as Role;
// 服务端通过 apollo-router 获取三层合并后的插件配置
const config = await fetchPluginConfig(userId, role);
return <ClientShell config={config} role={role} userId={userId} />;
}

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 };
}

View File

@@ -0,0 +1,38 @@
"use client";
/**
* ApolloProviderv2.1 M8
*
* 注入 Apollo Client 单例,所有 widget 的 useWidgetQuery 经此 Client
* 查询 apollo-routerM8 验收点portal-shell 查询走 Router
*
* Token 注入:从 localStorage 读取 JWT对齐 teacher-portal F12 约定),
* cookie 凭证通过 credentials:"include" 一并发送。
*
* 关联portal-shell spec §5.6、M8 验收标准
*/
import { useMemo, type ReactNode } from "react";
import { ApolloProvider as ApolloGraphQLProvider } from "@apollo/client";
import { getApolloClient } from "@/lib/apollo-client";
const TOKEN_KEY = "edu_token";
function readToken(): string | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage.getItem(TOKEN_KEY);
} catch {
return null;
}
}
export function ApolloProvider({
children,
}: {
children: ReactNode;
}): ReactNode {
const client = useMemo(() => getApolloClient(readToken), []);
return (
<ApolloGraphQLProvider client={client}>{children}</ApolloGraphQLProvider>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
/**
* AuthProviderv2.1 M8
*
* 提供 useAuth(),供 widget 插件读取当前用户角色与信息。
* 用户身份由 RSC 从请求头x-user-id / x-user-role解析后作为 props 注入,
* 客户端 AuthProvider 仅做 context 下发,不重复解析 JWT。
*
* 关联portal-shell spec §5.1 PluginProps.user、§9.1 providers
*/
import { createContext, useContext, type ReactNode } from "react";
import type { Role } from "@/lib/types";
export interface AuthUser {
id: string;
name: string;
email: string;
role: Role;
dataScope: string;
}
interface AuthContextValue {
user: AuthUser;
role: Role;
userId: string;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export interface AuthProviderProps {
user: AuthUser;
children: ReactNode;
}
export function AuthProvider({ user, children }: AuthProviderProps): ReactNode {
const value: AuthContextValue = {
user,
role: user.role,
userId: user.id,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth must be used within AuthProvider");
}
return ctx;
}

View File

@@ -0,0 +1,55 @@
"use client";
/**
* 主题 + i18n Providerv2.1 M8
*
* 通过 PluginStoreZustand管理 themelight/dark与 localezh-CN/en
* 将主题类名同步到 <html>locale 用于基础文案切换。
*
* 关联portal-shell spec §5.2.2 Zustand Store、§9.1 providers
*/
import { useEffect, type ReactNode } from "react";
import { usePluginStore } from "@/shell/PluginStore";
const DEFAULT_MESSAGES = {
"zh-CN": {
"shell.toggleSidebar": "切换侧栏",
"shell.layout": "布局",
"shell.empty": "暂无可见插件",
},
en: {
"shell.toggleSidebar": "Toggle sidebar",
"shell.layout": "Layout",
"shell.empty": "No visible plugins",
},
} as const;
type MessageKey = keyof (typeof DEFAULT_MESSAGES)["zh-CN"];
export function ThemeI18nProvider({
children,
}: {
children: ReactNode;
}): ReactNode {
const theme = usePluginStore((s) => s.theme);
const locale = usePluginStore((s) => s.locale);
useEffect(() => {
if (typeof document === "undefined") return;
const root = document.documentElement;
if (theme === "dark") {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
root.lang = locale;
}, [theme, locale]);
return <>{children}</>;
}
/** 简易 i18n 翻译函数MVP复用 PluginStore 的 locale */
export function useT(): (key: MessageKey) => string {
const locale = usePluginStore((s) => s.locale);
return (key: MessageKey) => DEFAULT_MESSAGES[locale][key];
}

View File

@@ -0,0 +1,85 @@
"use client";
/**
* ClientShell - 客户端入口v2.1 M8
*
* 接收 RSC propsConfig + userId + role挂载 ProvidersApollo/Auth/ThemeI18n
* 启用 SWR 静默刷新配置usePluginConfig渲染 Shell。
*
* 数据流portal-shell spec §5.5
* RSC 预取 Config → ClientShellfallbackData→ SWR 静默刷新 → Shell 重渲染
*
* 关联portal-shell spec §5.5、§6.4、M8 验收标准
*/
import { useState, type ReactNode } from "react";
import { ApolloProvider } from "@/providers/ApolloProvider";
import { AuthProvider, type AuthUser } from "@/providers/AuthProvider";
import { ThemeI18nProvider } from "@/providers/ThemeI18nProvider";
import { Shell } from "./Shell";
import { usePluginConfig } from "@/lib/usePluginConfig";
import type { PluginConfigResponse, Role } from "@/lib/types";
export interface ClientShellProps {
config: PluginConfigResponse;
role: Role;
userId: string;
/** 可选:服务端解析的用户名/邮箱user-menu 插件会自行查询 me */
userName?: string;
userEmail?: string;
}
export function ClientShell({
config,
role,
userId,
userName,
userEmail,
}: ClientShellProps): ReactNode {
const [configChanged, setConfigChanged] = useState(false);
const user: AuthUser = {
id: userId,
name: userName ?? userId,
email: userEmail ?? "",
role,
dataScope: "",
};
const { config: liveConfig } = usePluginConfig({
initialConfig: config,
userId,
role,
onChanged: () => setConfigChanged(true),
});
return (
<ApolloProvider>
<AuthProvider user={user}>
<ThemeI18nProvider>
<Shell config={liveConfig} user={user} role={role} userId={userId} />
{configChanged ? (
<div className="fixed bottom-xl right-xl z-50 rounded-card border border-rule bg-surface p-md shadow-md">
<p className="text-small text-ink">
</p>
<button
type="button"
onClick={() => window.location.reload()}
className="mt-sm rounded-button bg-accent px-md py-xs text-small text-ink-onAccent"
>
</button>
<button
type="button"
onClick={() => setConfigChanged(false)}
className="mt-sm ml-sm rounded-button border border-rule px-md py-xs text-small text-ink"
>
</button>
</div>
) : null}
</ThemeI18nProvider>
</AuthProvider>
</ApolloProvider>
);
}

View File

@@ -0,0 +1,145 @@
"use client";
/**
* LayoutManager - 5 种 Layout 模板渲染器v2.1 M8
*
* | layoutId | 布局 | slots |
* | -------- | ------------------------- | ------------------------ |
* | classic | TopBar + SideNav + Main | top / side / main |
* | focus | TopBar + 全宽 Main | top / main |
* | split | TopBar + 左右等分 Main | top / main-left / main-right |
* | triple | TopBar + SideNav + Main + RightRail | top / side / main / right |
* | canvas | TopBar + 自由摆放 | top / canvas-grid |
*
* 关联portal-shell spec §4.1、§4.2
*/
import { type ReactNode } from "react";
import { SlotRenderer, type SlotRendererProps } from "./SlotRenderer";
type SlotRendererInput = Omit<SlotRendererProps, "slotName" | "layoutId">;
export interface LayoutManagerProps extends SlotRendererInput {
layoutId: string;
}
export function LayoutManager(props: LayoutManagerProps): ReactNode {
const { layoutId, ...slotInput } = props;
switch (layoutId) {
case "focus":
return <FocusLayout slotInput={slotInput} layoutId={layoutId} />;
case "split":
return <SplitLayout slotInput={slotInput} layoutId={layoutId} />;
case "triple":
return <TripleLayout slotInput={slotInput} layoutId={layoutId} />;
case "canvas":
return <CanvasLayout slotInput={slotInput} layoutId={layoutId} />;
case "classic":
default:
return <ClassicLayout slotInput={slotInput} layoutId={layoutId} />;
}
}
interface LayoutShellProps {
slotInput: SlotRendererInput;
layoutId: string;
}
/** classicTopBar + SideNav + Main */
function ClassicLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
return (
<div className="flex min-h-screen flex-col bg-paper">
<header className="border-b border-rule bg-surface">
<SlotRenderer slotName="top" layoutId={layoutId} {...slotInput} />
</header>
<div className="flex flex-1">
<aside className="w-64 border-r border-rule bg-surface p-md">
<SlotRenderer slotName="side" layoutId={layoutId} {...slotInput} />
</aside>
<main className="flex-1 p-lg">
<SlotRenderer slotName="main" layoutId={layoutId} {...slotInput} />
</main>
</div>
</div>
);
}
/** focusTopBar + 全宽 Main */
function FocusLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
return (
<div className="flex min-h-screen flex-col bg-paper">
<header className="border-b border-rule bg-surface">
<SlotRenderer slotName="top" layoutId={layoutId} {...slotInput} />
</header>
<main className="flex-1 p-lg">
<SlotRenderer slotName="main" layoutId={layoutId} {...slotInput} />
</main>
</div>
);
}
/** splitTopBar + 左右等分 Main */
function SplitLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
return (
<div className="flex min-h-screen flex-col bg-paper">
<header className="border-b border-rule bg-surface">
<SlotRenderer slotName="top" layoutId={layoutId} {...slotInput} />
</header>
<div className="flex flex-1 gap-md p-lg">
<section className="flex-1">
<SlotRenderer
slotName="main-left"
layoutId={layoutId}
{...slotInput}
/>
</section>
<section className="flex-1">
<SlotRenderer
slotName="main-right"
layoutId={layoutId}
{...slotInput}
/>
</section>
</div>
</div>
);
}
/** tripleTopBar + SideNav + Main + RightRail */
function TripleLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
return (
<div className="flex min-h-screen flex-col bg-paper">
<header className="border-b border-rule bg-surface">
<SlotRenderer slotName="top" layoutId={layoutId} {...slotInput} />
</header>
<div className="flex flex-1">
<aside className="w-64 border-r border-rule bg-surface p-md">
<SlotRenderer slotName="side" layoutId={layoutId} {...slotInput} />
</aside>
<main className="flex-1 p-lg">
<SlotRenderer slotName="main" layoutId={layoutId} {...slotInput} />
</main>
<aside className="w-72 border-l border-rule bg-surface p-md">
<SlotRenderer slotName="right" layoutId={layoutId} {...slotInput} />
</aside>
</div>
</div>
);
}
/** canvasTopBar + 自由摆放 gridMVP 按 grid 排列,不实现拖拽) */
function CanvasLayout({ slotInput, layoutId }: LayoutShellProps): ReactNode {
return (
<div className="flex min-h-screen flex-col bg-paper">
<header className="border-b border-rule bg-surface">
<SlotRenderer slotName="top" layoutId={layoutId} {...slotInput} />
</header>
<main className="flex-1 p-lg">
<SlotRenderer
slotName="canvas-grid"
layoutId={layoutId}
{...slotInput}
/>
</main>
</div>
);
}

View File

@@ -0,0 +1,151 @@
"use client";
/**
* PluginLoader - 插件加载器v2.1 M8
*
* 职责:
* - PluginSkeleton5 种 skeleton 变体card/list/chart/stats/table供 dynamic loading 使用
* - PluginErrorFallback插件加载/渲染失败兜底
* - PluginErrorBoundary隔离单个插件错误不影响其他插件
* - PluginLoader包裹插件组件注入 PluginProps挂载 ErrorBoundary
*
* 关联portal-shell spec §5.3、§7.3
*/
import { Component, type ReactNode, type ErrorInfo } from "react";
import type { PluginProps } from "@/lib/types";
type SkeletonVariant = "card" | "list" | "chart" | "stats" | "table";
/** 插件骨架屏(纸感风格,使用设计令牌) */
export function PluginSkeleton({
variant = "card",
}: {
variant?: SkeletonVariant;
}): ReactNode {
if (variant === "table") {
return (
<div
className="rounded-card bg-surface p-md animate-pulse"
role="status"
aria-label="loading"
>
<div className="h-heading-3 bg-subtle rounded-button mb-md w-1/4" />
<div className="space-y-sm">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="h-body bg-subtle rounded-button w-full" />
))}
</div>
</div>
);
}
if (variant === "list") {
return (
<div className="space-y-sm" role="status" aria-label="loading">
{[0, 1, 2].map((i) => (
<div
key={i}
className="h-body bg-subtle rounded-button w-full animate-pulse"
/>
))}
</div>
);
}
// card / stats / chart 默认卡片骨架
return (
<div
className="rounded-card bg-surface p-md animate-pulse"
role="status"
aria-label="loading"
>
<div className="h-heading-3 bg-subtle rounded-button mb-md w-1/3" />
<div className="h-large-number bg-subtle rounded-button w-1/2" />
</div>
);
}
/** 插件错误兜底(居中错误图标 + 重试) */
export function PluginErrorFallback({
instanceId,
onRetry,
}: {
instanceId: string;
onRetry?: () => void;
}): ReactNode {
return (
<div
className="rounded-card border border-rule bg-surface p-md text-ink-muted"
role="alert"
>
<p className="text-small">{instanceId}</p>
{onRetry ? (
<button
type="button"
onClick={onRetry}
className="mt-sm rounded-button bg-accent px-md py-xs text-small text-ink-onAccent"
>
</button>
) : null}
</div>
);
}
interface ErrorBoundaryProps {
instanceId: string;
children: ReactNode;
onRetry?: () => void;
}
interface ErrorBoundaryState {
hasError: boolean;
}
/** 单插件错误隔离边界 */
class PluginErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
override state: ErrorBoundaryState = { hasError: false };
static getDerivedStateFromError(): ErrorBoundaryState {
return { hasError: true };
}
override componentDidCatch(error: Error, info: ErrorInfo): void {
console.error(
`[portal-shell] plugin ${this.props.instanceId} error: ${error.message}`,
info.componentStack,
);
}
handleRetry = (): void => {
this.setState({ hasError: false });
};
override render(): ReactNode {
if (this.state.hasError) {
return (
<PluginErrorFallback
instanceId={this.props.instanceId}
onRetry={this.props.onRetry ?? this.handleRetry}
/>
);
}
return this.props.children;
}
}
/** 插件加载器:包裹组件 + ErrorBoundary */
export function PluginLoader({
Component,
pluginProps,
}: {
Component: React.ComponentType<PluginProps>;
pluginProps: PluginProps;
}): ReactNode {
return (
<PluginErrorBoundary instanceId={pluginProps.instanceId}>
<Component {...pluginProps} />
</PluginErrorBoundary>
);
}

View File

@@ -0,0 +1,31 @@
/**
* PluginStore - Zustand 全局状态v2.1 M8
*
* 管理纯 UI、不可分享的全局状态portal-shell spec §5.2.2
* - themelight/dark
* - localezh-CN/en
* - sidebarCollapsed
*
* 跨插件可分享状态走 URL Search Params不进此 Store。
*
* 关联portal-shell spec §5.2.2、§9.1
*/
import { create } from "zustand";
export interface PluginStore {
theme: "light" | "dark";
locale: "zh-CN" | "en";
sidebarCollapsed: boolean;
setTheme: (theme: "light" | "dark") => void;
setLocale: (locale: "zh-CN" | "en") => void;
toggleSidebar: () => void;
}
export const usePluginStore = create<PluginStore>((set) => ({
theme: "light",
setTheme: (theme) => set({ theme }),
locale: "zh-CN",
setLocale: (locale) => set({ locale }),
sidebarCollapsed: false,
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
}));

View File

@@ -0,0 +1,83 @@
/**
* PropsMerger - 三层 props 合并v2.1 M8
*
* 合并优先级portal-shell spec §4.4):用户调整 > 角色默认 > 系统默认
* - 系统默认plugin.manifest.ts 的 defaultProps
* - 角色默认role_plugin_mapping.widget_props
* - 用户调整user_layout_override.plugin_placements[].props
*
* 合并算法:深合并(对象递归合并,数组与原始值后者覆盖前者)。
*
* 关联portal-shell spec §4.4、§5.1
*/
/**
* 深合并多个 props 层级,后者覆盖前者。
* - 普通对象递归合并
* - 数组、原始值直接覆盖
* - null/undefined 跳过
*/
export function mergeProps(
...layers: (Record<string, unknown> | undefined | null)[]
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const layer of layers) {
if (!layer) continue;
for (const key of Object.keys(layer)) {
const next = layer[key];
const prev = result[key];
if (isPlainObject(next) && isPlainObject(prev)) {
result[key] = mergeProps(
prev as Record<string, unknown>,
next as Record<string, unknown>,
);
} else {
result[key] = next;
}
}
}
return result;
}
/** 安全解析 JSON 字符串为对象,失败返回空对象 */
export function parsePropsJson(
json: string | undefined | null,
): Record<string, unknown> {
if (!json) return {};
try {
const parsed = JSON.parse(json);
return isPlainObject(parsed) ? (parsed as Record<string, unknown>) : {};
} catch {
return {};
}
}
/** 安全解析 size JSON */
export function parseSizeJson(json: string | undefined | null): {
colSpan: number;
rowSpan: number;
} {
if (!json) return { colSpan: 1, rowSpan: 1 };
try {
const parsed = JSON.parse(json);
if (isPlainObject(parsed)) {
const obj = parsed as Record<string, unknown>;
return {
colSpan: typeof obj.colSpan === "number" ? obj.colSpan : 1,
rowSpan: typeof obj.rowSpan === "number" ? obj.rowSpan : 1,
};
}
} catch {
// ignore
}
return { colSpan: 1, rowSpan: 1 };
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) === Object.prototype
);
}

View File

@@ -0,0 +1,63 @@
"use client";
/**
* 插件 Registryv2.1 M8
*
* 编译时登记内置插件plugin_id → { Componentdynamic import, metadata }。
* 运行时由 SlotRenderer 查表渲染。dynamic import 按需加载,首屏只加载可见 slot 插件。
*
* 内置插件M8 验证管道4 个示例):
* - grades-widgetuniversal / main
* - notification-belltopbar / top
* - user-menutopbar / top
* - class-selectorsidebar / side
*
* 关联portal-shell spec §2.2、§5.3
*/
import dynamic from "next/dynamic";
import type { PluginManifest } from "@/lib/types";
import { manifestMeta as gradesWidgetMeta } from "@/widgets/universal/grades-widget/plugin.manifest";
import { manifestMeta as notificationBellMeta } from "@/widgets/topbar/notification-bell/plugin.manifest";
import { manifestMeta as userMenuMeta } from "@/widgets/topbar/user-menu/plugin.manifest";
import { manifestMeta as classSelectorMeta } from "@/widgets/sidebar/class-selector/plugin.manifest";
import { PluginSkeleton } from "@/shell/PluginLoader";
/**
* 内置插件注册表。
* Component 使用 next/dynamic 懒加载ssr:false避免插件 JS 阻塞首屏。
*/
export const REGISTRY: Record<string, PluginManifest> = {
"grades-widget": {
...gradesWidgetMeta,
Component: dynamic(() => import("@/widgets/universal/grades-widget"), {
ssr: false,
loading: () => <PluginSkeleton variant="table" />,
}),
},
"notification-bell": {
...notificationBellMeta,
Component: dynamic(() => import("@/widgets/topbar/notification-bell"), {
ssr: false,
loading: () => <PluginSkeleton variant="card" />,
}),
},
"user-menu": {
...userMenuMeta,
Component: dynamic(() => import("@/widgets/topbar/user-menu"), {
ssr: false,
loading: () => <PluginSkeleton variant="card" />,
}),
},
"class-selector": {
...classSelectorMeta,
Component: dynamic(() => import("@/widgets/sidebar/class-selector"), {
ssr: false,
loading: () => <PluginSkeleton variant="list" />,
}),
},
};
/** 判断插件是否已注册 */
export function isPluginRegistered(pluginId: string): boolean {
return pluginId in REGISTRY;
}

View File

@@ -0,0 +1,34 @@
"use client";
/**
* Shell - Layout 框架 + Slotsv2.1 M8
*
* 微内核宿主:根据 activeLayout 选择 LayoutManager 模板,
* 将配置中的插件分发到对应 slot 渲染。Shell 本身不含业务逻辑。
*
* 关联portal-shell spec §2.2、§4.1
*/
import { type ReactNode } from "react";
import { LayoutManager } from "./LayoutManager";
import type { PluginConfigResponse, Role } from "@/lib/types";
import type { AuthUser } from "@/providers/AuthProvider";
export interface ShellProps {
config: PluginConfigResponse;
user: AuthUser;
role: Role;
userId: string;
}
export function Shell({ config, user, role, userId }: ShellProps): ReactNode {
const layoutId = config.activeLayout?.layoutId ?? "classic";
return (
<LayoutManager
layoutId={layoutId}
plugins={config.plugins}
user={user}
role={role}
userId={userId}
/>
);
}

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>
);
}

View File

@@ -0,0 +1,6 @@
/**
* portal-shell 设计令牌入口(引用 @edu/ui-tokens
*
* 业务代码通过 Tailwind 类bg-paper / text-ink或 hsl(var(--*)) 引用。
*/
@import "@edu/ui-tokens/all.css";

View File

@@ -0,0 +1,70 @@
"use client";
/**
* class-selectorsidebar / side
*
* 通过 useWidgetQuery 查询 apollo-router → core-edu 子图的 myClasses 数据。
* 切换班级时写入 URL Search ParamsclassIdgrades-widget 等插件自动响应。
*
* 关联portal-shell spec §5.2.1 URL 驱动、M8 验收
*/
import { gql } from "@apollo/client";
import { useRouter, useSearchParams } from "next/navigation";
import { useWidgetQuery } from "@/lib/useWidgetQuery";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
const GET_MY_CLASSES = gql`
query GetMyClasses {
myClasses {
id
name
}
}
`;
interface MyClassesQueryData {
myClasses: Array<{ id: string; name: string }>;
}
export default function ClassSelector(_props: PluginProps): React.ReactElement {
const router = useRouter();
const searchParams = useSearchParams();
const currentClassId = searchParams.get("classId") ?? "";
const { data, loading } = useWidgetQuery<
MyClassesQueryData,
Record<string, never>
>(GET_MY_CLASSES, {});
const handleSelect = (classId: string): void => {
const params = new URLSearchParams(searchParams.toString());
params.set("classId", classId);
router.push(`?${params.toString()}`);
};
if (loading && !data) {
return <PluginSkeleton variant="list" />;
}
const classes = data?.myClasses ?? [];
return (
<div>
<p className="text-small text-ink-muted"></p>
<select
value={currentClassId}
onChange={(e) => handleSelect(e.target.value)}
className="mt-xs w-full rounded-button border border-rule bg-surface px-sm py-xs text-small text-ink"
aria-label="选择班级"
>
<option value=""></option>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
);
}

View File

@@ -0,0 +1,21 @@
/**
* class-selector 插件清单sidebar
*
* 班级选择器,插入 side slot。查询 apollo-router → core-edu 子图 myClasses。
* 切换 classId 时写入 URL Search Params其他插件自动响应。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "class-selector",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "班级选择",
description: "切换当前班级(写入 URL classId",
category: "sidebar",
requiredRoles: ["teacher"],
defaultSlot: "side",
defaultSize: { colSpan: 1, rowSpan: 1 },
},
};

View File

@@ -0,0 +1,77 @@
"use client";
/**
* notification-belltopbar / top
*
* 通过 useWidgetQuery 查询 apollo-router → msg 子图的 notifications 数据。
* 点击铃铛展开下拉列表。
*
* 关联portal-shell spec §5.6 统一 Hook、M8 验收
*/
import { gql } from "@apollo/client";
import { useState } from "react";
import { useWidgetQuery } from "@/lib/useWidgetQuery";
import type { PluginProps } from "@/lib/types";
const GET_NOTIFICATIONS = gql`
query GetNotifications($limit: Int) {
notifications(limit: $limit) {
id
title
}
}
`;
interface NotificationsQueryData {
notifications: Array<{ id: string; title: string }>;
}
export default function NotificationBell(
props: PluginProps,
): React.ReactElement {
const rawLimit = props.props.limit;
const limit = typeof rawLimit === "number" ? rawLimit : 10;
const [open, setOpen] = useState(false);
const { data } = useWidgetQuery<NotificationsQueryData, { limit: number }>(
GET_NOTIFICATIONS,
{ limit },
);
const items = data?.notifications ?? [];
return (
<div className="relative">
<button
type="button"
aria-label="通知"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="relative rounded-button bg-surface px-sm py-xs text-ink"
>
<span aria-hidden>🔔</span>
{items.length > 0 ? (
<span className="absolute -right-xs -top-xs rounded-full bg-danger px-xs text-tiny text-ink-onAccent">
{items.length}
</span>
) : null}
</button>
{open ? (
<ul className="absolute right-0 z-50 mt-sm w-64 rounded-card border border-rule bg-surface p-sm shadow-md">
{items.length === 0 ? (
<li className="text-small text-ink-muted"></li>
) : (
items.map((n) => (
<li
key={n.id}
className="border-b border-rule py-xs text-small text-ink"
>
{n.title}
</li>
))
)}
</ul>
) : null}
</div>
);
}

View File

@@ -0,0 +1,27 @@
/**
* notification-bell 插件清单topbar
*
* 通知铃铛,插入 top slot。查询 apollo-router → msg 子图 notifications 数据。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "notification-bell",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "通知",
description: "通知铃铛与下拉列表",
category: "topbar",
requiredRoles: ["admin", "teacher", "student", "parent"],
defaultSlot: "top",
defaultSize: { colSpan: 1, rowSpan: 1 },
defaultProps: { limit: 10 },
propsSchema: {
type: "object",
properties: {
limit: { type: "number", description: "拉取条数", default: 10 },
},
},
},
};

View File

@@ -0,0 +1,76 @@
"use client";
/**
* user-menutopbar / top
*
* 通过 useWidgetQuery 查询 apollo-router → iam 子图的 me 数据。
* 展示用户头像 + 下拉菜单(昵称 / 邮箱 / 角色)。
*
* 关联portal-shell spec §5.6 统一 Hook、M8 验收
*/
import { gql } from "@apollo/client";
import { useState } from "react";
import { useWidgetQuery } from "@/lib/useWidgetQuery";
import { useAuth } from "@/providers/AuthProvider";
import type { PluginProps } from "@/lib/types";
const GET_CURRENT_USER = gql`
query GetCurrentUser {
me {
id
name
email
role
}
}
`;
interface MeQueryData {
me: { id: string; name: string; email: string; role: string } | null;
}
export default function UserMenu(_props: PluginProps): React.ReactElement {
const { user } = useAuth();
const [open, setOpen] = useState(false);
const { data } = useWidgetQuery<MeQueryData, Record<string, never>>(
GET_CURRENT_USER,
{},
);
const me = data?.me;
const displayName = me?.name ?? user.name;
const displayEmail = me?.email ?? user.email;
const displayRole = me?.role ?? user.role;
return (
<div className="relative">
<button
type="button"
aria-label="用户菜单"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-sm rounded-button bg-surface px-sm py-xs text-ink"
>
<span
aria-hidden
className="flex h-heading-3 w-heading-3 items-center justify-center rounded-full bg-accent text-ink-onAccent"
>
{displayName.charAt(0).toUpperCase()}
</span>
<span className="text-small">{displayName}</span>
</button>
{open ? (
<ul className="absolute right-0 z-50 mt-sm w-56 rounded-card border border-rule bg-surface p-sm shadow-md">
<li className="border-b border-rule py-xs">
<p className="text-small text-ink">{displayName}</p>
<p className="text-tiny text-ink-muted">{displayEmail}</p>
</li>
<li className="py-xs text-small text-ink-muted">
{displayRole}
</li>
</ul>
) : null}
</div>
);
}

View File

@@ -0,0 +1,20 @@
/**
* user-menu 插件清单topbar
*
* 用户菜单,插入 top slot。查询 apollo-router → iam 子图的 me 数据。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "user-menu",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "用户菜单",
description: "用户头像与下拉菜单",
category: "topbar",
requiredRoles: ["admin", "teacher", "student", "parent"],
defaultSlot: "top",
defaultSize: { colSpan: 1, rowSpan: 1 },
},
};

View File

@@ -0,0 +1,81 @@
"use client";
/**
* grades-widgetuniversal / main
*
* 通过 useWidgetQuery 查询 apollo-router → core-edu 子图的 grades 数据。
* classId 从 URL Search Params 读取class-selector 切换时自动响应)。
*
* 关联portal-shell spec §5.2.1 URL 驱动、§5.6 统一 Hook、M8 验收
*/
import { gql } from "@apollo/client";
import { useSearchParams } from "next/navigation";
import { useWidgetQuery } from "@/lib/useWidgetQuery";
import { PluginSkeleton } from "@/shell/PluginLoader";
import type { PluginProps } from "@/lib/types";
const GET_GRADES = gql`
query GetGrades($classId: ID!) {
grades(classId: $classId) {
studentId
score
}
}
`;
interface GradesQueryData {
grades: Array<{ studentId: string; score: number }>;
}
export default function GradesWidget(props: PluginProps): React.ReactElement {
const searchParams = useSearchParams();
const classId = searchParams.get("classId") ?? "";
const rawLimit = props.props.limit;
const limit = typeof rawLimit === "number" ? rawLimit : 20;
const { data, loading } = useWidgetQuery<
GradesQueryData,
{ classId: string }
>(GET_GRADES, { classId }, { enabled: classId.length > 0 });
if (loading && !data) {
return <PluginSkeleton variant="table" />;
}
if (!classId) {
return (
<section className="rounded-card border border-rule bg-surface p-md">
<h3 className="text-heading-3 text-ink"></h3>
<p className="text-small text-ink-muted"></p>
</section>
);
}
const rows = data?.grades ?? [];
return (
<section className="rounded-card border border-rule bg-surface p-md">
<h3 className="text-heading-3 text-ink"></h3>
{rows.length === 0 ? (
<p className="text-small text-ink-muted"></p>
) : (
<table className="mt-sm w-full text-small">
<thead>
<tr className="border-b border-rule text-ink-muted">
<th className="py-xs text-left"></th>
<th className="py-xs text-right"></th>
</tr>
</thead>
<tbody>
{rows.slice(0, limit).map((row) => (
<tr key={row.studentId} className="border-b border-rule">
<td className="py-xs text-ink">{row.studentId}</td>
<td className="py-xs text-right text-ink">{row.score}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
);
}

View File

@@ -0,0 +1,28 @@
/**
* grades-widget 插件清单universal
*
* 跨角色通用成绩卡片,插入 main slot。
* 通过 useWidgetQuery 查询 apollo-router 的 core-edu 子图 grades 数据。
*/
import type { PluginManifest } from "@/lib/types";
export const manifestMeta: Omit<PluginManifest, "Component"> = {
pluginId: "grades-widget",
version: "0.1.0",
requiredShellVersion: "^1.0.0",
metadata: {
displayName: "成绩",
description: "班级成绩列表(按 classId 过滤)",
category: "universal",
requiredRoles: ["teacher", "student", "parent"],
defaultSlot: "main",
defaultSize: { colSpan: 2, rowSpan: 1 },
defaultProps: { limit: 20 },
propsSchema: {
type: "object",
properties: {
limit: { type: "number", description: "展示条数", default: 20 },
},
},
},
};