From 05cf3ae54e833696d47582b582fd604965cb91e9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 22 Mar 2026 12:23:37 +0800 Subject: [PATCH] feat: add detail ui dsl v2 renderer --- components/canvas/DetailUiDomRenderer.tsx | 360 ++++++++++++++++++++++ components/canvas/nodes/ImageNode.tsx | 26 +- lib/detail-ui-dsl-v2-builder.ts | 302 ++++++++++++++++++ lib/detail-ui-dsl-v2.ts | 106 +++++++ 4 files changed, 789 insertions(+), 5 deletions(-) create mode 100644 components/canvas/DetailUiDomRenderer.tsx create mode 100644 lib/detail-ui-dsl-v2-builder.ts create mode 100644 lib/detail-ui-dsl-v2.ts diff --git a/components/canvas/DetailUiDomRenderer.tsx b/components/canvas/DetailUiDomRenderer.tsx new file mode 100644 index 000000000..9e3ec5c1c --- /dev/null +++ b/components/canvas/DetailUiDomRenderer.tsx @@ -0,0 +1,360 @@ +"use client"; + +import type { CSSProperties } from "react"; +import { ArrowRight, CheckCircle2, ShieldCheck, Sparkles } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { + DetailUiDslNode, + DetailUiDslRegion, + DetailUiDslV2Document, +} from "@/lib/detail-ui-dsl-v2"; + +function normalizedRegionToPixels(params: { + region: DetailUiDslRegion; + width: number; + height: number; +}) { + return { + x: params.region.x * params.width, + y: params.region.y * params.height, + width: params.region.width * params.width, + height: params.region.height * params.height, + }; +} + +function iconFor(name?: string, size = 16) { + const style = { width: size, height: size }; + if (name === "shield") return ; + if (name === "arrow") return ; + if (name === "check") return ; + return ; +} + +function panelClass(variant?: string, tone?: string) { + if (variant === "glass") { + if (tone === "dark") { + return "bg-slate-900/72 border border-white/10 text-white shadow-[0_18px_50px_rgba(15,23,42,0.26)] backdrop-blur-xl"; + } + if (tone === "accent") { + return "bg-blue-600/88 border border-blue-300/30 text-white shadow-[0_20px_56px_rgba(37,99,235,0.28)] backdrop-blur-xl"; + } + return "bg-white/84 border border-white/50 text-slate-950 shadow-[0_20px_56px_rgba(15,23,42,0.18)] backdrop-blur-xl"; + } + + if (variant === "ghost") { + return "bg-transparent text-slate-900"; + } + + return tone === "dark" + ? "bg-slate-900 text-white" + : tone === "accent" + ? "bg-blue-600 text-white" + : "bg-white text-slate-950"; +} + +function renderNode(node: DetailUiDslNode): JSX.Element { + if (node.kind === "panel") { + return ( +
+ {node.children.map(renderNode)} +
+ ); + } + + if (node.kind === "stack") { + return ( +
+ {node.children.map(renderNode)} +
+ ); + } + + if (node.kind === "row") { + const columns = Math.max(1, node.columns || node.children.length || 1); + return ( +
+ {node.children.map(renderNode)} +
+ ); + } + + if (node.kind === "badge") { + return ( +
+ {iconFor(node.icon, 14)} + {node.text} +
+ ); + } + + if (node.kind === "heading") { + return ( +
+ {node.text} +
+ ); + } + + if (node.kind === "body") { + return ( +
+ {node.text} +
+ ); + } + + if (node.kind === "feature-card") { + return ( +
+ {node.tag ? ( +
+ {iconFor(node.icon, 12)} + {node.tag} +
+ ) : null} +
{node.title}
+ {node.body ? ( +
+ {node.body} +
+ ) : null} +
+ ); + } + + if (node.kind === "spec-table") { + return ( +
+ {node.rows.map((row, index) => ( +
0 && "border-t border-slate-100/90", + )} + > +
+ {row.label} +
+
+ {row.value} +
+
+ ))} +
+ ); + } + + if (node.kind === "compare") { + return ( +
+
+
+ {node.before.title} +
+
+ {node.before.body} +
+
+
+
+ {node.after.title} +
+
+ {node.after.body} +
+
+
+ ); + } + + if (node.kind === "proof") { + return ( +
+ {iconFor(node.icon, 15)} + {node.text} +
+ ); + } + + if (node.kind === "cta") { + return ( +
+ {node.headline ? ( +
{node.headline}
+ ) : null} +
+
+ {node.buttonText} + +
+ {node.supportText ? ( +
+ {node.supportText} +
+ ) : null} +
+
+ ); + } + + return
; +} + +export function DetailUiDomRenderer(params: { + document: DetailUiDslV2Document | null; + imageMetrics?: { + offsetX: number; + offsetY: number; + renderedWidth: number; + renderedHeight: number; + } | null; + className?: string; +}) { + if (!params.document) return null; + + const renderWidth = + params.imageMetrics?.renderedWidth && params.imageMetrics.renderedWidth > 0 + ? params.imageMetrics.renderedWidth + : params.document.screenSize.width; + const renderHeight = + params.imageMetrics?.renderedHeight && params.imageMetrics.renderedHeight > 0 + ? params.imageMetrics.renderedHeight + : params.document.screenSize.height; + const scale = Math.max( + 0.55, + Math.min( + 2.4, + Math.min( + renderWidth / Math.max(1, params.document.screenSize.width), + renderHeight / Math.max(1, params.document.screenSize.height), + ), + ), + ); + + const containerStyle: CSSProperties = + params.imageMetrics && params.imageMetrics.renderedWidth > 0 && params.imageMetrics.renderedHeight > 0 + ? { + position: "absolute", + left: `${params.imageMetrics.offsetX}px`, + top: `${params.imageMetrics.offsetY}px`, + width: `${params.imageMetrics.renderedWidth}px`, + height: `${params.imageMetrics.renderedHeight}px`, + } + : { + position: "absolute", + inset: "0px", + }; + + const region = normalizedRegionToPixels({ + region: params.document.region, + width: renderWidth, + height: renderHeight, + }); + + return ( +
+
+
+ {params.document.nodes.map((node) => renderNode(node))} +
+
+
+ ); +} diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 5a94c3fd3..b9f7ec11d 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -36,8 +36,10 @@ import { SceneFusionDialog } from "@/components/scene-fusion-dialog"; import { ImageAnnotator } from "@/components/image-annotator"; import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecutionSnapshotDialog"; import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer"; +import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer"; import type { PickedObject } from "@/store/canvas"; import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter"; +import { buildDetailUiDslV2 } from "@/lib/detail-ui-dsl-v2-builder"; import { normalizeDetailUiSubjectAnalysis, type DetailUiSubjectAnalysis, @@ -381,6 +383,13 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { const detailSubjectAnalysis = useMemo(() => { return normalizeDetailUiSubjectAnalysis(data.metadata?.detailSubjectAnalysis); }, [data.metadata]); + const detailUiDslDocument = useMemo(() => { + if (!detailUiScreenOverlay) return null; + return buildDetailUiDslV2({ + overlay: detailUiScreenOverlay, + subjectAnalysis: detailSubjectAnalysis, + }); + }, [detailSubjectAnalysis, detailUiScreenOverlay]); const shouldRenderDetailOverlay = data.metadata?.detailGenerationMode === "agent_layout" && Boolean(detailUiScreenOverlay); const shortAgentExecutionSnapshotId = agentExecutionSnapshotId @@ -2545,11 +2554,18 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { }} /> {shouldRenderDetailOverlay ? ( - + detailUiDslDocument ? ( + + ) : ( + + ) ) : null} )} diff --git a/lib/detail-ui-dsl-v2-builder.ts b/lib/detail-ui-dsl-v2-builder.ts new file mode 100644 index 000000000..0a2e8d45c --- /dev/null +++ b/lib/detail-ui-dsl-v2-builder.ts @@ -0,0 +1,302 @@ +import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter"; +import type { DetailUiSubjectAnalysis } from "@/lib/detail-ui-subject-analysis"; +import type { DetailUiDslRegion, DetailUiDslV2Document, DetailUiDslNode } from "@/lib/detail-ui-dsl-v2"; + +type ScreenData = NonNullable; + +function splitCopy(text?: string) { + const parts = String(text || "") + .split(/[。\n!!??;;]+/g) + .map((item) => item.replace(/\s+/g, " ").trim()) + .filter(Boolean); + + if (parts.length === 0) return []; + if (parts.length <= 4) return parts; + return [ + parts.slice(0, 2).join(" · "), + parts.slice(2, 4).join(" · "), + parts.slice(4).join(" · "), + ].filter(Boolean); +} + +function pickLayoutVariant(params: { + screenIndex: number; + totalScreens?: number; + screenType?: string; + subject?: DetailUiSubjectAnalysis | null; +}) { + if ((params.totalScreens || 0) > 0 && params.screenIndex === params.totalScreens) { + return "cta-focus" as const; + } + if (params.subject?.dominantHorizontal === "left") { + return "hero-right-panel" as const; + } + if (params.subject?.dominantHorizontal === "right") { + return "hero-left-panel" as const; + } + if (params.screenType === "C") { + return "floating-split" as const; + } + return "top-banner-bottom-grid" as const; +} + +function normalizedRegionToPixels(region: DetailUiDslRegion, width: number, height: number) { + return { + x: Math.round(region.x * width), + y: Math.round(region.y * height), + width: Math.round(region.width * width), + height: Math.round(region.height * height), + }; +} + +function pickRegion(params: { + variant: DetailUiDslV2Document["layoutVariant"]; + subject?: DetailUiSubjectAnalysis | null; +}) { + if (params.variant === "hero-right-panel") { + return params.subject?.safeZones.right || { + x: 0.54, + y: 0.06, + width: 0.38, + height: 0.84, + }; + } + if (params.variant === "hero-left-panel") { + return params.subject?.safeZones.left || { + x: 0.06, + y: 0.06, + width: 0.38, + height: 0.84, + }; + } + if (params.variant === "cta-focus") { + return params.subject?.safeZones.right || params.subject?.safeZones.left || { + x: 0.52, + y: 0.10, + width: 0.40, + height: 0.80, + }; + } + if (params.variant === "floating-split") { + return params.subject?.safeZones.top || { + x: 0.06, + y: 0.05, + width: 0.88, + height: 0.36, + }; + } + return params.subject?.safeZones.top || { + x: 0.06, + y: 0.05, + width: 0.88, + height: 0.46, + }; +} + +function buildFeatureNodes(params: { + screen: ScreenData; +}) { + const chunks = splitCopy(params.screen.copy); + const featureSource = + chunks.length > 0 ? chunks : [params.screen.subtitle || params.screen.title].filter(Boolean); + + return featureSource.slice(0, 3).map((item, index) => { + const tone = index === 0 ? "primary" : index === 1 ? "secondary" : "neutral"; + return { + kind: "feature-card", + id: `feature-${index + 1}`, + icon: "sparkle", + tone, + tag: index === 0 ? "核心升级" : index === 1 ? "场景收益" : "舒适证明", + title: item, + body: + index === 0 + ? params.screen.subtitle || params.screen.objective || "" + : index === 1 + ? params.screen.objective || params.screen.layoutNote || "" + : params.screen.layoutNote || params.screen.subtitle || "", + } satisfies DetailUiDslNode; + }); +} + +function buildSpecRows(screen: ScreenData) { + return [ + { label: "核心卖点", value: screen.title || "核心升级" }, + ...(screen.objective ? [{ label: "适用场景", value: screen.objective }] : []), + ...(screen.subtitle ? [{ label: "结果表达", value: screen.subtitle }] : []), + ...(screen.layoutNote ? [{ label: "设计要求", value: screen.layoutNote }] : []), + ]; +} + +export function buildDetailUiDslV2(params: { + overlay: DetailUiScreenOverlaySnapshot; + subjectAnalysis?: DetailUiSubjectAnalysis | null; +}) { + const screen = params.overlay.dataModel.screen; + if (!screen) return null; + + const totalScreens = Number(params.overlay.surfaceId.split("-")[3] || 0) || undefined; + const variant = pickLayoutVariant({ + screenIndex: params.overlay.screenIndex, + totalScreens, + screenType: screen.screenType, + subject: params.subjectAnalysis, + }); + const region = pickRegion({ + variant, + subject: params.subjectAnalysis, + }); + + const nodes: DetailUiDslNode[] = []; + + const heroPanel: DetailUiDslNode = { + kind: "panel", + id: "hero-panel", + variant: "glass", + tone: variant === "hero-right-panel" ? "light" : "dark", + direction: "column", + gap: 18, + children: [ + { + kind: "badge", + id: "screen-badge", + icon: "sparkle", + text: + screen.screenType === "A" + ? "技术证明" + : screen.screenType === "B" + ? "场景共鸣" + : screen.screenType === "C" + ? "细节证明" + : `第${screen.index}屏`, + tone: screen.screenType === "A" ? "neutral" : "primary", + }, + { + kind: "heading", + id: "hero-title", + text: screen.title || "", + level: 1, + emphasis: "hero", + }, + ...(screen.subtitle + ? [ + { + kind: "body", + id: "hero-subtitle", + text: screen.subtitle, + emphasis: "strong", + } satisfies DetailUiDslNode, + ] + : []), + ], + }; + + nodes.push(heroPanel); + + const featureNodes = buildFeatureNodes({ screen }); + const specRows = buildSpecRows(screen); + + if (variant === "floating-split") { + nodes.push({ + kind: "row", + id: "compare-row", + columns: 2, + gap: 16, + children: [ + { + kind: "compare", + id: "compare-panel", + before: { + title: "旧方式", + body: featureNodes[0]?.title || "久坐容易疲劳、支撑不足", + }, + after: { + title: "现在", + body: featureNodes[1]?.title || screen.subtitle || screen.title, + }, + }, + { + kind: "stack", + id: "support-stack", + gap: 14, + children: [ + ...(featureNodes.slice(0, 2) as DetailUiDslNode[]), + { + kind: "proof", + id: "proof-bar", + icon: "shield", + text: screen.layoutNote || screen.objective || screen.subtitle || screen.title, + }, + ], + }, + ], + }); + } else if (variant === "hero-left-panel" || variant === "hero-right-panel") { + nodes.push({ + kind: "stack", + id: "support-column", + gap: 16, + children: [ + ...featureNodes, + ...(screen.screenType === "A" + ? [ + { + kind: "spec-table", + id: "spec-table", + rows: specRows, + } satisfies DetailUiDslNode, + ] + : []), + { + kind: "proof", + id: "proof-bar", + icon: "shield", + text: screen.layoutNote || screen.objective || screen.subtitle || screen.title, + }, + ], + }); + } else { + nodes.push({ + kind: "stack", + id: "feature-grid-stack", + gap: 16, + children: [ + { + kind: "row", + id: "feature-row", + columns: Math.min(3, Math.max(1, featureNodes.length)), + gap: 12, + children: featureNodes as DetailUiDslNode[], + }, + { + kind: "proof", + id: "proof-bar", + icon: "shield", + text: screen.layoutNote || screen.objective || screen.subtitle || screen.title, + }, + ], + }); + } + + if (variant === "cta-focus" || /(下单|购买|转化|cta)/i.test(screen.layoutNote || "")) { + nodes.push({ + kind: "cta", + id: "cta-section", + headline: screen.title, + buttonText: screen.screenType === "C" ? "立即对比体验" : "立即了解详情", + supportText: screen.subtitle || screen.objective || "继续查看完整卖点与场景说明", + }); + } + + return { + version: "detail-ui-dsl/v2", + surfaceId: params.overlay.surfaceId, + screenIndex: params.overlay.screenIndex, + generationMode: params.overlay.generationMode, + screenSize: params.overlay.screenSize, + designStyle: params.overlay.dataModel.globalStyle, + region, + layoutVariant: variant, + nodes, + } satisfies DetailUiDslV2Document; +} diff --git a/lib/detail-ui-dsl-v2.ts b/lib/detail-ui-dsl-v2.ts new file mode 100644 index 000000000..9c33b37d8 --- /dev/null +++ b/lib/detail-ui-dsl-v2.ts @@ -0,0 +1,106 @@ +import type { DetailUiGlobalStyle } from "@/lib/detail-ui-protocol"; + +export type DetailUiDslRegion = { + x: number; + y: number; + width: number; + height: number; +}; + +export type DetailUiDslNode = + | { + kind: "panel"; + id: string; + variant?: "glass" | "solid" | "ghost"; + tone?: "light" | "dark" | "accent"; + region?: DetailUiDslRegion; + direction?: "column" | "row"; + gap?: number; + children: DetailUiDslNode[]; + } + | { + kind: "stack"; + id: string; + gap?: number; + children: DetailUiDslNode[]; + } + | { + kind: "row"; + id: string; + gap?: number; + columns?: number; + children: DetailUiDslNode[]; + } + | { + kind: "badge"; + id: string; + icon?: "sparkle" | "shield" | "arrow" | "check"; + text: string; + tone?: "primary" | "secondary" | "neutral"; + } + | { + kind: "heading"; + id: string; + text: string; + level?: 1 | 2 | 3; + emphasis?: "hero" | "section"; + } + | { + kind: "body"; + id: string; + text: string; + emphasis?: "normal" | "muted" | "strong"; + } + | { + kind: "feature-card"; + id: string; + icon?: "sparkle" | "shield" | "arrow" | "check"; + tone?: "primary" | "secondary" | "neutral"; + tag?: string; + title: string; + body?: string; + } + | { + kind: "spec-table"; + id: string; + rows: Array<{ label: string; value: string }>; + } + | { + kind: "compare"; + id: string; + before: { title: string; body: string }; + after: { title: string; body: string }; + } + | { + kind: "proof"; + id: string; + icon?: "shield" | "check" | "sparkle"; + text: string; + } + | { + kind: "cta"; + id: string; + headline?: string; + buttonText: string; + supportText?: string; + }; + +export interface DetailUiDslV2Document { + version: "detail-ui-dsl/v2"; + surfaceId: string; + screenIndex: number; + generationMode: "classic" | "agent_layout"; + screenSize: { + width: number; + height: number; + }; + designStyle?: DetailUiGlobalStyle; + region: DetailUiDslRegion; + layoutVariant: + | "hero-left-panel" + | "hero-right-panel" + | "top-banner-bottom-grid" + | "floating-split" + | "cta-focus"; + nodes: DetailUiDslNode[]; +}