feat: upgrade detail section component 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-21 23:35:48 +08:00
parent d8aa1d1661
commit a1b6496976
6 changed files with 628 additions and 83 deletions

View File

@ -32,15 +32,42 @@ function getActionClassName(action?: DetailSurfaceInspectorReviewState["action"]
return "bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300";
}
function collectRenderableScreenComponents(surface: UiSurfaceSnapshot, parentId: string) {
const result = [] as typeof surface.components;
const queue = [...getUiSurfaceChildren(surface, parentId)];
while (queue.length > 0) {
const component = queue.shift();
if (!component) continue;
if (
component.type === "detail_text_block" ||
component.type === "detail_card" ||
component.type === "detail_badge"
) {
result.push(component);
continue;
}
queue.push(...getUiSurfaceChildren(surface, component.id));
}
return result;
}
export function DetailSurfaceInspectorPanel(params: {
surface: UiSurfaceSnapshot | null;
latestScreenValidation?: DetailSurfaceInspectorReviewState | null;
}) {
if (!params.surface) return null;
const surface = params.surface;
const screenComponents = getUiSurfaceScreenComponents(params.surface);
const totalComponents = countUiSurfaceComponents(params.surface);
const totalTextBlocks = countUiSurfaceComponents(params.surface, "detail_text_block");
const screenComponents = getUiSurfaceScreenComponents(surface);
const totalRenderableBlocks =
countUiSurfaceComponents(surface, "detail_text_block") +
countUiSurfaceComponents(surface, "detail_card") +
countUiSurfaceComponents(surface, "detail_badge");
const totalSections = countUiSurfaceComponents(surface, "detail_section");
const totalScreens = screenComponents.length;
return (
@ -51,12 +78,12 @@ export function DetailSurfaceInspectorPanel(params: {
<div>
<p className="text-[11px] font-medium text-foreground">Detail Surface Inspector</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">
<span className="font-mono">{params.surface.surfaceId}</span>
<span className="font-mono">{surface.surfaceId}</span>
</p>
</div>
</div>
<span className="rounded-full bg-violet-50 px-2 py-0.5 text-[10px] font-medium text-violet-700 ring-1 ring-inset ring-violet-500/20 dark:bg-violet-500/10 dark:text-violet-300">
{params.surface.generationMode === "agent_layout" ? "AGENT Surface" : "Classic Surface"}
{surface.generationMode === "agent_layout" ? "AGENT Surface" : "Classic Surface"}
</span>
</div>
@ -71,16 +98,16 @@ export function DetailSurfaceInspectorPanel(params: {
<div className="rounded-lg border border-border/40 bg-background/80 px-3 py-2 dark:bg-zinc-950/40">
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<Blocks className="h-3.5 w-3.5" />
Components
Sections
</div>
<div className="mt-1 text-sm font-semibold text-foreground">{totalComponents}</div>
<div className="mt-1 text-sm font-semibold text-foreground">{totalSections}</div>
</div>
<div className="rounded-lg border border-border/40 bg-background/80 px-3 py-2 dark:bg-zinc-950/40">
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<CheckCircle2 className="h-3.5 w-3.5" />
Text Blocks
UI Blocks
</div>
<div className="mt-1 text-sm font-semibold text-foreground">{totalTextBlocks}</div>
<div className="mt-1 text-sm font-semibold text-foreground">{totalRenderableBlocks}</div>
</div>
</div>
@ -88,38 +115,44 @@ export function DetailSurfaceInspectorPanel(params: {
<div>
<div className="text-[10px] text-muted-foreground">Surface Validation</div>
<div className="mt-0.5 text-[11px] font-medium text-foreground">
{params.surface.validation?.passed === false
? params.surface.validation.issues[0] || "存在待优化项"
{surface.validation?.passed === false
? surface.validation.issues[0] || "存在待优化项"
: "当前 surface 已通过基础校验"}
</div>
</div>
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-medium",
params.surface.validation?.passed === false
surface.validation?.passed === false
? "bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-500/20 dark:bg-amber-500/10 dark:text-amber-300"
: "bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300",
)}
>
{params.surface.validation?.passed === false ? "待优化" : "通过"}
{surface.validation?.passed === false ? "待优化" : "通过"}
</span>
</div>
<div className="mt-3 space-y-2">
{screenComponents.slice(0, 6).map((screenComponent) => {
const screenIndex = Number(screenComponent.props?.screenIndex || 0);
const textStack = getUiSurfaceChildren(params.surface, screenComponent.id).find(
const textStack = getUiSurfaceChildren(surface, screenComponent.id).find(
(component) => component.type === "detail_text_stack",
);
const textBlocks = textStack
? getUiSurfaceChildren(params.surface, textStack.id).filter(
(component) => component.type === "detail_text_block",
)
? collectRenderableScreenComponents(surface, textStack.id)
: [];
const titleBlock =
textBlocks.find((component) => component.props?.blockType === "title") || null;
textBlocks.find(
(component) =>
component.metadata?.componentRole === "title" ||
component.props?.blockType === "title",
) || null;
const subtitleBlock =
textBlocks.find((component) => component.props?.blockType === "subtitle") || null;
textBlocks.find(
(component) =>
component.metadata?.componentRole === "subtitle" ||
component.props?.blockType === "subtitle",
) || null;
const isLatestScreen = params.latestScreenValidation?.screenIndex === screenIndex;
return (
@ -139,11 +172,11 @@ export function DetailSurfaceInspectorPanel(params: {
: ""}
</div>
<div className="mt-0.5 truncate text-[11px] text-foreground/85">
{resolveUiSurfaceTextBinding(params.surface, titleBlock, "content") || "未定义标题"}
{resolveUiSurfaceTextBinding(surface, titleBlock, "content") || "未定义标题"}
</div>
{subtitleBlock ? (
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
{resolveUiSurfaceTextBinding(params.surface, subtitleBlock, "content")}
{resolveUiSurfaceTextBinding(surface, subtitleBlock, "content")}
</div>
) : null}
</div>

View File

@ -46,6 +46,41 @@ export function TextNode({ node, isSelected }: TextNodeProps) {
setIsEditing(true);
};
const containerStyle = data.transparent
? undefined
: {
backgroundColor: data.backgroundColor || undefined,
borderColor: data.borderColor || undefined,
borderWidth:
typeof data.borderWidth === "number" ? `${data.borderWidth}px` : undefined,
borderStyle:
typeof data.borderWidth === "number" && data.borderWidth > 0 ? "solid" : undefined,
borderRadius:
typeof data.borderRadius === "number" ? `${data.borderRadius}px` : undefined,
padding: typeof data.padding === "number" ? `${data.padding}px` : undefined,
boxShadow: data.boxShadow || undefined,
backdropFilter:
typeof data.backdropBlur === "number" && data.backdropBlur > 0
? `blur(${data.backdropBlur}px)`
: undefined,
WebkitBackdropFilter:
typeof data.backdropBlur === "number" && data.backdropBlur > 0
? `blur(${data.backdropBlur}px)`
: undefined,
};
const textStyle = {
fontSize: data.fontSize || 14,
fontFamily: data.fontFamily || undefined,
color: data.color || "inherit",
fontWeight: data.fontWeight || "normal",
lineHeight: data.lineHeight || 1.35,
letterSpacing:
typeof data.letterSpacing === "number" ? `${data.letterSpacing}px` : undefined,
textAlign: data.textAlign || "left",
textTransform: data.textTransform || "none",
} as const;
return (
<div
className={cn(
@ -56,6 +91,7 @@ export function TextNode({ node, isSelected }: TextNodeProps) {
isSelected && (data.transparent ? "ring-1 ring-primary/50" : "border-primary")
)}
onDoubleClick={handleDoubleClick}
style={containerStyle}
>
{isEditing ? (
<textarea
@ -72,24 +108,12 @@ export function TextNode({ node, isSelected }: TextNodeProps) {
}
}}
className="w-full h-full resize-none bg-transparent outline-none text-sm"
style={{
fontSize: data.fontSize || 14,
color: data.color || 'inherit',
fontWeight: data.fontWeight || 'normal',
lineHeight: data.lineHeight || 1.35,
textAlign: data.textAlign || "left",
}}
style={textStyle}
/>
) : (
<div
className="w-full h-full overflow-auto text-sm whitespace-pre-wrap"
style={{
fontSize: data.fontSize || 14,
color: data.color || 'inherit',
fontWeight: data.fontWeight || 'normal',
lineHeight: data.lineHeight || 1.35,
textAlign: data.textAlign || "left",
}}
style={textStyle}
>
{data.content || '双击编辑文本'}
</div>

View File

@ -29,10 +29,21 @@ function createTextNodeDraft(params: {
height: number;
content: string;
fontSize: number;
fontFamily?: string;
fontWeight: string;
color: string;
lineHeight: number;
letterSpacing?: number;
textAlign: "left" | "center" | "right";
transparent?: boolean;
backgroundColor?: string;
borderColor?: string;
borderWidth?: number;
borderRadius?: number;
padding?: number;
boxShadow?: string;
backdropBlur?: number;
textTransform?: "none" | "uppercase" | "lowercase" | "capitalize";
metadata?: Record<string, unknown>;
}): DetailTextLayerDraft {
return {
@ -42,11 +53,21 @@ function createTextNodeDraft(params: {
data: {
content: params.content,
fontSize: params.fontSize,
fontFamily: params.fontFamily,
fontWeight: params.fontWeight,
color: params.color,
lineHeight: params.lineHeight,
letterSpacing: params.letterSpacing,
textAlign: params.textAlign,
transparent: true,
transparent: params.transparent ?? true,
backgroundColor: params.backgroundColor,
borderColor: params.borderColor,
borderWidth: params.borderWidth,
borderRadius: params.borderRadius,
padding: params.padding,
boxShadow: params.boxShadow,
backdropBlur: params.backdropBlur,
textTransform: params.textTransform,
metadata: params.metadata,
} as TextNodeData & { metadata?: Record<string, unknown> },
connections: [],
@ -119,6 +140,43 @@ export function buildDetailTextLayerNodesFromReviewPatch(params: {
});
}
function collectRenderableSurfaceComponents(params: {
surface: UiSurfaceSnapshot;
parentId: string;
}) {
const collected: UiSurfaceSnapshot["components"] = [];
const queue = getUiSurfaceChildren(params.surface, params.parentId)
.sort((left, right) => {
const leftZ = Number(left.layout?.zIndex || 0);
const rightZ = Number(right.layout?.zIndex || 0);
return leftZ - rightZ;
});
while (queue.length > 0) {
const component = queue.shift();
if (!component) continue;
if (
component.type === "detail_card" ||
component.type === "detail_badge" ||
component.type === "detail_text_block"
) {
collected.push(component);
continue;
}
const children = getUiSurfaceChildren(params.surface, component.id)
.sort((left, right) => {
const leftZ = Number(left.layout?.zIndex || 0);
const rightZ = Number(right.layout?.zIndex || 0);
return leftZ - rightZ;
});
queue.unshift(...children);
}
return collected;
}
export function buildDetailTextLayerNodesFromSurfaceScreen(params: {
parentNodeId: string;
parentPosition: { x: number; y: number };
@ -144,8 +202,10 @@ export function buildDetailTextLayerNodesFromSurfaceScreen(params: {
const scaleX = params.parentSize.width / Math.max(1, params.surface.screenSize.width);
const scaleY = params.parentSize.height / Math.max(1, params.surface.screenSize.height);
return getUiSurfaceChildren(params.surface, textStack.id)
.filter((component) => component.type === "detail_text_block")
return collectRenderableSurfaceComponents({
surface: params.surface,
parentId: textStack.id,
})
.map((component) => {
const frame = (component.layout?.frame || {}) as {
x?: number;
@ -160,18 +220,42 @@ export function buildDetailTextLayerNodesFromSurfaceScreen(params: {
y: params.parentPosition.y + Math.round(Number(frame.y || 0) * scaleY),
width: Math.max(120, Math.round(Number(frame.width || 240) * scaleX)),
height: Math.max(48, Math.round(Number(frame.height || 60) * scaleY)),
content: resolveUiSurfaceTextBinding(params.surface, component, "content"),
content:
resolveUiSurfaceTextBinding(params.surface, component, "content") ||
String(component.props?.content || "").trim(),
fontSize: Math.max(
12,
Math.round(Number(style.fontSize || 24) * Math.min(scaleX, scaleY)),
),
fontFamily: typeof style.fontFamily === "string" ? style.fontFamily : undefined,
fontWeight: String(style.fontWeight || "500"),
color: String(style.color || "#111827"),
lineHeight: Number(style.lineHeight || 1.3),
letterSpacing:
typeof style.letterSpacing === "number" ? style.letterSpacing : undefined,
textAlign:
style.textAlign === "center" || style.textAlign === "right"
? style.textAlign
: "left",
transparent: component.type === "detail_text_block" ? true : false,
backgroundColor:
typeof style.backgroundColor === "string" ? style.backgroundColor : undefined,
borderColor:
typeof style.borderColor === "string" ? style.borderColor : undefined,
borderWidth:
typeof style.borderWidth === "number" ? style.borderWidth : undefined,
borderRadius:
typeof style.borderRadius === "number" ? style.borderRadius : undefined,
padding: typeof style.padding === "number" ? style.padding : undefined,
boxShadow: typeof style.boxShadow === "string" ? style.boxShadow : undefined,
backdropBlur:
typeof style.backdropBlur === "number" ? style.backdropBlur : undefined,
textTransform:
style.textTransform === "uppercase" ||
style.textTransform === "lowercase" ||
style.textTransform === "capitalize"
? style.textTransform
: "none",
metadata: {
detailUiManaged: true,
detailUiBlockId: String(component.metadata?.blockId || component.id),

View File

@ -12,12 +12,6 @@ import type {
UiSurfaceSnapshot,
} from "@/lib/ui-surface-protocol";
function mapBlockTypeToBindingKey(blockType: "title" | "subtitle" | "body") {
if (blockType === "title") return "title";
if (blockType === "subtitle") return "subtitle";
return "copy";
}
function createDetailSurfaceId(params: {
generationMode: DetailGenerationMode;
screenCount: number;
@ -36,12 +30,426 @@ function createRootComponent(): UiSurfaceComponent {
};
}
type DetailSurfaceLayoutVariant = "hero_left" | "hero_right" | "split_bottom";
function splitBodyCopyIntoHighlights(copy?: string) {
const segments = String(copy || "")
.split(/[。\n!!??;;]+/g)
.map((segment) => segment.replace(/\s+/g, " ").trim())
.filter(Boolean);
if (segments.length === 0) return [];
if (segments.length <= 3) return segments;
return [
segments.slice(0, 2).join(" · "),
segments.slice(2, 4).join(" · "),
segments.slice(4).join(" · "),
].filter(Boolean);
}
function pickScreenLayoutVariant(params: {
screenIndex: number;
screenType?: string;
}): DetailSurfaceLayoutVariant {
if (params.screenIndex === 1 || params.screenType === "B") {
return "hero_left";
}
if (params.screenType === "A") {
return "hero_right";
}
return "split_bottom";
}
function buildScreenBadgeLabel(params: {
screenIndex: number;
screenType?: string;
objective?: string;
}) {
const normalizedObjective = String(params.objective || "").replace(/\s+/g, " ").trim();
if (normalizedObjective) return normalizedObjective.slice(0, 18);
if (params.screenType === "A") return "技术证明";
if (params.screenType === "B") return "场景共鸣";
if (params.screenType === "C") return "细节证明";
return `第 ${params.screenIndex} 屏`;
}
function createStyledSurfaceTextComponent(params: {
id: string;
type: "detail_card" | "detail_badge" | "detail_text_block";
parentId: string;
frame: { x: number; y: number; width: number; height: number };
zIndex?: number;
content?: string;
bindingPath?: string;
role: string;
style: Record<string, unknown>;
metadata?: Record<string, unknown>;
}) {
return {
id: params.id,
type: params.type,
parentId: params.parentId,
props: {
role: params.role,
content: params.content || "",
},
bindings: params.bindingPath
? {
content: {
kind: "text" as const,
path: params.bindingPath,
},
}
: undefined,
layout: {
frame: params.frame,
zIndex: params.zIndex || 1,
},
style: params.style,
metadata: params.metadata,
} satisfies UiSurfaceComponent;
}
function createSectionComponent(params: {
id: string;
parentId: string;
role: string;
frame: { x: number; y: number; width: number; height: number };
zIndex?: number;
metadata?: Record<string, unknown>;
}) {
return {
id: params.id,
type: "detail_section" as const,
parentId: params.parentId,
props: {
role: params.role,
},
layout: {
frame: params.frame,
zIndex: params.zIndex || 1,
},
metadata: params.metadata,
} satisfies UiSurfaceComponent;
}
function buildSectionedTextComponents(params: {
screenId: string;
textStackId: string;
screenIndex: number;
screenSize: { width: number; height: number };
screen: Pick<
DetailUiScreenInput,
"title" | "subtitle" | "copy" | "screenType" | "objective" | "layoutNote"
>;
screenProtocol?: DetailUiScreenProtocol;
}) {
const safeArea = params.screenProtocol?.safeArea || {
top: 64,
bottom: 360,
left: 56,
right: 56,
};
const contentWidth = Math.max(
260,
Math.round(params.screenSize.width - safeArea.left - safeArea.right),
);
const variant = pickScreenLayoutVariant({
screenIndex: params.screenIndex,
screenType: params.screen.screenType,
});
const badgeLabel = buildScreenBadgeLabel({
screenIndex: params.screenIndex,
screenType: params.screen.screenType,
objective: params.screen.objective,
});
const bodyHighlights = splitBodyCopyIntoHighlights(params.screen.copy);
const titleBlock =
params.screenProtocol?.blocks.find((block) => block.type === "title") || null;
const subtitleBlock =
params.screenProtocol?.blocks.find((block) => block.type === "subtitle") || null;
const bodyBlock =
params.screenProtocol?.blocks.find((block) => block.type === "body") || null;
const cardX =
variant === "hero_right"
? Math.round(params.screenSize.width - safeArea.right - Math.min(430, contentWidth))
: safeArea.left;
const cardWidth = Math.min(430, contentWidth);
const titleY = safeArea.top + 42;
const subtitleY = titleY + Math.max(124, Math.round(Number(titleBlock?.frame.height || 132))) + 14;
const components: UiSurfaceComponent[] = [];
components.push(
createSectionComponent({
id: `${params.screenId}-section-hero`,
parentId: params.textStackId,
role: "hero_section",
frame: {
x: cardX,
y: safeArea.top,
width: cardWidth,
height: Math.min(360, params.screenSize.height - safeArea.top - 120),
},
metadata: {
screenIndex: params.screenIndex,
variant,
},
}),
);
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-badge`,
type: "detail_badge",
parentId: `${params.screenId}-section-hero`,
frame: {
x: cardX,
y: safeArea.top,
width: Math.min(156, Math.max(104, badgeLabel.length * 18)),
height: 34,
},
zIndex: 3,
content: badgeLabel,
role: "screen_badge",
style: {
fontSize: 16,
fontWeight: "700",
color: "#f8fafc",
textAlign: "center",
lineHeight: 1.15,
letterSpacing: 0.4,
backgroundColor: "rgba(15,23,42,0.82)",
borderColor: "rgba(255,255,255,0.18)",
borderWidth: 1,
borderRadius: 999,
padding: 8,
boxShadow: "0 10px 28px rgba(15,23,42,0.20)",
backdropBlur: 8,
textTransform: "uppercase",
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "badge",
},
}),
);
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-title-card`,
type: "detail_card",
parentId: `${params.screenId}-section-hero`,
frame: {
x: cardX,
y: titleY,
width: cardWidth,
height: Math.max(132, Math.round(Number(titleBlock?.frame.height || 132) + 34)),
},
zIndex: 2,
bindingPath: `/screens/${params.screenIndex - 1}/title`,
role: "title_card",
style: {
fontFamily: titleBlock?.style.fontFamily || undefined,
fontSize: Math.max(34, Number(titleBlock?.style.fontSize || 52)),
fontWeight: titleBlock?.style.fontWeight || "800",
color: "#0f172a",
textAlign: "left",
lineHeight: 1.08,
letterSpacing: -0.8,
backgroundColor: "rgba(255,255,255,0.86)",
borderColor: "rgba(255,255,255,0.38)",
borderWidth: 1,
borderRadius: 28,
padding: 24,
boxShadow: "0 22px 58px rgba(15,23,42,0.18)",
backdropBlur: 14,
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "title",
},
}),
);
if (params.screen.subtitle) {
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-subtitle-card`,
type: "detail_card",
parentId: `${params.screenId}-section-hero`,
frame: {
x: cardX,
y: subtitleY,
width: Math.min(cardWidth, 360),
height: Math.max(72, Math.round(Number(subtitleBlock?.frame.height || 64) + 26)),
},
zIndex: 2,
bindingPath: `/screens/${params.screenIndex - 1}/subtitle`,
role: "subtitle_card",
style: {
fontFamily: subtitleBlock?.style.fontFamily || undefined,
fontSize: Math.max(22, Number(subtitleBlock?.style.fontSize || 28)),
fontWeight: subtitleBlock?.style.fontWeight || "700",
color: "#f8fafc",
textAlign: "left",
lineHeight: 1.18,
backgroundColor:
variant === "hero_right" ? "rgba(37,99,235,0.82)" : "rgba(30,41,59,0.78)",
borderColor: "rgba(255,255,255,0.18)",
borderWidth: 1,
borderRadius: 22,
padding: 18,
boxShadow: "0 18px 42px rgba(15,23,42,0.18)",
backdropBlur: 12,
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "subtitle",
},
}),
);
}
const supportingSectionY =
variant === "split_bottom" ? Math.round(params.screenSize.height * 0.72) : subtitleY + 96;
const supportingSectionHeight =
variant === "split_bottom"
? Math.min(320, params.screenSize.height - supportingSectionY - 80)
: 240;
components.push(
createSectionComponent({
id: `${params.screenId}-section-supporting`,
parentId: params.textStackId,
role: variant === "split_bottom" ? "supporting_bottom_section" : "supporting_grid",
frame: {
x: safeArea.left,
y: supportingSectionY,
width: contentWidth,
height: supportingSectionHeight,
},
zIndex: 1,
metadata: {
screenIndex: params.screenIndex,
variant,
},
}),
);
const supportingCards =
bodyHighlights.length > 0
? bodyHighlights.slice(0, 3)
: [String(params.screen.copy || "").trim()].filter(Boolean);
const cardGap = 14;
const supportColumns = variant === "split_bottom" ? Math.min(3, supportingCards.length) : Math.min(2, Math.max(1, supportingCards.length));
const supportCardWidth = Math.floor(
(contentWidth - cardGap * Math.max(0, supportColumns - 1)) / supportColumns,
);
supportingCards.forEach((highlight, index) => {
const column = supportColumns === 0 ? 0 : index % supportColumns;
const row = supportColumns === 0 ? 0 : Math.floor(index / supportColumns);
const x = safeArea.left + column * (supportCardWidth + cardGap);
const y = supportingSectionY + row * 116;
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-support-card-${index + 1}`,
type: "detail_card",
parentId: `${params.screenId}-section-supporting`,
frame: {
x,
y,
width: supportCardWidth,
height: variant === "split_bottom" ? 102 : 96,
},
zIndex: 2,
content: highlight,
role: "support_card",
style: {
fontSize: bodyBlock ? Math.max(18, Number(bodyBlock.style.fontSize || 22)) : 20,
fontWeight: "600",
color: "#111827",
textAlign: "left",
lineHeight: 1.32,
backgroundColor:
variant === "split_bottom"
? "rgba(255,255,255,0.92)"
: "rgba(248,250,252,0.82)",
borderColor:
index === 0 ? "rgba(59,130,246,0.42)" : "rgba(148,163,184,0.28)",
borderWidth: 1,
borderRadius: 24,
padding: 18,
boxShadow: "0 18px 42px rgba(15,23,42,0.12)",
backdropBlur: 12,
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "support_card",
},
}),
);
});
if (params.screen.layoutNote) {
const noteY = Math.min(
params.screenSize.height - 116,
supportingSectionY + supportingSectionHeight - 74,
);
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-proof-card`,
type: "detail_card",
parentId: `${params.screenId}-section-supporting`,
frame: {
x:
variant === "hero_right"
? Math.round(params.screenSize.width - safeArea.right - Math.min(260, contentWidth * 0.46))
: safeArea.left,
y: noteY,
width: Math.min(260, contentWidth * 0.46),
height: 62,
},
zIndex: 2,
content: String(params.screen.layoutNote || "").replace(/\s+/g, " ").trim(),
role: "proof_chip",
style: {
fontSize: 16,
fontWeight: "600",
color: "#1e293b",
textAlign: "left",
lineHeight: 1.22,
backgroundColor: "rgba(255,255,255,0.88)",
borderColor: "rgba(15,23,42,0.10)",
borderWidth: 1,
borderRadius: 18,
padding: 14,
boxShadow: "0 14px 36px rgba(15,23,42,0.12)",
backdropBlur: 10,
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "proof_chip",
},
}),
);
}
return components;
}
function createScreenSubtree(params: {
screenIndex: number;
screen: Pick<
DetailUiScreenInput,
"title" | "subtitle" | "copy" | "screenType" | "objective" | "layoutNote"
>;
screenSize: { width: number; height: number };
screenProtocol?: DetailUiScreenProtocol;
rootId?: string;
}) {
@ -91,42 +499,16 @@ function createScreenSubtree(params: {
},
];
const blocks = params.screenProtocol?.blocks || [];
blocks.forEach((block, blockIndex) => {
const bindingKey = mapBlockTypeToBindingKey(block.type);
components.push({
id: `${screenId}-text-${blockIndex + 1}`,
type: "detail_text_block",
parentId: textStackId,
props: {
blockType: block.type,
frame: block.frame,
textKey: bindingKey,
},
bindings: {
content: {
kind: "text",
path: `/screens/${params.screenIndex - 1}/${bindingKey}`,
},
},
style: {
fontFamily: block.style.fontFamily || "",
fontSize: block.style.fontSize,
fontWeight: block.style.fontWeight,
lineHeight: block.style.lineHeight,
color: block.style.color,
textAlign: block.style.textAlign,
},
layout: {
frame: block.frame,
},
metadata: {
screenIndex: params.screenIndex,
blockType: block.type,
blockId: block.id,
},
});
});
components.push(
...buildSectionedTextComponents({
screenId,
textStackId,
screenIndex: params.screenIndex,
screenSize: params.screenSize,
screen: params.screen,
screenProtocol: params.screenProtocol,
}),
);
return components;
}
@ -180,6 +562,7 @@ export function buildDetailUiSurfaceSnapshot(params: {
...createScreenSubtree({
screenIndex: screen.index,
screen,
screenSize: params.screenSize,
screenProtocol,
}),
);
@ -231,9 +614,17 @@ export function buildDetailUiSurfacePatchFromReview(params: {
const replacementComponents = createScreenSubtree({
screenIndex: params.screenIndex,
screen: params.screen,
screenSize: {
width: 790,
height: 1300,
},
screenProtocol: params.uiPatch.recommendedScreen,
rootId: screenId,
}).filter((component) => component.id === textStackId || component.parentId === textStackId);
}).filter(
(component) =>
component.id !== screenId &&
component.id !== `${screenId}-background`,
);
return {
version: "ui-surface-patch/v1",

View File

@ -7,7 +7,10 @@ export type UiSurfaceComponentType =
| "detail_screen"
| "detail_background_image"
| "detail_text_stack"
| "detail_text_block";
| "detail_text_block"
| "detail_section"
| "detail_card"
| "detail_badge";
export type UiSurfaceTextBindingKey = "title" | "subtitle" | "copy";

View File

@ -77,11 +77,21 @@ export interface VideoNodeData {
export interface TextNodeData {
content: string;
fontSize?: number;
fontFamily?: string;
color?: string;
fontWeight?: string;
lineHeight?: number;
letterSpacing?: number;
textAlign?: "left" | "center" | "right";
transparent?: boolean;
backgroundColor?: string;
borderColor?: string;
borderWidth?: number;
borderRadius?: number;
padding?: number;
boxShadow?: string;
backdropBlur?: number;
textTransform?: "none" | "uppercase" | "lowercase" | "capitalize";
}
// 场景裂变节点数据