/** * 配置获取(服务端 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 直出 * * 开发态降级(spec §5.5 容错): * - 优先走 apollo-router(生产路径) * - Router 不可用时降级直连 config-service /graphql(仅开发态,由 CONFIG_SERVICE_URL 触发) * - 二者均失败时返回空默认配置,保证 Shell 可渲染 * * 关联:portal-shell spec §5.5、§6.2、M8 验收标准 */ import { gql } from "@apollo/client"; import { createApolloClient } from "./apollo-client"; import type { LayoutTemplateInfo, PluginConfigResponse, PluginPlacement, PluginRegistryItem, Role, SlotConfig, } 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 } } } `; /** * 获取用户合并后的插件配置(服务端调用)。 * * 查询顺序(开发态容错): * 1. apollo-router(生产路径,M8 验收点) * 2. config-service 直连(仅当 CONFIG_SERVICE_URL 配置时启用,开发态降级) * 3. 空默认配置(最后兜底) * * @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 { // 1. 优先走 apollo-router(生产路径) try { const client = createApolloClient(); const { data, error } = await client.query<{ pluginConfig: PluginConfigResponse; }>({ query: GET_PLUGIN_CONFIG, variables: { userId, role }, }); if (error) { console.warn( `[portal-shell] fetchPluginConfig partial error from apollo-router: ${error.message}`, ); } if (data?.pluginConfig) { return data.pluginConfig; } } catch (err) { console.warn( `[portal-shell] apollo-router query failed: ${ err instanceof Error ? err.message : String(err) }`, ); } // 2. 开发态降级:直连 config-service GraphQL const configServiceUrl = process.env.CONFIG_SERVICE_URL || process.env.NEXT_PUBLIC_CONFIG_SERVICE_URL; if (configServiceUrl) { try { const result = await fetchPluginConfigDirect( userId, role, configServiceUrl, ); if (result) { console.info( `[portal-shell] fetchPluginConfig fallback to config-service direct`, ); return result; } } catch (err) { console.warn( `[portal-shell] config-service direct fallback failed: ${ err instanceof Error ? err.message : String(err) }`, ); } } // 3. 最终兜底:内置默认配置(按角色静态定义,P0-4) console.warn( `[portal-shell] fetchPluginConfig falling back to built-in default config (role=${role})`, ); return getDefaultConfig(role); } /** * 开发态降级:直连 config-service GraphQL 查询 pluginConfig。 * * 当 apollo-router 不可用时(如本地开发未启动 Router), * 直接请求 config-service 的 /graphql 端点获取插件配置。 * 生产环境不应触发此路径(apollo-router 必须可用)。 */ async function fetchPluginConfigDirect( userId: string, role: string, configServiceUrl: string, ): Promise { const endpoint = `${configServiceUrl.replace(/\/$/, "")}/graphql`; const response = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: ` 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 } } } `, variables: { userId, role }, }), // RSC 服务端调用,不携带 cookie cache: "no-store", }); if (!response.ok) { throw new Error(`config-service HTTP ${response.status}`); } const json = (await response.json()) as { data?: { pluginConfig?: PluginConfigResponse }; errors?: unknown; }; if (json.errors) { throw new Error( `config-service GraphQL errors: ${JSON.stringify(json.errors)}`, ); } return json.data?.pluginConfig ?? null; } /** * 默认 classic 布局配置(Router 未就绪 / 查询失败时降级)。 * * P0-4(ARCHITECTURE.md §10):按角色静态定义内置默认仪表盘插件集, * 保证 config-service 不可用时仪表盘仍有内容(fail-safe 而非空壳)。 * * 角色映射依据各 widget 的 manifest `requiredRoles` 字段(src/widgets 下各目录): * - topbar 4 件:全角色(notification-bell/global-search/locale-switcher/user-menu) * - sidebar:teacher → class-selector + term-switcher + quick-actions; * student → term-switcher + quick-actions; * parent → child-selector + term-switcher + quick-actions; * admin → 无 sidebar 上下文 * - main:universal 7 件按角色过滤 + 角色专属 widget * * @param role 用户角色;未提供时按 teacher 兜底(与现有 ShellPage 默认行为一致) */ export function getDefaultConfig(role?: Role): PluginConfigResponse { const effectiveRole: Role = role ?? "teacher"; const layout: LayoutTemplateInfo = { layoutId: "classic", displayName: "经典三栏", description: "TopBar + SideNav + Main", availableSlots: ["top", "side", "main"], layoutSchemaJson: JSON.stringify({ grid: { rows: 1, cols: 1, areas: [["main"]] }, }), }; const slots: SlotConfig[] = [ { slotName: "top", navItems: [] }, { slotName: "side", navItems: [] }, { slotName: "main", navItems: [] }, ]; // ── top slot(全角色共享:通知铃 / 全局搜索 / 语言切换 / 用户菜单) ── const topPlugins: PluginPlacement[] = [ placement("notification-bell", "top", 0, { colSpan: 1, rowSpan: 1 }), placement("global-search", "top", 1, { colSpan: 1, rowSpan: 1 }), placement("locale-switcher", "top", 2, { colSpan: 1, rowSpan: 1 }), placement("user-menu", "top", 3, { colSpan: 1, rowSpan: 1 }), ]; // ── side slot(按角色裁剪) ── const sidePlugins: PluginPlacement[] = SIDE_DEFAULTS[effectiveRole].map( (id, idx) => placement(id, "side", idx, { colSpan: 1, rowSpan: 1 }), ); // ── main slot(universal 按角色 + 角色专属) ── const mainPlugins: PluginPlacement[] = MAIN_DEFAULTS[effectiveRole].map( (id, idx) => placement(id, "main", idx, { colSpan: 2, rowSpan: 1 }), ); // ── registry(按角色聚合所有可见插件的元信息) ── const registry: PluginRegistryItem[] = [ ...topPlugins, ...sidePlugins, ...mainPlugins, ].map((p) => registryItem(p.pluginId, effectiveRole)); return { activeLayout: layout, slots, plugins: [...topPlugins, ...sidePlugins, ...mainPlugins], registry, }; } /** side slot 角色默认(按渲染顺序) */ const SIDE_DEFAULTS: Record = { teacher: ["class-selector", "term-switcher", "quick-actions"], student: ["term-switcher", "quick-actions"], parent: ["child-selector", "term-switcher", "quick-actions"], admin: [], }; /** main slot 角色默认(universal + 角色专属,按渲染顺序) */ const MAIN_DEFAULTS: Record = { teacher: [ "schedule-widget", "grades-widget", "homework-widget", "exams-widget", "attendance-widget", "announcements-widget", "notifications-widget", "lesson-plan-editor", "question-bank", "textbook-manager", "scheduling-rules", ], student: [ "schedule-widget", "grades-widget", "homework-widget", "exams-widget", "attendance-widget", "announcements-widget", "notifications-widget", "error-book", "learning-path", "elective-selector", "ai-tutor", ], parent: [ "schedule-widget", "grades-widget", "homework-widget", "exams-widget", "attendance-widget", "announcements-widget", "notifications-widget", "child-overview", "leave-approval", ], admin: [ "announcements-widget", "notifications-widget", "user-management", "rbac-manager", "plugin-manager", "school-settings", "audit-logs", "invitation-codes", ], }; /** 简易 PluginPlacement 构造器 */ function placement( pluginId: string, slot: string, sortOrder: number, size: { colSpan: number; rowSpan: number }, ): PluginPlacement { return { pluginId, slot, sortOrder, sizeJson: JSON.stringify(size), propsJson: "{}", isVisible: true, }; } /** 简易 PluginRegistryItem 构造器(基于内置 manifest 元数据) */ function registryItem(pluginId: string, role: Role): PluginRegistryItem { // category 由 pluginId 前缀目录决定,与 src/widgets// 对齐 const category = inferCategory(pluginId); return { pluginId, category, version: "0.1.0", displayName: pluginId, description: `Built-in ${category} plugin (default config fallback)`, requiredRoles: [role], isBuiltin: true, isActive: true, }; } /** 由 pluginId 推断 category(与目录结构 src/widgets// 对齐) */ function inferCategory(pluginId: string): string { if (pluginId.endsWith("-widget")) return "universal"; if ( pluginId === "notification-bell" || pluginId === "global-search" || pluginId === "locale-switcher" || pluginId === "user-menu" ) return "topbar"; if ( pluginId === "class-selector" || pluginId === "child-selector" || pluginId === "term-switcher" || pluginId === "quick-actions" ) return "sidebar"; if ( pluginId === "lesson-plan-editor" || pluginId === "question-bank" || pluginId === "textbook-manager" || pluginId === "scheduling-rules" ) return "teacher"; if ( pluginId === "error-book" || pluginId === "learning-path" || pluginId === "elective-selector" || pluginId === "ai-tutor" ) return "student"; if (pluginId === "child-overview" || pluginId === "leave-approval") return "parent"; return "admin"; }