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

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