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:
SpecialX
2026-07-17 16:10:05 +08:00
parent f7e52b5b7f
commit 9cedf0c437
140 changed files with 10872 additions and 3192 deletions

View 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" });
// 只更新 classIdtermId 保留
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);
});
});

View File

@@ -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;
}
/**

View File

@@ -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 schemaadmin 配置面板用) */

View File

@@ -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, {

View File

@@ -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>,