feat: upgrade detail section component renderer
This commit is contained in:
parent
d8aa1d1661
commit
a1b6496976
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
// 场景裂变节点数据
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user