feat: add detail ui dsl v2 renderer
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-22 12:23:37 +08:00
parent ac02644d3f
commit 05cf3ae54e
4 changed files with 789 additions and 5 deletions

View File

@ -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 <ShieldCheck style={style} />;
if (name === "arrow") return <ArrowRight style={style} />;
if (name === "check") return <CheckCircle2 style={style} />;
return <Sparkles style={style} />;
}
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 (
<div
key={node.id}
className={cn(
"rounded-[2em] p-[1.15em]",
panelClass(node.variant, node.tone),
node.direction === "row" ? "flex flex-row" : "flex flex-col",
)}
style={{
gap: `${node.gap || 16}px`,
}}
>
{node.children.map(renderNode)}
</div>
);
}
if (node.kind === "stack") {
return (
<div
key={node.id}
className="flex flex-col"
style={{ gap: `${node.gap || 12}px` }}
>
{node.children.map(renderNode)}
</div>
);
}
if (node.kind === "row") {
const columns = Math.max(1, node.columns || node.children.length || 1);
return (
<div
key={node.id}
className="grid"
style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gap: `${node.gap || 12}px`,
}}
>
{node.children.map(renderNode)}
</div>
);
}
if (node.kind === "badge") {
return (
<div
key={node.id}
className={cn(
"inline-flex w-fit items-center gap-[0.55em] rounded-full border px-[0.9em] py-[0.45em] text-[0.8em] font-extrabold tracking-[0.02em]",
node.tone === "secondary"
? "border-violet-200/70 bg-violet-100/90 text-violet-700"
: node.tone === "neutral"
? "border-slate-200/70 bg-slate-100/95 text-slate-700"
: "border-blue-200/70 bg-blue-100/95 text-blue-700",
)}
>
{iconFor(node.icon, 14)}
<span>{node.text}</span>
</div>
);
}
if (node.kind === "heading") {
return (
<div
key={node.id}
className={cn(
"font-black leading-[0.98] tracking-[-0.04em]",
node.emphasis === "hero" ? "text-[3.5em]" : "text-[2em]",
)}
>
{node.text}
</div>
);
}
if (node.kind === "body") {
return (
<div
key={node.id}
className={cn(
"leading-[1.35]",
node.emphasis === "strong"
? "text-[1.15em] font-bold opacity-95"
: node.emphasis === "muted"
? "text-[0.95em] opacity-75"
: "text-[1em] font-semibold opacity-90",
)}
>
{node.text}
</div>
);
}
if (node.kind === "feature-card") {
return (
<div
key={node.id}
className={cn(
"flex h-full min-h-[7.2em] flex-col rounded-[1.45em] border px-[1.15em] py-[1.05em] shadow-[0_14px_36px_rgba(15,23,42,0.10)]",
node.tone === "secondary"
? "border-violet-200/70 bg-violet-50/95 text-slate-900"
: node.tone === "neutral"
? "border-slate-200/70 bg-slate-50/95 text-slate-900"
: "border-blue-200/70 bg-white/96 text-slate-900",
)}
>
{node.tag ? (
<div
className={cn(
"mb-[0.85em] inline-flex w-fit items-center gap-[0.45em] rounded-full px-[0.75em] py-[0.3em] text-[0.72em] font-extrabold",
node.tone === "secondary"
? "bg-violet-100 text-violet-700"
: node.tone === "neutral"
? "bg-slate-100 text-slate-700"
: "bg-blue-100 text-blue-700",
)}
>
{iconFor(node.icon, 12)}
<span>{node.tag}</span>
</div>
) : null}
<div className="text-[1.02em] font-extrabold leading-[1.15]">{node.title}</div>
{node.body ? (
<div className="mt-[0.7em] text-[0.8em] font-semibold leading-[1.32] text-slate-600">
{node.body}
</div>
) : null}
</div>
);
}
if (node.kind === "spec-table") {
return (
<div
key={node.id}
className="overflow-hidden rounded-[1.4em] border border-blue-100/70 bg-white/96 shadow-[0_14px_38px_rgba(15,23,42,0.10)]"
>
{node.rows.map((row, index) => (
<div
key={`${node.id}-${index}`}
className={cn(
"grid grid-cols-[7.5em_1fr] gap-[0.55em] px-[1em] py-[0.8em]",
index > 0 && "border-t border-slate-100/90",
)}
>
<div className="rounded-[0.8em] bg-slate-100 px-[0.8em] py-[0.55em] text-center text-[0.76em] font-extrabold text-slate-700">
{row.label}
</div>
<div className="rounded-[0.8em] bg-white px-[0.8em] py-[0.55em] text-[0.82em] font-semibold text-slate-900">
{row.value}
</div>
</div>
))}
</div>
);
}
if (node.kind === "compare") {
return (
<div key={node.id} className="grid grid-cols-2 gap-[0.85em]">
<div className="rounded-[1.45em] border border-slate-200/80 bg-white/95 px-[1.05em] py-[1em] shadow-[0_12px_28px_rgba(15,23,42,0.08)]">
<div className="text-[0.72em] font-black uppercase tracking-[0.08em] text-slate-500">
{node.before.title}
</div>
<div className="mt-[0.9em] text-[0.86em] font-semibold leading-[1.35] text-slate-700">
{node.before.body}
</div>
</div>
<div className="rounded-[1.45em] border border-blue-200/80 bg-blue-600/94 px-[1.05em] py-[1em] text-white shadow-[0_18px_40px_rgba(37,99,235,0.22)]">
<div className="text-[0.72em] font-black uppercase tracking-[0.08em] text-blue-100">
{node.after.title}
</div>
<div className="mt-[0.9em] text-[0.86em] font-semibold leading-[1.35]">
{node.after.body}
</div>
</div>
</div>
);
}
if (node.kind === "proof") {
return (
<div
key={node.id}
className="inline-flex w-fit items-center gap-[0.7em] rounded-[1.1em] border border-slate-200/80 bg-white/92 px-[1em] py-[0.72em] text-[0.8em] font-bold text-slate-800 shadow-[0_12px_26px_rgba(15,23,42,0.08)]"
>
{iconFor(node.icon, 15)}
<span>{node.text}</span>
</div>
);
}
if (node.kind === "cta") {
return (
<div
key={node.id}
className="rounded-[1.7em] border border-white/16 bg-slate-900/78 p-[1.05em] text-white shadow-[0_20px_46px_rgba(15,23,42,0.24)] backdrop-blur-xl"
>
{node.headline ? (
<div className="mb-[0.75em] text-[1em] font-black leading-[1.1]">{node.headline}</div>
) : null}
<div className="flex items-center gap-[0.8em]">
<div className="inline-flex min-w-[10.2em] items-center justify-center gap-[0.5em] rounded-full border border-white/20 bg-blue-600 px-[1.2em] py-[0.9em] text-[0.82em] font-black text-white shadow-[0_14px_34px_rgba(37,99,235,0.28)]">
<span>{node.buttonText}</span>
<ArrowRight style={{ width: 16, height: 16 }} />
</div>
{node.supportText ? (
<div className="text-[0.78em] font-semibold leading-[1.28] text-slate-200">
{node.supportText}
</div>
) : null}
</div>
</div>
);
}
return <div />;
}
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 (
<div
className={cn("pointer-events-none z-[122]", params.className)}
data-canvas-no-drag="true"
style={containerStyle}
>
<div
className="absolute"
style={{
left: `${region.x}px`,
top: `${region.y}px`,
width: `${region.width}px`,
height: `${region.height}px`,
fontSize: `${16 * scale}px`,
}}
>
<div
className={cn(
"flex h-full w-full flex-col",
params.document.layoutVariant === "floating-split" ||
params.document.layoutVariant === "cta-focus"
? "justify-between"
: "gap-[1em]",
)}
>
{params.document.nodes.map((node) => renderNode(node))}
</div>
</div>
</div>
);
}

View File

@ -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 ? (
<DetailSurfaceOverlayRenderer
overlay={detailUiScreenOverlay}
imageMetrics={boxCutoutMetrics}
subjectAnalysis={detailSubjectAnalysis}
/>
detailUiDslDocument ? (
<DetailUiDomRenderer
document={detailUiDslDocument}
imageMetrics={boxCutoutMetrics}
/>
) : (
<DetailSurfaceOverlayRenderer
overlay={detailUiScreenOverlay}
imageMetrics={boxCutoutMetrics}
subjectAnalysis={detailSubjectAnalysis}
/>
)
) : null}
</>
)}

View File

@ -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<DetailUiScreenOverlaySnapshot["dataModel"]["screen"]>;
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;
}

106
lib/detail-ui-dsl-v2.ts Normal file
View File

@ -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[];
}