feat(portal-shell): v2.0 P0 shadcn standardization + security + streaming + error handling
- shadcn/ui 标准化:废弃纸感令牌,统一 bg-background/text-foreground 等 - Tailwind v4 + @theme inline,移除 tailwind.config.js - React 19 use() + Suspense 流式渲染,首屏骨架秒出 - 三级错误边界:Route → Section → Widget 层层兜底 - 错误上报:useErrorReport → sendBeacon → /api/log mock 端点 - 三层安全边界:L1 角色门禁 / L2 权限点门禁 / L3 数据范围 - 权限位图 base36 压缩:67 权限点 → ~14 字符,JWT 体积减少 ≥ 99% - notify 统一 Toast 封装,禁止业务直接 import sonner - PluginBoundary 替代 PluginLoader(错误边界 + Suspense + Skeleton 三件套) 验证:typecheck 0 错误 / lint 0 错误 / build 6 路由生成成功
This commit is contained in:
130
apps/portal-shell/src/lib/__tests__/plugin-context.test.ts
Normal file
130
apps/portal-shell/src/lib/__tests__/plugin-context.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseUrlContext,
|
||||
writeUrlContext,
|
||||
URL_CONTEXT_KEYS,
|
||||
type UrlPluginContext,
|
||||
} from "@edu/shared-ts/contracts";
|
||||
|
||||
/**
|
||||
* URL Search Params 上下文测试(portal-shell spec §5.2.1、§9.9)
|
||||
*
|
||||
* 覆盖:
|
||||
* - parseUrlContext:从 URLSearchParams 解析上下文
|
||||
* - writeUrlContext:将上下文写入 URLSearchParams
|
||||
* - URL_CONTEXT_KEYS:常量正确性
|
||||
*/
|
||||
describe("URL_CONTEXT_KEYS", () => {
|
||||
it("key 名称与 URL 参数名一致", () => {
|
||||
expect(URL_CONTEXT_KEYS.classId).toBe("classId");
|
||||
expect(URL_CONTEXT_KEYS.childId).toBe("childId");
|
||||
expect(URL_CONTEXT_KEYS.termId).toBe("termId");
|
||||
expect(URL_CONTEXT_KEYS.view).toBe("view");
|
||||
expect(URL_CONTEXT_KEYS.subjectId).toBe("subjectId");
|
||||
expect(URL_CONTEXT_KEYS.examId).toBe("examId");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseUrlContext", () => {
|
||||
it("空参数 → 空上下文", () => {
|
||||
const ctx = parseUrlContext(new URLSearchParams());
|
||||
expect(ctx).toEqual({});
|
||||
});
|
||||
|
||||
it("解析 classId", () => {
|
||||
const params = new URLSearchParams("?classId=cls-001");
|
||||
const ctx = parseUrlContext(params);
|
||||
expect(ctx.classId).toBe("cls-001");
|
||||
});
|
||||
|
||||
it("解析多个参数", () => {
|
||||
const params = new URLSearchParams(
|
||||
"?classId=cls-001&termId=2024-fall&view=chart",
|
||||
);
|
||||
const ctx = parseUrlContext(params);
|
||||
expect(ctx).toEqual({
|
||||
classId: "cls-001",
|
||||
termId: "2024-fall",
|
||||
view: "chart",
|
||||
});
|
||||
});
|
||||
|
||||
it("解析全部参数", () => {
|
||||
const params = new URLSearchParams(
|
||||
"?classId=cls-1&childId=child-1&termId=t-1&view=list&subjectId=subj-1&examId=exam-1",
|
||||
);
|
||||
const ctx = parseUrlContext(params);
|
||||
expect(ctx).toEqual({
|
||||
classId: "cls-1",
|
||||
childId: "child-1",
|
||||
termId: "t-1",
|
||||
view: "list",
|
||||
subjectId: "subj-1",
|
||||
examId: "exam-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("忽略空值参数", () => {
|
||||
const params = new URLSearchParams("?classId=&termId=t-1");
|
||||
const ctx = parseUrlContext(params);
|
||||
expect(ctx.classId).toBeUndefined();
|
||||
expect(ctx.termId).toBe("t-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeUrlContext", () => {
|
||||
it("写入单个值", () => {
|
||||
const params = new URLSearchParams();
|
||||
writeUrlContext(params, { classId: "cls-001" });
|
||||
expect(params.get("classId")).toBe("cls-001");
|
||||
});
|
||||
|
||||
it("写入多个值", () => {
|
||||
const params = new URLSearchParams();
|
||||
writeUrlContext(params, {
|
||||
classId: "cls-1",
|
||||
termId: "t-1",
|
||||
view: "chart",
|
||||
});
|
||||
expect(params.get("classId")).toBe("cls-1");
|
||||
expect(params.get("termId")).toBe("t-1");
|
||||
expect(params.get("view")).toBe("chart");
|
||||
});
|
||||
|
||||
it("空字符串 → 删除参数", () => {
|
||||
const params = new URLSearchParams("?classId=cls-1");
|
||||
writeUrlContext(params, { classId: "" });
|
||||
expect(params.has("classId")).toBe(false);
|
||||
});
|
||||
|
||||
it("undefined → 保留原值不变", () => {
|
||||
const params = new URLSearchParams("?classId=cls-1&termId=t-1");
|
||||
writeUrlContext(params, { classId: "cls-2" });
|
||||
// 只更新 classId,termId 保留
|
||||
expect(params.get("classId")).toBe("cls-2");
|
||||
expect(params.get("termId")).toBe("t-1");
|
||||
});
|
||||
|
||||
it("空对象 → 不修改任何参数", () => {
|
||||
const params = new URLSearchParams("?classId=cls-1");
|
||||
writeUrlContext(params, {});
|
||||
expect(params.get("classId")).toBe("cls-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseUrlContext + writeUrlContext 往返", () => {
|
||||
it("写入后解析应得到相同上下文", () => {
|
||||
const original: UrlPluginContext = {
|
||||
classId: "cls-001",
|
||||
childId: "child-001",
|
||||
termId: "2024-fall",
|
||||
view: "list",
|
||||
subjectId: "math",
|
||||
examId: "exam-001",
|
||||
};
|
||||
const params = new URLSearchParams();
|
||||
writeUrlContext(params, original);
|
||||
const parsed = parseUrlContext(params);
|
||||
expect(parsed).toEqual(original);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,11 @@
|
||||
* - 统一走 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";
|
||||
@@ -55,6 +60,11 @@ export const GET_PLUGIN_CONFIG = gql`
|
||||
/**
|
||||
* 获取用户合并后的插件配置(服务端调用)。
|
||||
*
|
||||
* 查询顺序(开发态容错):
|
||||
* 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 配置
|
||||
@@ -63,8 +73,9 @@ export async function fetchPluginConfig(
|
||||
userId: string,
|
||||
role: Role,
|
||||
): Promise<PluginConfigResponse> {
|
||||
const client = createApolloClient();
|
||||
// 1. 优先走 apollo-router(生产路径)
|
||||
try {
|
||||
const client = createApolloClient();
|
||||
const { data, error } = await client.query<{
|
||||
pluginConfig: PluginConfigResponse;
|
||||
}>({
|
||||
@@ -73,22 +84,106 @@ export async function fetchPluginConfig(
|
||||
});
|
||||
if (error) {
|
||||
console.warn(
|
||||
`[portal-shell] fetchPluginConfig partial error: ${error.message}`,
|
||||
`[portal-shell] fetchPluginConfig partial error from apollo-router: ${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: ${
|
||||
`[portal-shell] apollo-router query failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return getDefaultConfig();
|
||||
}
|
||||
|
||||
// 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. 最终兜底:空默认配置
|
||||
console.warn(`[portal-shell] fetchPluginConfig returning empty default`);
|
||||
return getDefaultConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* 开发态降级:直连 config-service GraphQL 查询 pluginConfig。
|
||||
*
|
||||
* 当 apollo-router 不可用时(如本地开发未启动 Router),
|
||||
* 直接请求 config-service 的 /graphql 端点获取插件配置。
|
||||
* 生产环境不应触发此路径(apollo-router 必须可用)。
|
||||
*/
|
||||
async function fetchPluginConfigDirect(
|
||||
userId: string,
|
||||
role: string,
|
||||
configServiceUrl: string,
|
||||
): Promise<PluginConfigResponse | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -120,7 +120,13 @@ export interface PluginManifest {
|
||||
displayName: string;
|
||||
description: string;
|
||||
category: PluginCategory;
|
||||
/** L1 角色门禁:可访问此插件的角色列表(粗粒度) */
|
||||
requiredRoles: Role[];
|
||||
/**
|
||||
* L2 权限点门禁:访问此插件所需的权限点列表(细粒度,AND 语义)
|
||||
* 权限点必须来自 PERMISSION_BITMAP_ORDER
|
||||
*/
|
||||
requiredPermissions?: string[];
|
||||
defaultSlot: string;
|
||||
defaultSize: PluginSize;
|
||||
/** 插件可配置的 props schema(admin 配置面板用) */
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type TypedDocumentNode,
|
||||
} from "@apollo/client";
|
||||
|
||||
export function useWidgetMutation<TData, TVars extends Record<string, unknown>>(
|
||||
export function useWidgetMutation<TData, TVars = Record<string, unknown>>(
|
||||
mutation: DocumentNode | TypedDocumentNode<TData, TVars>,
|
||||
) {
|
||||
const [mutate, result] = useMutation<TData, TVars>(mutation, {
|
||||
|
||||
@@ -28,7 +28,10 @@ export interface UseWidgetQueryOptions<TData> {
|
||||
fetchPolicy?: FetchPolicy;
|
||||
}
|
||||
|
||||
export function useWidgetQuery<TData, TVars extends Record<string, unknown>>(
|
||||
export function useWidgetQuery<
|
||||
TData,
|
||||
TVars extends Record<string, unknown> = Record<string, unknown>,
|
||||
>(
|
||||
query: DocumentNode | TypedDocumentNode<TData, TVars>,
|
||||
variables: TVars,
|
||||
options?: UseWidgetQueryOptions<TData>,
|
||||
|
||||
Reference in New Issue
Block a user