Files
awoooi/apps/web/src/components/genui/GenUIRenderer.tsx
OG T 4d46e6b9a7
Some checks failed
E2E Health Check / e2e-health (push) Successful in 17s
CD Pipeline / build-and-deploy (push) Has been cancelled
style(web): 全站 font-mono → font-body (DM Mono 設計系統套用)
45 個 component + 6 個 page 統一從舊 font-mono 遷移到
font-body (DM Mono),確保設計系統一致性。

font-body = DM Mono (等寬),視覺效果相同但走新設計 token。
保留: font-heading (Syne)、font-dot-matrix (VT323/DSEG7)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 09:37:03 +08:00

154 lines
4.6 KiB
TypeScript

/**
* GenUIRenderer - Dynamic Component Renderer
* ==========================================
* Phase 19.4a - GenUI 動態渲染器
* Phase 19.O - 可觀測性整合
*
* 根據 SSE 事件動態渲染已註冊的 GenUI 組件
*
* @see ADR-032 GenUI Dynamic Rendering
* @author Claude Code (首席架構師)
* @version 1.1.0 - 加入 Telemetry
* @date 2026-03-28 (台北時間)
*/
'use client'
import React, { Suspense, useEffect, useRef } from 'react'
import { AlertTriangle, Loader2 } from 'lucide-react'
import { getComponent, isRegistered, validateProps } from './registry'
import { trackGenUIRender } from '@/lib/telemetry'
interface GenUIRendererProps {
/** 組件名稱 */
component: string
/** 組件 Props */
props: Record<string, unknown>
/** 渲染位置 */
position?: 'inline' | 'modal' | 'panel'
/** 組件 ID (用於更新/移除) */
id?: string
}
/**
* GenUI 動態渲染器
*
* 使用方式:
* <GenUIRenderer component="ApprovalCard" props={{ approvalId: "APR-123" }} />
*/
export const GenUIRenderer: React.FC<GenUIRendererProps> = ({
component,
props,
position = 'inline',
id,
}) => {
const renderStartTime = useRef(Date.now())
// 追蹤渲染完成 (Phase 19.O)
useEffect(() => {
const renderTime = Date.now() - renderStartTime.current
trackGenUIRender({
componentName: component,
renderTime,
success: true,
})
}, [component])
// 檢查組件是否已註冊
if (!isRegistered(component)) {
trackGenUIRender({
componentName: component,
success: false,
error: 'Component not registered',
})
return (
<ErrorCard
title="Unknown Component"
message={`Component "${component}" is not registered in GenUI Registry.`}
/>
)
}
// 取得組件定義
const componentDef = getComponent(component)
if (!componentDef) {
trackGenUIRender({
componentName: component,
success: false,
error: 'Component definition not found',
})
return (
<ErrorCard
title="Component Not Found"
message={`Failed to load component "${component}".`}
/>
)
}
// 驗證 Props (Phase 19 首席架構師審查 P1 - Zod 驗證)
const validation = validateProps(component, props)
if (!validation.valid) {
console.warn(`[GenUI] Props validation failed for ${component}:`, validation.errors)
// 追蹤驗證失敗 (含錯誤分類碼)
trackGenUIRender({
componentName: component,
success: false,
error: validation.errors.join('; '),
errorCode: validation.errorCode,
})
// 不阻止渲染,只是警告
}
// 取得 React 組件
const Component = componentDef.component
// 根據 position 決定容器樣式
const containerClass = {
inline: '',
modal: 'fixed inset-0 flex items-center justify-center z-70 bg-black/50',
panel: 'fixed right-0 top-0 h-full w-96 bg-white shadow-xl z-50',
}[position]
return (
<div className={containerClass} data-genui-id={id} data-genui-component={component}>
<Suspense fallback={<LoadingCard componentName={component} />}>
<Component data={props} />
</Suspense>
</div>
)
}
// =============================================================================
// Helper Components
// =============================================================================
const LoadingCard: React.FC<{ componentName: string }> = ({ componentName }) => (
<div className="w-full max-w-2xl bg-white border-2 border-gray-200 shadow-lg rounded-sm overflow-hidden mt-4 mb-2 animate-pulse">
<div className="p-4 flex items-center gap-2">
<Loader2 className="animate-spin text-gray-400" size={20} />
<span className="font-body text-sm text-gray-500">
Loading {componentName}...
</span>
</div>
</div>
)
const ErrorCard: React.FC<{ title: string; message: string }> = ({ title, message }) => (
<div className="w-full max-w-2xl bg-red-50 border-2 border-red-300 shadow-lg rounded-sm overflow-hidden mt-4 mb-2">
<div className="p-4 flex items-start gap-3">
<AlertTriangle className="text-red-500 shrink-0" size={20} />
<div>
<div className="font-body font-bold text-red-700">{title}</div>
<div className="font-body text-sm text-red-600 mt-1">{message}</div>
</div>
</div>
</div>
)
// =============================================================================
// Export Registry Functions for External Use
// =============================================================================
export { isRegistered, getComponent, getRegisteredComponents, validateProps } from './registry'