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>
154 lines
4.6 KiB
TypeScript
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'
|