394 lines
11 KiB
TypeScript
394 lines
11 KiB
TypeScript
export type DetailGenerationMode = "classic" | "agent_layout";
|
||
|
||
export type DetailUiTextBlockType = "title" | "subtitle" | "body";
|
||
|
||
export type DetailUiTextAlign = "left" | "center" | "right";
|
||
|
||
export interface DetailUiGlobalStyle {
|
||
designLanguage?: string;
|
||
typography?: {
|
||
fontFamily?: string;
|
||
title?: string;
|
||
subtitle?: string;
|
||
body?: string;
|
||
};
|
||
layout?: {
|
||
grid?: string;
|
||
safeArea?: string;
|
||
rhythm?: string;
|
||
};
|
||
palette?: string;
|
||
}
|
||
|
||
export interface DetailUiScreenInput {
|
||
index: number;
|
||
title?: string;
|
||
subtitle?: string;
|
||
copy?: string;
|
||
screenType?: "A" | "B" | "C";
|
||
objective?: string;
|
||
layoutNote?: string;
|
||
}
|
||
|
||
export interface DetailUiFrame {
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
}
|
||
|
||
export interface DetailUiTextStyle {
|
||
fontFamily?: string;
|
||
fontSize: number;
|
||
fontWeight: string;
|
||
lineHeight: number;
|
||
color: string;
|
||
textAlign: DetailUiTextAlign;
|
||
}
|
||
|
||
export interface DetailUiTextBlock {
|
||
id: string;
|
||
type: DetailUiTextBlockType;
|
||
content: string;
|
||
frame: DetailUiFrame;
|
||
style: DetailUiTextStyle;
|
||
}
|
||
|
||
export interface DetailUiScreenProtocol {
|
||
index: number;
|
||
screenType?: "A" | "B" | "C";
|
||
objective?: string;
|
||
layoutNote?: string;
|
||
safeArea: {
|
||
top: number;
|
||
bottom: number;
|
||
left: number;
|
||
right: number;
|
||
};
|
||
blocks: DetailUiTextBlock[];
|
||
}
|
||
|
||
export interface DetailUiProtocol {
|
||
version: "detail-ui-protocol/v1";
|
||
mode: DetailGenerationMode;
|
||
screenSize: {
|
||
width: number;
|
||
height: number;
|
||
};
|
||
globalStyle?: DetailUiGlobalStyle;
|
||
screens: DetailUiScreenProtocol[];
|
||
}
|
||
|
||
export interface DetailUiPatchNote {
|
||
type: "layout" | "typography" | "conversion";
|
||
message: string;
|
||
}
|
||
|
||
export interface DetailUiScreenReviewPatch {
|
||
version: "detail-ui-review-patch/v1";
|
||
mode: DetailGenerationMode;
|
||
screenIndex: number;
|
||
recommendedScreen: DetailUiScreenProtocol;
|
||
notes: DetailUiPatchNote[];
|
||
}
|
||
|
||
function extractFirstNumber(value: string, fallback: number): number {
|
||
const matched = String(value || "").match(/(\d+(?:\.\d+)?)/);
|
||
return matched ? Number(matched[1]) : fallback;
|
||
}
|
||
|
||
function extractFontWeight(value: string, fallback = "600"): string {
|
||
const matched = String(value || "").match(/\b(100|200|300|400|500|600|700|800|900)\b/);
|
||
return matched?.[1] || fallback;
|
||
}
|
||
|
||
function parseSafeArea(input?: string, width = 790, height = 1300) {
|
||
const raw = String(input || "");
|
||
const yMatch = raw.match(/y\s*=\s*(\d+)\s*[-~~]\s*(\d+)/i);
|
||
const leftMatch = raw.match(/(?:左右边距|左边距|左侧边距)\s*(\d+)\s*px/i);
|
||
const rightMatch = raw.match(/(?:左右边距|右边距|右侧边距)\s*(\d+)\s*px/i);
|
||
|
||
const top = yMatch ? Number(yMatch[1]) : 64;
|
||
const bottom = yMatch ? Number(yMatch[2]) : Math.min(height - 120, 360);
|
||
const left = leftMatch ? Number(leftMatch[1]) : 56;
|
||
const right = rightMatch ? Number(rightMatch[1]) : left;
|
||
|
||
return {
|
||
top,
|
||
bottom,
|
||
left: Math.max(24, Math.min(width / 3, left)),
|
||
right: Math.max(24, Math.min(width / 3, right)),
|
||
};
|
||
}
|
||
|
||
function buildTextBlockStyle(
|
||
type: DetailUiTextBlockType,
|
||
globalStyle?: DetailUiGlobalStyle,
|
||
screenIndex = 0,
|
||
): DetailUiTextStyle {
|
||
const typography = globalStyle?.typography;
|
||
const titleBase = extractFirstNumber(typography?.title || "", 64);
|
||
const subtitleBase = extractFirstNumber(typography?.subtitle || "", 34);
|
||
const bodyBase = extractFirstNumber(typography?.body || "", 24);
|
||
|
||
if (type === "title") {
|
||
const fontSize = Math.max(36, Math.min(80, screenIndex === 0 ? titleBase : Math.round(titleBase * 0.86)));
|
||
return {
|
||
fontFamily: typography?.fontFamily,
|
||
fontSize,
|
||
fontWeight: extractFontWeight(typography?.title || "", "700"),
|
||
lineHeight: 1.15,
|
||
color: "#111827",
|
||
textAlign: "left",
|
||
};
|
||
}
|
||
|
||
if (type === "subtitle") {
|
||
return {
|
||
fontFamily: typography?.fontFamily,
|
||
fontSize: Math.max(24, Math.min(50, subtitleBase)),
|
||
fontWeight: extractFontWeight(typography?.subtitle || "", "600"),
|
||
lineHeight: 1.22,
|
||
color: "#374151",
|
||
textAlign: "left",
|
||
};
|
||
}
|
||
|
||
return {
|
||
fontFamily: typography?.fontFamily,
|
||
fontSize: Math.max(18, Math.min(36, bodyBase)),
|
||
fontWeight: extractFontWeight(typography?.body || "", "500"),
|
||
lineHeight: 1.38,
|
||
color: "#4b5563",
|
||
textAlign: "left",
|
||
};
|
||
}
|
||
|
||
function estimateBlockHeight(
|
||
content: string,
|
||
style: DetailUiTextStyle,
|
||
width: number,
|
||
maxLines: number,
|
||
) {
|
||
const safeWidth = Math.max(120, width);
|
||
const charsPerLine = Math.max(8, Math.floor(safeWidth / Math.max(10, style.fontSize * 0.9)));
|
||
const estimatedLines = Math.min(
|
||
maxLines,
|
||
Math.max(1, Math.ceil(Array.from(String(content || "")).length / charsPerLine)),
|
||
);
|
||
return Math.ceil(style.fontSize * style.lineHeight * estimatedLines + 16);
|
||
}
|
||
|
||
export function buildDetailUiProtocol(params: {
|
||
generationMode: DetailGenerationMode;
|
||
screenSize: { width: number; height: number };
|
||
globalStyle?: DetailUiGlobalStyle;
|
||
screens: DetailUiScreenInput[];
|
||
}): DetailUiProtocol {
|
||
const { generationMode, screenSize, globalStyle } = params;
|
||
|
||
const screens = params.screens.map((screen, index) => {
|
||
const safeArea = parseSafeArea(globalStyle?.layout?.safeArea, screenSize.width, screenSize.height);
|
||
const textWidth = Math.max(240, Math.round(screenSize.width - safeArea.left - safeArea.right));
|
||
const blocks: DetailUiTextBlock[] = [];
|
||
|
||
const titleText = String(screen.title || "").trim();
|
||
const subtitleText = String(screen.subtitle || "").trim();
|
||
const bodyText = String(screen.copy || "").trim();
|
||
|
||
let cursorY = safeArea.top;
|
||
|
||
if (titleText) {
|
||
const style = buildTextBlockStyle("title", globalStyle, index);
|
||
const height = estimateBlockHeight(titleText, style, textWidth, 2);
|
||
blocks.push({
|
||
id: `screen-${screen.index}-title`,
|
||
type: "title",
|
||
content: titleText,
|
||
frame: {
|
||
x: safeArea.left,
|
||
y: cursorY,
|
||
width: textWidth,
|
||
height,
|
||
},
|
||
style,
|
||
});
|
||
cursorY += height + 16;
|
||
}
|
||
|
||
if (subtitleText) {
|
||
const style = buildTextBlockStyle("subtitle", globalStyle, index);
|
||
const height = estimateBlockHeight(subtitleText, style, textWidth, 3);
|
||
blocks.push({
|
||
id: `screen-${screen.index}-subtitle`,
|
||
type: "subtitle",
|
||
content: subtitleText,
|
||
frame: {
|
||
x: safeArea.left,
|
||
y: cursorY,
|
||
width: textWidth,
|
||
height,
|
||
},
|
||
style,
|
||
});
|
||
cursorY += height + 14;
|
||
}
|
||
|
||
if (bodyText) {
|
||
const style = buildTextBlockStyle("body", globalStyle, index);
|
||
const remainingHeight = Math.max(96, safeArea.bottom - cursorY);
|
||
const estimatedHeight = estimateBlockHeight(bodyText, style, textWidth, 5);
|
||
blocks.push({
|
||
id: `screen-${screen.index}-body`,
|
||
type: "body",
|
||
content: bodyText,
|
||
frame: {
|
||
x: safeArea.left,
|
||
y: cursorY,
|
||
width: textWidth,
|
||
height: Math.min(remainingHeight, estimatedHeight),
|
||
},
|
||
style,
|
||
});
|
||
}
|
||
|
||
return {
|
||
index: screen.index,
|
||
screenType: screen.screenType,
|
||
objective: screen.objective,
|
||
layoutNote: screen.layoutNote,
|
||
safeArea,
|
||
blocks,
|
||
} satisfies DetailUiScreenProtocol;
|
||
});
|
||
|
||
return {
|
||
version: "detail-ui-protocol/v1",
|
||
mode: generationMode,
|
||
screenSize,
|
||
globalStyle,
|
||
screens,
|
||
};
|
||
}
|
||
|
||
function applyReviewHeuristicsToScreen(params: {
|
||
screen: DetailUiScreenProtocol;
|
||
screenSize: { width: number; height: number };
|
||
notes: DetailUiPatchNote[];
|
||
}) {
|
||
const noteText = params.notes.map((note) => note.message).join(" | ").toLowerCase();
|
||
if (!noteText.trim()) {
|
||
return params.screen;
|
||
}
|
||
|
||
const emphasizeTitle =
|
||
/标题层级|headline|title hierarchy|主标题|标题不够突出|主副标题/.test(noteText);
|
||
const addBreathingRoom =
|
||
/留白|spacing|rhythm|拥挤|挤|crowd|breathing|间距/.test(noteText);
|
||
const protectHeadlineZone =
|
||
/顶部|headline zone|safe area|安全区|避免重叠|不要遮挡|hero/.test(noteText);
|
||
const softenBodyDensity =
|
||
/正文|文案过密|copy too dense|copy density|太多文字|可读性/.test(noteText);
|
||
|
||
const safeArea = {
|
||
...params.screen.safeArea,
|
||
top: protectHeadlineZone
|
||
? Math.min(params.screen.safeArea.top + 12, params.screen.safeArea.bottom - 120)
|
||
: params.screen.safeArea.top,
|
||
};
|
||
const gapTitleToSubtitle = addBreathingRoom ? 24 : 16;
|
||
const gapSubtitleToBody = addBreathingRoom ? 20 : 14;
|
||
|
||
let cursorY = safeArea.top;
|
||
const blocks = params.screen.blocks.map((block) => {
|
||
const nextBlock = {
|
||
...block,
|
||
frame: { ...block.frame },
|
||
style: { ...block.style },
|
||
};
|
||
|
||
if (nextBlock.type === "title" && emphasizeTitle) {
|
||
nextBlock.style.fontSize = Math.min(84, nextBlock.style.fontSize + 4);
|
||
nextBlock.style.lineHeight = 1.12;
|
||
}
|
||
|
||
if (nextBlock.type === "subtitle" && emphasizeTitle) {
|
||
nextBlock.style.fontSize = Math.max(22, nextBlock.style.fontSize - 1);
|
||
}
|
||
|
||
if (nextBlock.type === "body" && softenBodyDensity) {
|
||
nextBlock.style.fontSize = Math.max(18, nextBlock.style.fontSize - 2);
|
||
nextBlock.style.lineHeight = 1.42;
|
||
}
|
||
|
||
const width = Math.max(
|
||
220,
|
||
Math.round(params.screenSize.width - safeArea.left - safeArea.right),
|
||
);
|
||
nextBlock.frame.x = safeArea.left;
|
||
nextBlock.frame.width = width;
|
||
nextBlock.frame.y = cursorY;
|
||
nextBlock.frame.height = estimateBlockHeight(
|
||
nextBlock.content,
|
||
nextBlock.style,
|
||
nextBlock.frame.width,
|
||
nextBlock.type === "title" ? 2 : nextBlock.type === "subtitle" ? 3 : 5,
|
||
);
|
||
|
||
if (nextBlock.type === "body") {
|
||
const maxBodyBottom = Math.max(cursorY + 80, safeArea.bottom);
|
||
nextBlock.frame.height = Math.min(nextBlock.frame.height, maxBodyBottom - cursorY);
|
||
}
|
||
|
||
cursorY +=
|
||
nextBlock.frame.height +
|
||
(nextBlock.type === "title"
|
||
? gapTitleToSubtitle
|
||
: nextBlock.type === "subtitle"
|
||
? gapSubtitleToBody
|
||
: 0);
|
||
|
||
return nextBlock;
|
||
});
|
||
|
||
return {
|
||
...params.screen,
|
||
safeArea,
|
||
blocks,
|
||
} satisfies DetailUiScreenProtocol;
|
||
}
|
||
|
||
export function buildDetailUiReviewPatch(params: {
|
||
generationMode: DetailGenerationMode;
|
||
screenSize: { width: number; height: number };
|
||
globalStyle?: DetailUiGlobalStyle;
|
||
screen: DetailUiScreenInput;
|
||
layoutFixes?: string[];
|
||
typographyFixes?: string[];
|
||
conversionFixes?: string[];
|
||
}): DetailUiScreenReviewPatch {
|
||
const notes = [
|
||
...(params.layoutFixes || []).map((message) => ({ type: "layout" as const, message })),
|
||
...(params.typographyFixes || []).map((message) => ({ type: "typography" as const, message })),
|
||
...(params.conversionFixes || []).map((message) => ({ type: "conversion" as const, message })),
|
||
];
|
||
const protocol = buildDetailUiProtocol({
|
||
generationMode: params.generationMode,
|
||
screenSize: params.screenSize,
|
||
globalStyle: params.globalStyle,
|
||
screens: [params.screen],
|
||
});
|
||
|
||
return {
|
||
version: "detail-ui-review-patch/v1",
|
||
mode: params.generationMode,
|
||
screenIndex: params.screen.index,
|
||
recommendedScreen: applyReviewHeuristicsToScreen({
|
||
screen: protocol.screens[0]!,
|
||
screenSize: params.screenSize,
|
||
notes,
|
||
}),
|
||
notes,
|
||
};
|
||
}
|