ai-chatbot/lib/detail-ui-surface-adapter.ts
Your Name 2f1eff956a
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add detail component catalog renderer
2026-03-21 23:51:40 +08:00

869 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type {
DetailGenerationMode,
DetailUiGlobalStyle,
DetailUiProtocol,
DetailUiScreenInput,
DetailUiScreenProtocol,
DetailUiScreenReviewPatch,
} from "@/lib/detail-ui-protocol";
import type {
UiSurfaceComponent,
UiSurfacePatch,
UiSurfaceSnapshot,
} from "@/lib/ui-surface-protocol";
function createDetailSurfaceId(params: {
generationMode: DetailGenerationMode;
screenCount: number;
}) {
return `detail-surface-${params.generationMode}-${params.screenCount}-790x1300`;
}
function createRootComponent(): UiSurfaceComponent {
return {
id: "detail-page-root",
type: "detail_page_root",
parentId: null,
props: {
role: "detail_page",
},
};
}
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"
| "detail_feature_card"
| "detail_spec_table"
| "detail_comparison_strip"
| "detail_proof_bar"
| "detail_cta_banner";
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 buildCatalogStrings(params: {
screenIndex: number;
totalScreens?: number;
screen: Pick<
DetailUiScreenInput,
"title" | "subtitle" | "copy" | "screenType" | "objective" | "layoutNote"
>;
}) {
const highlights = splitBodyCopyIntoHighlights(params.screen.copy).slice(0, 3);
const primaryTitle = String(params.screen.title || "").replace(/\s+/g, " ").trim();
const subtitle = String(params.screen.subtitle || "").replace(/\s+/g, " ").trim();
const objective = String(params.screen.objective || "").replace(/\s+/g, " ").trim();
const layoutNote = String(params.screen.layoutNote || "").replace(/\s+/g, " ").trim();
const catalogTitle = primaryTitle || "核心卖点";
const specRows = [
`核心卖点|${catalogTitle || "舒适支撑"}`,
objective ? `适用场景|${objective}` : "",
subtitle ? `结果表达|${subtitle}` : "",
layoutNote ? `设计要求|${layoutNote}` : "",
].filter(Boolean);
const comparisonText = [
"旧方式",
highlights[0] || "久坐容易疲劳、支撑不足",
"",
"现在",
highlights[1] || subtitle || "围绕护腰/舒适/效率完成升级",
]
.filter(Boolean)
.join("\n");
const proofText = layoutNote || objective || subtitle || catalogTitle;
const shouldShowCta =
params.screenIndex === (params.totalScreens || 0) ||
/下单|购买|收口|转化|cta|call to action/i.test(layoutNote);
const ctaText = shouldShowCta
? `立即聚焦:${catalogTitle}${subtitle ? `\n${subtitle}` : ""}`
: "";
return {
highlights,
specTableText: specRows.join("\n"),
comparisonText,
proofText,
ctaText,
};
}
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;
totalScreens?: 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 catalog = buildCatalogStrings({
screenIndex: params.screenIndex,
totalScreens: params.totalScreens,
screen: params.screen,
});
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 =
catalog.highlights.length > 0
? catalog.highlights.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_feature_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 (catalog.specTableText && params.screen.screenType === "A") {
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-spec-table`,
type: "detail_spec_table",
parentId: `${params.screenId}-section-supporting`,
frame: {
x: safeArea.left,
y:
variant === "split_bottom"
? supportingSectionY - 122
: supportingSectionY + supportingSectionHeight - 204,
width: Math.min(contentWidth, 360),
height: 188,
},
zIndex: 2,
content: catalog.specTableText,
role: "spec_table",
style: {
fontSize: 16,
fontWeight: "600",
color: "#0f172a",
textAlign: "left",
lineHeight: 1.48,
backgroundColor: "rgba(255,255,255,0.94)",
borderColor: "rgba(59,130,246,0.18)",
borderWidth: 1,
borderRadius: 24,
padding: 20,
boxShadow: "0 18px 42px rgba(15,23,42,0.12)",
backdropBlur: 12,
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "spec_table",
},
}),
);
}
if (catalog.comparisonText && params.screen.screenType === "C") {
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-comparison-strip`,
type: "detail_comparison_strip",
parentId: `${params.screenId}-section-supporting`,
frame: {
x: safeArea.left,
y: supportingSectionY - 112,
width: Math.min(contentWidth, 420),
height: 158,
},
zIndex: 2,
content: catalog.comparisonText,
role: "comparison_strip",
style: {
fontSize: 17,
fontWeight: "700",
color: "#f8fafc",
textAlign: "left",
lineHeight: 1.4,
backgroundColor: "rgba(15,23,42,0.88)",
borderColor: "rgba(255,255,255,0.18)",
borderWidth: 1,
borderRadius: 26,
padding: 20,
boxShadow: "0 20px 48px rgba(15,23,42,0.18)",
backdropBlur: 12,
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "comparison_strip",
},
}),
);
}
if (catalog.proofText) {
const noteY = Math.min(
params.screenSize.height - 116,
supportingSectionY + supportingSectionHeight - 74,
);
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-proof-bar`,
type: "detail_proof_bar",
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: catalog.proofText,
role: "proof_bar",
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_bar",
},
}),
);
}
if (catalog.ctaText) {
components.push(
createStyledSurfaceTextComponent({
id: `${params.screenId}-cta-banner`,
type: "detail_cta_banner",
parentId: params.textStackId,
frame: {
x: safeArea.left,
y: Math.min(params.screenSize.height - 164, supportingSectionY + supportingSectionHeight + 8),
width: Math.min(contentWidth, 430),
height: 104,
},
zIndex: 3,
content: catalog.ctaText,
role: "cta_banner",
style: {
fontSize: 20,
fontWeight: "800",
color: "#f8fafc",
textAlign: "left",
lineHeight: 1.28,
backgroundColor: "rgba(37,99,235,0.88)",
borderColor: "rgba(255,255,255,0.24)",
borderWidth: 1,
borderRadius: 28,
padding: 22,
boxShadow: "0 22px 58px rgba(37,99,235,0.28)",
backdropBlur: 12,
},
metadata: {
screenIndex: params.screenIndex,
componentRole: "cta_banner",
},
}),
);
}
return components;
}
function createScreenSubtree(params: {
screenIndex: number;
totalScreens?: number;
screen: Pick<
DetailUiScreenInput,
"title" | "subtitle" | "copy" | "screenType" | "objective" | "layoutNote"
>;
screenSize: { width: number; height: number };
screenProtocol?: DetailUiScreenProtocol;
rootId?: string;
}) {
const screenId = params.rootId || `detail-screen-${params.screenIndex}`;
const backgroundId = `${screenId}-background`;
const textStackId = `${screenId}-text-stack`;
const components: UiSurfaceComponent[] = [
{
id: screenId,
type: "detail_screen",
parentId: "detail-page-root",
props: {
screenIndex: params.screenIndex,
screenType: params.screen.screenType || "",
objective: params.screen.objective || "",
layoutNote: params.screen.layoutNote || "",
},
metadata: {
screenIndex: params.screenIndex,
},
},
{
id: backgroundId,
type: "detail_background_image",
parentId: screenId,
slot: "background",
props: {
screenIndex: params.screenIndex,
},
metadata: {
screenIndex: params.screenIndex,
},
},
{
id: textStackId,
type: "detail_text_stack",
parentId: screenId,
slot: "content",
props: {
screenIndex: params.screenIndex,
safeArea: params.screenProtocol?.safeArea || null,
},
metadata: {
screenIndex: params.screenIndex,
},
},
];
components.push(
...buildSectionedTextComponents({
screenId,
textStackId,
screenIndex: params.screenIndex,
totalScreens: params.totalScreens,
screenSize: params.screenSize,
screen: params.screen,
screenProtocol: params.screenProtocol,
}),
);
return components;
}
function buildDetailSurfaceDataModel(params: {
summary?: string;
screens: DetailUiScreenInput[];
globalStyle?: DetailUiGlobalStyle;
}) {
return {
summary: params.summary || "",
globalStyle: params.globalStyle || {},
screens: params.screens.map((screen) => ({
index: screen.index,
title: String(screen.title || "").trim(),
subtitle: String(screen.subtitle || "").trim(),
copy: String(screen.copy || "").trim(),
screenType: screen.screenType || "",
objective: screen.objective || "",
layoutNote: screen.layoutNote || "",
})),
};
}
export function buildDetailUiSurfaceSnapshot(params: {
surfaceId?: string;
generationMode: DetailGenerationMode;
screenSize: { width: number; height: number };
summary?: string;
globalStyle?: DetailUiGlobalStyle;
screens: DetailUiScreenInput[];
uiProtocol?: DetailUiProtocol;
uiValidation?: {
passed: boolean;
issues: string[];
};
}) {
const surfaceId =
params.surfaceId ||
createDetailSurfaceId({
generationMode: params.generationMode,
screenCount: params.screens.length,
});
const components: UiSurfaceComponent[] = [createRootComponent()];
params.screens.forEach((screen) => {
const screenProtocol = params.uiProtocol?.screens.find(
(item) => item.index === screen.index,
);
components.push(
...createScreenSubtree({
screenIndex: screen.index,
totalScreens: params.screens.length,
screen,
screenSize: params.screenSize,
screenProtocol,
}),
);
});
return {
version: "ui-surface/v1",
surfaceId,
surfaceType: "detail_page",
generationMode: params.generationMode,
rootComponentId: "detail-page-root",
screenSize: params.screenSize,
components,
dataModel: buildDetailSurfaceDataModel({
summary: params.summary,
screens: params.screens,
globalStyle: params.globalStyle,
}),
validation: params.uiValidation
? {
passed: params.uiValidation.passed,
issues: params.uiValidation.issues,
source: "plan",
}
: undefined,
metadata: {
detailScreenCount: params.screens.length,
},
} satisfies UiSurfaceSnapshot;
}
export function buildDetailUiSurfacePatchFromReview(params: {
surfaceId: string;
screenIndex: number;
screen: Pick<
DetailUiScreenInput,
"index" | "title" | "subtitle" | "copy" | "screenType" | "objective" | "layoutNote"
>;
uiPatch?: DetailUiScreenReviewPatch;
uiValidation?: {
passed: boolean;
issues: string[];
};
}) {
if (!params.uiPatch) return null;
const screenId = `detail-screen-${params.screenIndex}`;
const textStackId = `${screenId}-text-stack`;
const replacementComponents = createScreenSubtree({
screenIndex: params.screenIndex,
totalScreens: params.screenIndex,
screen: params.screen,
screenSize: {
width: 790,
height: 1300,
},
screenProtocol: params.uiPatch.recommendedScreen,
rootId: screenId,
}).filter(
(component) =>
component.id !== screenId &&
component.id !== `${screenId}-background`,
);
return {
version: "ui-surface-patch/v1",
surfaceId: params.surfaceId,
surfaceType: "detail_page",
operations: [
{
type: "replace_component_subtree",
componentId: textStackId,
components: replacementComponents,
},
{
type: "set_data",
updates: [
{
path: `/screens/${params.screenIndex - 1}/title`,
value: String(params.screen.title || "").trim(),
},
{
path: `/screens/${params.screenIndex - 1}/subtitle`,
value: String(params.screen.subtitle || "").trim(),
},
{
path: `/screens/${params.screenIndex - 1}/copy`,
value: String(params.screen.copy || "").trim(),
},
{
path: `/screens/${params.screenIndex - 1}/layoutNote`,
value: String(params.screen.layoutNote || "").trim(),
},
],
},
...(params.uiValidation
? [
{
type: "set_validation" as const,
validation: {
passed: params.uiValidation.passed,
issues: params.uiValidation.issues,
source: "review" as const,
},
},
]
: []),
],
} satisfies UiSurfacePatch;
}
export function findDetailSurfaceScreenComponent(
snapshot: UiSurfaceSnapshot | null | undefined,
screenIndex: number,
) {
if (!snapshot) return null;
return (
snapshot.components.find(
(component) =>
component.type === "detail_screen" &&
Number(component.props?.screenIndex || 0) === screenIndex,
) || null
);
}