feat: add detail ui dsl v2 renderer
This commit is contained in:
parent
ac02644d3f
commit
05cf3ae54e
360
components/canvas/DetailUiDomRenderer.tsx
Normal file
360
components/canvas/DetailUiDomRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
</>
|
||||
)}
|
||||
|
||||
302
lib/detail-ui-dsl-v2-builder.ts
Normal file
302
lib/detail-ui-dsl-v2-builder.ts
Normal 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
106
lib/detail-ui-dsl-v2.ts
Normal 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[];
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user