"use client"; import { useEffect, useMemo, useState } from "react"; import { AlertTriangle, Check, CheckCircle2, Layers3, Loader2, MonitorSmartphone, Sparkles, Trash2, Wand2, } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon"; import { UiScreenRenderer } from "@/components/canvas/ui/UiScreenRenderer"; import { getCanvasToolbarItem, type CanvasToolbarConfig, } from "@/lib/canvas-toolbar-config"; import { normalizeUiScreenDocument, type UiScreenLayer } from "@/lib/ui-screen/schema"; import type { UiScreenPatch } from "@/lib/ui-screen/patch"; import type { UiScreenValidationResult } from "@/lib/ui-screen/validation"; import { cn } from "@/lib/utils"; import { useCanvasStore } from "@/store/canvas"; import type { CanvasNode, UiScreenNodeData } from "@/types/canvas"; interface UiScreenNodeProps { node: CanvasNode; isSelected: boolean; canvasToolbarConfig?: CanvasToolbarConfig; } const STATUS_LABELS = { pending: "待生成", generating: "渲染中", completed: "可编辑 UI", failed: "失败", } as const; const STATUS_CLASS_MAP = { pending: "border-slate-200 bg-slate-50 text-slate-600", generating: "border-blue-200 bg-blue-50 text-blue-700", completed: "border-emerald-200 bg-emerald-50 text-emerald-700", failed: "border-rose-200 bg-rose-50 text-rose-700", } as const; type ValidationSummary = { score: number; issueCount: number; patchedCount: number; }; type UiScreenReviewResponse = { success?: boolean; error?: string; data?: { validation?: UiScreenValidationResult; patch?: UiScreenPatch; patchedDocument?: unknown; }; }; function findLayerById(layers: UiScreenLayer[], id: string | null): UiScreenLayer | null { if (!id) return null; for (const layer of layers) { if (layer.id === id) return layer; const child = layer.children ? findLayerById(layer.children, id) : null; if (child) return child; } return null; } function getEditableLayerText(layer: UiScreenLayer | null) { if (!layer) return null; if (layer.kind === "text") return layer.text || ""; if (layer.kind === "button" || layer.kind === "badge") return layer.label || layer.text || ""; return null; } function updateLayerText( layers: UiScreenLayer[], targetLayerId: string, nextText: string ): UiScreenLayer[] { return layers.map((layer) => { const children = layer.children ? updateLayerText(layer.children, targetLayerId, nextText) : undefined; if (layer.id !== targetLayerId) { return children ? { ...layer, children } : layer; } if (layer.kind === "text") { return { ...layer, children, text: nextText }; } if (layer.kind === "button" || layer.kind === "badge") { return { ...layer, children, label: nextText }; } return children ? { ...layer, children } : layer; }); } export function UiScreenNode({ node, isSelected, canvasToolbarConfig, }: UiScreenNodeProps) { const data = node.data as UiScreenNodeData; const uiScreenReviewItem = getCanvasToolbarItem( canvasToolbarConfig, "uiScreenReview" ); const uiScreenDeleteItem = getCanvasToolbarItem( canvasToolbarConfig, "uiScreenDelete" ); const uiScreenSaveTextItem = getCanvasToolbarItem( canvasToolbarConfig, "uiScreenSaveText" ); const deleteNode = useCanvasStore((state) => state.deleteNode); const updateNode = useCanvasStore((state) => state.updateNode); const [selectedLayerId, setSelectedLayerId] = useState(null); const [draftText, setDraftText] = useState(""); const [isReviewing, setIsReviewing] = useState(false); const [validationSummary, setValidationSummary] = useState(null); const document = useMemo( () => normalizeUiScreenDocument(data.document), [data.document] ); const selectedLayer = useMemo( () => (document ? findLayerById(document.layers, selectedLayerId) : null), [document, selectedLayerId] ); const editableText = getEditableLayerText(selectedLayer); useEffect(() => { setDraftText(editableText ?? ""); }, [editableText, selectedLayerId]); const status = data.status || "completed"; const statusLabel = STATUS_LABELS[status] || STATUS_LABELS.completed; const statusClassName = STATUS_CLASS_MAP[status] || STATUS_CLASS_MAP.completed; const currentScore = validationSummary?.score ?? data.evaluation?.score; const currentIssueCount = validationSummary?.issueCount ?? data.evaluation?.issues?.length ?? null; const scoreClassName = typeof currentScore === "number" && currentScore >= 82 ? "border-emerald-200 bg-emerald-50 text-emerald-700" : "border-amber-200 bg-amber-50 text-amber-700"; const handleSaveLayerText = () => { if (!selectedLayer || editableText === null) return; const nextDocument = { ...data.document, layers: updateLayerText(data.document.layers, selectedLayer.id, draftText), }; updateNode(node.id, { data: { ...data, document: nextDocument, }, }); }; const handleReviewAndPatch = async () => { if (!document || isReviewing) return; setIsReviewing(true); try { const response = await fetch("/api/canvas/ui-screen/review", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ document, applyAutoPatch: true, }), }); const payload = (await response.json()) as UiScreenReviewResponse; if (!response.ok || !payload.success || !payload.data?.validation) { throw new Error(payload.error || "UI Screen 质检失败"); } const validation = payload.data.validation; const patch = payload.data.patch; const patchedDocument = normalizeUiScreenDocument(payload.data.patchedDocument) || document; const patchedCount = patch?.operations.filter((operation) => operation.op !== "set_validation").length || 0; updateNode(node.id, { data: { ...data, document: patchedDocument, evaluation: { score: validation.score, issues: validation.issues.map((issue) => issue.message).slice(0, 12), reviewedAt: new Date().toISOString(), }, metadata: { ...(data.metadata || {}), uiScreenValidation: validation, uiScreenPatch: patch, }, }, }); setValidationSummary({ score: validation.score, issueCount: validation.issues.length, patchedCount, }); if (patchedCount > 0) { toast.success(`已自动修复 ${patchedCount} 项 UI 问题`); } else if (validation.passed) { toast.success("UI Screen 质检通过"); } else { toast.message(`发现 ${validation.issues.length} 项需要人工确认的问题`); } } catch (error) { toast.error(error instanceof Error ? error.message : "UI Screen 质检失败"); } finally { setIsReviewing(false); } }; return (
{document ? ( ) : (
UI 文档不可渲染
缺少有效的 ui-screen/v1 layers
)} {isSelected ? ( <>
event.stopPropagation()} > {statusLabel} {document?.title ? ( {document.title} ) : null} {typeof currentScore === "number" ? ( {currentScore >= 82 ? ( ) : ( )} UI {currentScore} {currentIssueCount ? ` · ${currentIssueCount}` : ""} ) : null}
event.stopPropagation()} >
{selectedLayer ? (
event.stopPropagation()} >
{selectedLayer.name || selectedLayer.id}
{selectedLayer.kind} {selectedLayer.bounds ? ` · ${Math.round(selectedLayer.bounds.width)}x${Math.round(selectedLayer.bounds.height)}` : ""}
{editableText !== null ? (
setDraftText(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); handleSaveLayerText(); } }} value={draftText} />
) : null} {validationSummary ? (
质检 {validationSummary.score} · 问题 {validationSummary.issueCount} · 自动修复{" "} {validationSummary.patchedCount}
) : null}
) : (
event.stopPropagation()} > 点击内部图层可选中
)} ) : null}
); }