feat: add detail ui protocol foundation
This commit is contained in:
parent
8bd15f25c6
commit
3e4594273b
@ -12,6 +12,11 @@ import {
|
||||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||
import { inlineVisionImages } from "@/lib/ai/vision-inline";
|
||||
import { filterReachableRemoteUrls } from "@/lib/net/url-access";
|
||||
import {
|
||||
buildDetailUiProtocol,
|
||||
type DetailUiProtocol,
|
||||
} from "@/lib/detail-ui-protocol";
|
||||
import { validateDetailUiProtocol } from "@/lib/detail-ui-validator";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 180;
|
||||
@ -218,6 +223,10 @@ type DetailPlanResponse = {
|
||||
passed: boolean;
|
||||
issues: string[];
|
||||
};
|
||||
uiValidation?: {
|
||||
passed: boolean;
|
||||
issues: string[];
|
||||
};
|
||||
preflight?: {
|
||||
passed: boolean;
|
||||
designDirectorScore: number;
|
||||
@ -226,6 +235,7 @@ type DetailPlanResponse = {
|
||||
rewriteApplied: boolean;
|
||||
};
|
||||
screens: DetailPlanScreen[];
|
||||
uiProtocol?: DetailUiProtocol;
|
||||
model?: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
@ -1190,6 +1200,20 @@ function buildFallbackPlan(params: {
|
||||
hasBundleImages: params.hasBundleImages,
|
||||
coreFocus: params.coreFocus,
|
||||
});
|
||||
const uiProtocol = buildDetailUiProtocol({
|
||||
generationMode: params.generationMode,
|
||||
screenSize: {
|
||||
width: 790,
|
||||
height: 1300,
|
||||
},
|
||||
globalStyle,
|
||||
screens,
|
||||
});
|
||||
const uiValidation = validateDetailUiProtocol(uiProtocol);
|
||||
const mergedIssues = [
|
||||
...qualityChecks.issues,
|
||||
...uiValidation.issues.map((item) => item.message),
|
||||
];
|
||||
return {
|
||||
generationMode: params.generationMode,
|
||||
summary:
|
||||
@ -1203,8 +1227,16 @@ function buildFallbackPlan(params: {
|
||||
},
|
||||
globalStyle,
|
||||
skillFlow,
|
||||
qualityChecks,
|
||||
qualityChecks: {
|
||||
passed: qualityChecks.passed && uiValidation.passed,
|
||||
issues: mergedIssues,
|
||||
},
|
||||
uiValidation: {
|
||||
passed: uiValidation.passed,
|
||||
issues: uiValidation.issues.map((item) => item.message),
|
||||
},
|
||||
screens,
|
||||
uiProtocol,
|
||||
};
|
||||
}
|
||||
|
||||
@ -2065,6 +2097,16 @@ export async function POST(request: Request) {
|
||||
...preflight.issues.map((issue) => `双总监预审:${issue}`),
|
||||
]),
|
||||
];
|
||||
const uiProtocol = buildDetailUiProtocol({
|
||||
generationMode,
|
||||
screenSize: {
|
||||
width: 790,
|
||||
height: 1300,
|
||||
},
|
||||
globalStyle,
|
||||
screens,
|
||||
});
|
||||
const uiValidation = validateDetailUiProtocol(uiProtocol);
|
||||
const qualityChecks = {
|
||||
passed: !shouldFallback,
|
||||
issues: shouldFallback
|
||||
@ -2073,6 +2115,10 @@ export async function POST(request: Request) {
|
||||
: ["双总监预审未通过,已回退到稳定分镜模板。"]
|
||||
: qualityIssuesMerged,
|
||||
};
|
||||
const mergedIssues = [
|
||||
...qualityChecks.issues,
|
||||
...uiValidation.issues.map((item) => item.message),
|
||||
];
|
||||
const prompts = screens.map((item) => item.visualPrompt);
|
||||
const summaryRaw = String(parsed?.summary || "").trim();
|
||||
const summary = desensitizeAudienceCopy(
|
||||
@ -2093,10 +2139,17 @@ export async function POST(request: Request) {
|
||||
globalStyle,
|
||||
skillFlow: fallback.skillFlow,
|
||||
qualityChecks: qualityChecks.passed
|
||||
? qualityChecks
|
||||
? {
|
||||
passed: qualityChecks.passed && uiValidation.passed,
|
||||
issues: mergedIssues,
|
||||
}
|
||||
: {
|
||||
passed: false,
|
||||
issues: [...qualityChecks.issues],
|
||||
issues: [...mergedIssues],
|
||||
},
|
||||
uiValidation: {
|
||||
passed: uiValidation.passed,
|
||||
issues: uiValidation.issues.map((item) => item.message),
|
||||
},
|
||||
preflight: {
|
||||
passed: !shouldFallback,
|
||||
@ -2106,6 +2159,7 @@ export async function POST(request: Request) {
|
||||
rewriteApplied: preflight.rewriteApplied,
|
||||
},
|
||||
screens,
|
||||
uiProtocol,
|
||||
model: {
|
||||
id: plannerConfig.model.id,
|
||||
displayName: plannerConfig.model.displayName,
|
||||
|
||||
@ -11,12 +11,20 @@ import {
|
||||
} from "@/lib/db/ai-model-flags";
|
||||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||
import { inlineVisionImages } from "@/lib/ai/vision-inline";
|
||||
import {
|
||||
buildDetailUiProtocol,
|
||||
buildDetailUiReviewPatch,
|
||||
type DetailGenerationMode,
|
||||
type DetailUiScreenReviewPatch,
|
||||
} from "@/lib/detail-ui-protocol";
|
||||
import { validateDetailUiProtocol } from "@/lib/detail-ui-validator";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 180;
|
||||
|
||||
type DetailReviewRequest = {
|
||||
outputLanguage?: string;
|
||||
generationMode?: string;
|
||||
imageUrl?: string;
|
||||
referenceImages?: string[];
|
||||
screen?: {
|
||||
@ -168,6 +176,8 @@ export async function POST(request: Request) {
|
||||
|
||||
const body = (await request.json()) as DetailReviewRequest;
|
||||
const language = normalizeLanguage(body.outputLanguage);
|
||||
const generationMode: DetailGenerationMode =
|
||||
body.generationMode === "agent_layout" ? "agent_layout" : "classic";
|
||||
const imageUrl = String(body.imageUrl || "").trim();
|
||||
if (!/^https?:\/\//i.test(imageUrl)) {
|
||||
return NextResponse.json({ success: false, error: "缺少待质检图片" }, { status: 400 });
|
||||
@ -200,18 +210,45 @@ export async function POST(request: Request) {
|
||||
language,
|
||||
isFirstScreen: Number(body.screen?.index || 0) === 1,
|
||||
});
|
||||
const currentUiProtocol = buildDetailUiProtocol({
|
||||
generationMode,
|
||||
screenSize: {
|
||||
width: 790,
|
||||
height: 1300,
|
||||
},
|
||||
globalStyle: body.globalStyle,
|
||||
screens: [
|
||||
{
|
||||
index: Number(body.screen?.index || 1),
|
||||
title: String(body.screen?.title || "").trim(),
|
||||
subtitle: String(body.screen?.subtitle || "").trim(),
|
||||
copy: String(body.screen?.copy || "").trim(),
|
||||
screenType:
|
||||
body.screen?.screenType === "A" ||
|
||||
body.screen?.screenType === "B" ||
|
||||
body.screen?.screenType === "C"
|
||||
? body.screen.screenType
|
||||
: undefined,
|
||||
objective: undefined,
|
||||
layoutNote: layoutNote,
|
||||
},
|
||||
],
|
||||
});
|
||||
const currentUiValidation = validateDetailUiProtocol(currentUiProtocol);
|
||||
|
||||
if (!reviewerConfig) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
pass: true,
|
||||
reviewAction: "pass",
|
||||
score: 80,
|
||||
designDirectorScore: 80,
|
||||
marketingDirectorScore: 80,
|
||||
issues: [],
|
||||
layoutFixes: [],
|
||||
revisedPrompt: fallbackPrompt,
|
||||
uiPatch: undefined,
|
||||
model: null,
|
||||
},
|
||||
});
|
||||
@ -227,12 +264,14 @@ export async function POST(request: Request) {
|
||||
success: true,
|
||||
data: {
|
||||
pass: true,
|
||||
reviewAction: "pass",
|
||||
score: 80,
|
||||
designDirectorScore: 80,
|
||||
marketingDirectorScore: 80,
|
||||
issues: [],
|
||||
layoutFixes: [],
|
||||
revisedPrompt: fallbackPrompt,
|
||||
uiPatch: undefined,
|
||||
model: null,
|
||||
},
|
||||
});
|
||||
@ -259,6 +298,13 @@ export async function POST(request: Request) {
|
||||
`- Screen subtitle: ${String(body.screen?.subtitle || "").trim()}`,
|
||||
`- Screen copy: ${String(body.screen?.copy || "").trim()}`,
|
||||
`- Layout note: ${layoutNote}`,
|
||||
currentUiValidation.issues.length > 0
|
||||
? `- Current protocol validation issues: ${currentUiValidation.issues
|
||||
.map((item) => item.message)
|
||||
.join(" | ")}`
|
||||
: language === "en"
|
||||
? "- Current protocol validation issues: none"
|
||||
: "- 当前协议校验问题:无",
|
||||
Number(body.screen?.index || 0) === 1
|
||||
? "- Screen 1 must keep poster hero quality: single dominant focal point, clear headline zone, no collage."
|
||||
: "",
|
||||
@ -319,12 +365,14 @@ export async function POST(request: Request) {
|
||||
success: true,
|
||||
data: {
|
||||
pass: true,
|
||||
reviewAction: "pass",
|
||||
score: 78,
|
||||
designDirectorScore: 78,
|
||||
marketingDirectorScore: 78,
|
||||
issues: fallbackIssues,
|
||||
layoutFixes: [],
|
||||
revisedPrompt: fallbackPrompt,
|
||||
uiPatch: undefined,
|
||||
model: {
|
||||
id: reviewerConfig.model.id,
|
||||
displayName: reviewerConfig.model.displayName,
|
||||
@ -392,16 +440,65 @@ export async function POST(request: Request) {
|
||||
designDirectorScore >= 85 &&
|
||||
marketingDirectorScore >= 85);
|
||||
|
||||
const uiPatch: DetailUiScreenReviewPatch | undefined = buildDetailUiReviewPatch({
|
||||
generationMode,
|
||||
screenSize: {
|
||||
width: 790,
|
||||
height: 1300,
|
||||
},
|
||||
globalStyle: body.globalStyle,
|
||||
screen: {
|
||||
index: Number(body.screen?.index || 1),
|
||||
title: String(body.screen?.title || "").trim(),
|
||||
subtitle: String(body.screen?.subtitle || "").trim(),
|
||||
copy: String(body.screen?.copy || "").trim(),
|
||||
screenType:
|
||||
body.screen?.screenType === "A" ||
|
||||
body.screen?.screenType === "B" ||
|
||||
body.screen?.screenType === "C"
|
||||
? body.screen.screenType
|
||||
: undefined,
|
||||
objective: undefined,
|
||||
layoutNote: String(body.screen?.layoutNote || "").trim(),
|
||||
},
|
||||
layoutFixes,
|
||||
typographyFixes,
|
||||
conversionFixes,
|
||||
});
|
||||
const uiValidation = validateDetailUiProtocol({
|
||||
version: "detail-ui-protocol/v1",
|
||||
mode: generationMode,
|
||||
screenSize: {
|
||||
width: 790,
|
||||
height: 1300,
|
||||
},
|
||||
screens: uiPatch ? [uiPatch.recommendedScreen] : [],
|
||||
globalStyle: body.globalStyle,
|
||||
});
|
||||
const reviewAction =
|
||||
pass
|
||||
? "pass"
|
||||
: generationMode === "agent_layout" &&
|
||||
(uiPatch.notes.length > 0 || !uiValidation.passed)
|
||||
? "protocol_patch"
|
||||
: "regenerate_image";
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
pass,
|
||||
reviewAction,
|
||||
score,
|
||||
designDirectorScore,
|
||||
marketingDirectorScore,
|
||||
issues,
|
||||
layoutFixes: [...layoutFixes, ...typographyFixes, ...conversionFixes].slice(0, 10),
|
||||
revisedPrompt,
|
||||
uiPatch,
|
||||
uiValidation: {
|
||||
passed: uiValidation.passed,
|
||||
issues: uiValidation.issues.map((item) => item.message),
|
||||
},
|
||||
model: {
|
||||
id: reviewerConfig.model.id,
|
||||
displayName: reviewerConfig.model.displayName,
|
||||
|
||||
@ -5,6 +5,13 @@ import { DefaultChatTransport } from "ai";
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { DetailUiProtocol } from "@/lib/detail-ui-protocol";
|
||||
import type { DetailUiScreenReviewPatch } from "@/lib/detail-ui-protocol";
|
||||
import {
|
||||
buildDetailTextLayerNodesFromProtocol,
|
||||
buildDetailTextLayerNodesFromReviewPatch,
|
||||
buildLegacyDetailTextLayerNodes,
|
||||
} from "@/lib/detail-ui-renderer";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -57,6 +64,7 @@ import type {
|
||||
AgentPreset,
|
||||
AgentWorkflowComposerContext,
|
||||
} from "@/types/agent";
|
||||
import type { CanvasNode } from "@/types/canvas";
|
||||
import { type CanvasLaunchPayload, getCanvasLaunchStorageKey } from "@/lib/canvas-launch";
|
||||
import {
|
||||
DEFAULT_CANVAS_SKILL_TAGS_TITLE,
|
||||
@ -1235,6 +1243,7 @@ type DetailPlanResponse = {
|
||||
displayName: string;
|
||||
providerType: string;
|
||||
};
|
||||
uiProtocol?: DetailUiProtocol;
|
||||
};
|
||||
type DetailPlanScreenSpec = NonNullable<DetailPlanResponse["screens"]>[number];
|
||||
|
||||
@ -1355,10 +1364,12 @@ type DetailReviewGroup = {
|
||||
|
||||
type DetailScreenReviewResult = {
|
||||
pass: boolean;
|
||||
reviewAction?: "pass" | "protocol_patch" | "regenerate_image";
|
||||
score?: number;
|
||||
issues?: string[];
|
||||
layoutFixes?: string[];
|
||||
revisedPrompt?: string;
|
||||
uiPatch?: DetailUiScreenReviewPatch;
|
||||
model?: {
|
||||
id?: string;
|
||||
displayName?: string;
|
||||
@ -6057,10 +6068,26 @@ export function CanvasChatPanel({
|
||||
"600"
|
||||
);
|
||||
const bodyFontWeight = extractFontWeight(planData.globalStyle?.typography?.body || "", "500");
|
||||
const replaceDetailTextLayers = (params: {
|
||||
nodeId: string;
|
||||
drafts: Array<Omit<CanvasNode, "id" | "createdAt" | "updatedAt">>;
|
||||
}) => {
|
||||
const storeState = useCanvasStore.getState();
|
||||
const childTextNodes = storeState.nodes.filter(
|
||||
(node) =>
|
||||
node.parentId === params.nodeId &&
|
||||
node.type === "text" &&
|
||||
Boolean((node.data as any)?.metadata?.detailUiBlockId),
|
||||
);
|
||||
|
||||
childTextNodes.forEach((node) => deleteNode(node.id));
|
||||
params.drafts.forEach((draft) => addNode(draft));
|
||||
};
|
||||
const createAgentLayoutTextLayers = (params: {
|
||||
nodeId: string;
|
||||
screenIndex: number;
|
||||
screen?: DetailPlanScreenSpec;
|
||||
screenProtocol?: DetailUiProtocol["screens"][number];
|
||||
}) => {
|
||||
if (!isAgentLayoutMode) return;
|
||||
const storeState = useCanvasStore.getState();
|
||||
@ -6069,69 +6096,40 @@ export function CanvasChatPanel({
|
||||
const x = Number(imageNode.position?.x || 0);
|
||||
const y = Number(imageNode.position?.y || 0);
|
||||
const width = Number(imageNode.size?.width || DETAIL_TARGET_WIDTH);
|
||||
const safeLeft = Math.round(width * 0.07);
|
||||
const textWidth = Math.max(240, Math.round(width - safeLeft * 2));
|
||||
const height = Number(imageNode.size?.height || DETAIL_TARGET_HEIGHT);
|
||||
const screen = params.screen;
|
||||
const titleText = String(screen?.title || "").trim();
|
||||
const subtitleText = String(screen?.subtitle || "").trim();
|
||||
const bodyText = String(screen?.copy || "").trim();
|
||||
|
||||
if (titleText) {
|
||||
addNode({
|
||||
type: "text",
|
||||
position: { x: x + safeLeft, y: y + 72 },
|
||||
size: { width: textWidth, height: 140 },
|
||||
data: {
|
||||
content: titleText,
|
||||
fontSize: params.screenIndex === 0 ? titleFontSize : Math.round(titleFontSize * 0.86),
|
||||
fontWeight: titleFontWeight,
|
||||
color: "#111827",
|
||||
lineHeight: 1.15,
|
||||
textAlign: "left",
|
||||
transparent: true,
|
||||
const drafts =
|
||||
params.screenProtocol
|
||||
? buildDetailTextLayerNodesFromProtocol({
|
||||
parentNodeId: params.nodeId,
|
||||
parentPosition: { x, y },
|
||||
parentSize: { width, height },
|
||||
screenSize: planData.uiProtocol?.screenSize || {
|
||||
width: DETAIL_TARGET_WIDTH,
|
||||
height: DETAIL_TARGET_HEIGHT,
|
||||
},
|
||||
connections: [],
|
||||
parentId: params.nodeId,
|
||||
screenProtocol: params.screenProtocol,
|
||||
})
|
||||
: buildLegacyDetailTextLayerNodes({
|
||||
parentNodeId: params.nodeId,
|
||||
parentPosition: { x, y },
|
||||
parentWidth: width,
|
||||
screenIndex: params.screenIndex,
|
||||
title: screen?.title,
|
||||
subtitle: screen?.subtitle,
|
||||
body: screen?.copy,
|
||||
titleFontSize,
|
||||
titleFontWeight,
|
||||
subtitleFontSize,
|
||||
subtitleFontWeight,
|
||||
bodyFontSize,
|
||||
bodyFontWeight,
|
||||
});
|
||||
}
|
||||
|
||||
if (subtitleText) {
|
||||
addNode({
|
||||
type: "text",
|
||||
position: { x: x + safeLeft, y: y + 214 },
|
||||
size: { width: textWidth, height: 120 },
|
||||
data: {
|
||||
content: subtitleText,
|
||||
fontSize: subtitleFontSize,
|
||||
fontWeight: subtitleFontWeight,
|
||||
color: "#374151",
|
||||
lineHeight: 1.22,
|
||||
textAlign: "left",
|
||||
transparent: true,
|
||||
},
|
||||
connections: [],
|
||||
parentId: params.nodeId,
|
||||
replaceDetailTextLayers({
|
||||
nodeId: params.nodeId,
|
||||
drafts,
|
||||
});
|
||||
}
|
||||
|
||||
if (bodyText) {
|
||||
addNode({
|
||||
type: "text",
|
||||
position: { x: x + safeLeft, y: y + 304 },
|
||||
size: { width: textWidth, height: 260 },
|
||||
data: {
|
||||
content: bodyText,
|
||||
fontSize: bodyFontSize,
|
||||
fontWeight: bodyFontWeight,
|
||||
color: "#4b5563",
|
||||
lineHeight: 1.38,
|
||||
textAlign: "left",
|
||||
transparent: true,
|
||||
},
|
||||
connections: [],
|
||||
parentId: params.nodeId,
|
||||
});
|
||||
}
|
||||
};
|
||||
for (let index = 0; index < prompts.length; index += 1) {
|
||||
const screen = screenSpecs[index];
|
||||
@ -6306,15 +6304,19 @@ export function CanvasChatPanel({
|
||||
let finalGenerated = firstPassImage;
|
||||
let finalGenerationPrompt = generationPrompt;
|
||||
let screenReviewResult: DetailScreenReviewResult | null = null;
|
||||
if (!isAgentLayoutMode) {
|
||||
const targetNodeId = detailPlaceholderNodeIds[index];
|
||||
patchDetailPlaceholder(index, {
|
||||
label: `双总监审稿中 ${index + 1}/${prompts.length}`,
|
||||
label: isAgentLayoutMode
|
||||
? `协议质检中 ${index + 1}/${prompts.length}`
|
||||
: `双总监审稿中 ${index + 1}/${prompts.length}`,
|
||||
status: "generating",
|
||||
});
|
||||
|
||||
setDetailProgress({
|
||||
status: "running",
|
||||
stage: `第 ${index + 1}/${prompts.length} 屏质检中,Agent 正在检查标题与排版...`,
|
||||
stage: isAgentLayoutMode
|
||||
? `第 ${index + 1}/${prompts.length} 屏协议质检中,Agent 正在检查背景与文字层协议...`
|
||||
: `第 ${index + 1}/${prompts.length} 屏质检中,Agent 正在检查标题与排版...`,
|
||||
current: reviewStepCurrent,
|
||||
total: totalSteps,
|
||||
});
|
||||
@ -6326,7 +6328,9 @@ export function CanvasChatPanel({
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `### AI详情生成\n第 ${index + 1}/${prompts.length} 屏首稿完成,正在质检与排版复核...`,
|
||||
text: isAgentLayoutMode
|
||||
? `### AI详情生成\n第 ${index + 1}/${prompts.length} 屏首稿完成,正在检查背景画面与协议文字层...`
|
||||
: `### AI详情生成\n第 ${index + 1}/${prompts.length} 屏首稿完成,正在质检与排版复核...`,
|
||||
},
|
||||
],
|
||||
}
|
||||
@ -6340,6 +6344,7 @@ export function CanvasChatPanel({
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
outputLanguage,
|
||||
generationMode: activeDetailGenerationMode,
|
||||
imageUrl: firstPassImage.originalUrl || firstPassImage.thumbnailUrl,
|
||||
referenceImages: generationReferenceImagesBase.slice(0, 6),
|
||||
brief,
|
||||
@ -6363,12 +6368,68 @@ export function CanvasChatPanel({
|
||||
console.error("[CanvasChat] Detail screen review failed:", reviewError);
|
||||
}
|
||||
|
||||
const shouldApplyProtocolPatch = Boolean(
|
||||
screenReviewResult?.uiPatch &&
|
||||
screenReviewResult?.reviewAction === "protocol_patch",
|
||||
);
|
||||
const shouldRegenerate =
|
||||
screenReviewResult?.pass === false &&
|
||||
!shouldApplyProtocolPatch &&
|
||||
screenReviewResult?.reviewAction === "regenerate_image" &&
|
||||
typeof screenReviewResult?.revisedPrompt === "string" &&
|
||||
screenReviewResult.revisedPrompt.trim().length > 20;
|
||||
|
||||
if (shouldRegenerate) {
|
||||
if (shouldApplyProtocolPatch) {
|
||||
patchDetailPlaceholder(index, {
|
||||
label: `协议修正中 ${index + 1}/${prompts.length}`,
|
||||
status: "generating",
|
||||
});
|
||||
const issueHint = Array.isArray(screenReviewResult?.issues)
|
||||
? screenReviewResult.issues.filter(Boolean).slice(0, 2).join(";")
|
||||
: "";
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
message.id === assistantMessageId
|
||||
? {
|
||||
...message,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `### AI详情生成\n第 ${index + 1}/${prompts.length} 屏质检发现排版/文案层可优化${issueHint ? `:${issueHint}` : ""},正在直接修正协议文字层...`,
|
||||
},
|
||||
],
|
||||
}
|
||||
: message,
|
||||
),
|
||||
);
|
||||
|
||||
const currentNode = targetNodeId
|
||||
? useCanvasStore.getState().getNode(targetNodeId)
|
||||
: null;
|
||||
if (currentNode && targetNodeId) {
|
||||
const replacementDrafts = buildDetailTextLayerNodesFromReviewPatch({
|
||||
parentNodeId: targetNodeId,
|
||||
parentPosition: {
|
||||
x: Number(currentNode.position?.x || 0),
|
||||
y: Number(currentNode.position?.y || 0),
|
||||
},
|
||||
parentSize: {
|
||||
width: Number(currentNode.size?.width || DETAIL_TARGET_WIDTH),
|
||||
height: Number(currentNode.size?.height || DETAIL_TARGET_HEIGHT),
|
||||
},
|
||||
screenSize: planData.uiProtocol?.screenSize || {
|
||||
width: DETAIL_TARGET_WIDTH,
|
||||
height: DETAIL_TARGET_HEIGHT,
|
||||
},
|
||||
uiPatch: screenReviewResult?.uiPatch,
|
||||
});
|
||||
if (replacementDrafts.length > 0) {
|
||||
replaceDetailTextLayers({
|
||||
nodeId: targetNodeId,
|
||||
drafts: replacementDrafts,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (shouldRegenerate) {
|
||||
patchDetailPlaceholder(index, {
|
||||
label: `二次优化生成中 ${index + 1}/${prompts.length}`,
|
||||
status: "generating",
|
||||
@ -6409,8 +6470,7 @@ export function CanvasChatPanel({
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if (isAgentLayoutMode) {
|
||||
patchDetailPlaceholder(index, {
|
||||
label: `排版层渲染中 ${index + 1}/${prompts.length}`,
|
||||
status: "generating",
|
||||
@ -6441,7 +6501,6 @@ export function CanvasChatPanel({
|
||||
const dimensions = await getImageDimensions(
|
||||
finalGenerated.originalUrl || finalGenerated.thumbnailUrl
|
||||
);
|
||||
const targetNodeId = detailPlaceholderNodeIds[index];
|
||||
if (targetNodeId) {
|
||||
const storeState = useCanvasStore.getState();
|
||||
const currentNode = storeState.getNode(targetNodeId);
|
||||
@ -6486,6 +6545,7 @@ export function CanvasChatPanel({
|
||||
nodeId: targetNodeId,
|
||||
screenIndex: index,
|
||||
screen,
|
||||
screenProtocol: planData.uiProtocol?.screens?.[index],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
301
lib/detail-ui-protocol.ts
Normal file
301
lib/detail-ui-protocol.ts
Normal file
@ -0,0 +1,301 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDetailUiReviewPatch(params: {
|
||||
generationMode: DetailGenerationMode;
|
||||
screenSize: { width: number; height: number };
|
||||
globalStyle?: DetailUiGlobalStyle;
|
||||
screen: DetailUiScreenInput;
|
||||
layoutFixes?: string[];
|
||||
typographyFixes?: string[];
|
||||
conversionFixes?: string[];
|
||||
}): DetailUiScreenReviewPatch {
|
||||
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: protocol.screens[0]!,
|
||||
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 })),
|
||||
],
|
||||
};
|
||||
}
|
||||
175
lib/detail-ui-renderer.ts
Normal file
175
lib/detail-ui-renderer.ts
Normal file
@ -0,0 +1,175 @@
|
||||
import type {
|
||||
DetailUiProtocol,
|
||||
DetailUiScreenReviewPatch,
|
||||
} from "@/lib/detail-ui-protocol";
|
||||
import type { CanvasNode, TextNodeData } from "@/types/canvas";
|
||||
|
||||
type CanvasTextNodeDraft = Omit<CanvasNode, "id" | "createdAt" | "updatedAt">;
|
||||
|
||||
function createTextNodeDraft(params: {
|
||||
parentId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
content: string;
|
||||
fontSize: number;
|
||||
fontWeight: string;
|
||||
color: string;
|
||||
lineHeight: number;
|
||||
textAlign: "left" | "center" | "right";
|
||||
metadata?: Record<string, unknown>;
|
||||
}): CanvasTextNodeDraft {
|
||||
return {
|
||||
type: "text",
|
||||
position: { x: params.x, y: params.y },
|
||||
size: { width: params.width, height: params.height },
|
||||
data: {
|
||||
content: params.content,
|
||||
fontSize: params.fontSize,
|
||||
fontWeight: params.fontWeight,
|
||||
color: params.color,
|
||||
lineHeight: params.lineHeight,
|
||||
textAlign: params.textAlign,
|
||||
transparent: true,
|
||||
metadata: params.metadata,
|
||||
} as TextNodeData & { metadata?: Record<string, unknown> },
|
||||
connections: [],
|
||||
parentId: params.parentId,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDetailTextLayerNodesFromProtocol(params: {
|
||||
parentNodeId: string;
|
||||
parentPosition: { x: number; y: number };
|
||||
parentSize: { width: number; height: number };
|
||||
screenSize: { width: number; height: number };
|
||||
screenProtocol?: DetailUiProtocol["screens"][number];
|
||||
}) {
|
||||
const protocolBlocks = params.screenProtocol?.blocks || [];
|
||||
if (protocolBlocks.length === 0) return [] as CanvasTextNodeDraft[];
|
||||
|
||||
const scaleX = params.parentSize.width / Math.max(1, params.screenSize.width);
|
||||
const scaleY = params.parentSize.height / Math.max(1, params.screenSize.height);
|
||||
|
||||
return protocolBlocks.map((block) =>
|
||||
createTextNodeDraft({
|
||||
parentId: params.parentNodeId,
|
||||
x: params.parentPosition.x + Math.round(block.frame.x * scaleX),
|
||||
y: params.parentPosition.y + Math.round(block.frame.y * scaleY),
|
||||
width: Math.max(120, Math.round(block.frame.width * scaleX)),
|
||||
height: Math.max(48, Math.round(block.frame.height * scaleY)),
|
||||
content: block.content,
|
||||
fontSize: Math.max(12, Math.round(block.style.fontSize * Math.min(scaleX, scaleY))),
|
||||
fontWeight: block.style.fontWeight,
|
||||
color: block.style.color,
|
||||
lineHeight: block.style.lineHeight,
|
||||
textAlign: block.style.textAlign,
|
||||
metadata: {
|
||||
detailUiBlockId: block.id,
|
||||
detailUiBlockType: block.type,
|
||||
detailUiScreenIndex: params.screenProtocol?.index,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildDetailTextLayerNodesFromReviewPatch(params: {
|
||||
parentNodeId: string;
|
||||
parentPosition: { x: number; y: number };
|
||||
parentSize: { width: number; height: number };
|
||||
screenSize: { width: number; height: number };
|
||||
uiPatch?: DetailUiScreenReviewPatch;
|
||||
}) {
|
||||
if (!params.uiPatch) return [] as CanvasTextNodeDraft[];
|
||||
return buildDetailTextLayerNodesFromProtocol({
|
||||
parentNodeId: params.parentNodeId,
|
||||
parentPosition: params.parentPosition,
|
||||
parentSize: params.parentSize,
|
||||
screenSize: params.screenSize,
|
||||
screenProtocol: params.uiPatch.recommendedScreen,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildLegacyDetailTextLayerNodes(params: {
|
||||
parentNodeId: string;
|
||||
parentPosition: { x: number; y: number };
|
||||
parentWidth: number;
|
||||
screenIndex: number;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
body?: string;
|
||||
titleFontSize: number;
|
||||
titleFontWeight: string;
|
||||
subtitleFontSize: number;
|
||||
subtitleFontWeight: string;
|
||||
bodyFontSize: number;
|
||||
bodyFontWeight: string;
|
||||
}) {
|
||||
const safeLeft = Math.round(params.parentWidth * 0.07);
|
||||
const textWidth = Math.max(240, Math.round(params.parentWidth - safeLeft * 2));
|
||||
const drafts: CanvasTextNodeDraft[] = [];
|
||||
|
||||
const titleText = String(params.title || "").trim();
|
||||
const subtitleText = String(params.subtitle || "").trim();
|
||||
const bodyText = String(params.body || "").trim();
|
||||
|
||||
if (titleText) {
|
||||
drafts.push(
|
||||
createTextNodeDraft({
|
||||
parentId: params.parentNodeId,
|
||||
x: params.parentPosition.x + safeLeft,
|
||||
y: params.parentPosition.y + 72,
|
||||
width: textWidth,
|
||||
height: 140,
|
||||
content: titleText,
|
||||
fontSize:
|
||||
params.screenIndex === 0
|
||||
? params.titleFontSize
|
||||
: Math.round(params.titleFontSize * 0.86),
|
||||
fontWeight: params.titleFontWeight,
|
||||
color: "#111827",
|
||||
lineHeight: 1.15,
|
||||
textAlign: "left",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (subtitleText) {
|
||||
drafts.push(
|
||||
createTextNodeDraft({
|
||||
parentId: params.parentNodeId,
|
||||
x: params.parentPosition.x + safeLeft,
|
||||
y: params.parentPosition.y + 214,
|
||||
width: textWidth,
|
||||
height: 120,
|
||||
content: subtitleText,
|
||||
fontSize: params.subtitleFontSize,
|
||||
fontWeight: params.subtitleFontWeight,
|
||||
color: "#374151",
|
||||
lineHeight: 1.22,
|
||||
textAlign: "left",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (bodyText) {
|
||||
drafts.push(
|
||||
createTextNodeDraft({
|
||||
parentId: params.parentNodeId,
|
||||
x: params.parentPosition.x + safeLeft,
|
||||
y: params.parentPosition.y + 304,
|
||||
width: textWidth,
|
||||
height: 260,
|
||||
content: bodyText,
|
||||
fontSize: params.bodyFontSize,
|
||||
fontWeight: params.bodyFontWeight,
|
||||
color: "#4b5563",
|
||||
lineHeight: 1.38,
|
||||
textAlign: "left",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return drafts;
|
||||
}
|
||||
149
lib/detail-ui-validator.ts
Normal file
149
lib/detail-ui-validator.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import type {
|
||||
DetailUiProtocol,
|
||||
DetailUiScreenProtocol,
|
||||
DetailUiTextBlock,
|
||||
} from "@/lib/detail-ui-protocol";
|
||||
|
||||
export type DetailUiValidationIssueType =
|
||||
| "out_of_safe_area"
|
||||
| "block_overlap"
|
||||
| "reading_order"
|
||||
| "typography_hierarchy";
|
||||
|
||||
export interface DetailUiValidationIssue {
|
||||
type: DetailUiValidationIssueType;
|
||||
message: string;
|
||||
screenIndex: number;
|
||||
blockIds?: string[];
|
||||
}
|
||||
|
||||
export interface DetailUiValidationResult {
|
||||
passed: boolean;
|
||||
issues: DetailUiValidationIssue[];
|
||||
}
|
||||
|
||||
function intersects(a: DetailUiTextBlock["frame"], b: DetailUiTextBlock["frame"]) {
|
||||
return (
|
||||
a.x < b.x + b.width &&
|
||||
a.x + a.width > b.x &&
|
||||
a.y < b.y + b.height &&
|
||||
a.y + a.height > b.y
|
||||
);
|
||||
}
|
||||
|
||||
function validateSingleScreen(
|
||||
screen: DetailUiScreenProtocol,
|
||||
screenSize: { width: number; height: number },
|
||||
): DetailUiValidationIssue[] {
|
||||
const issues: DetailUiValidationIssue[] = [];
|
||||
const blocks = [...screen.blocks];
|
||||
|
||||
for (const block of blocks) {
|
||||
const safe = screen.safeArea;
|
||||
const blockRight = block.frame.x + block.frame.width;
|
||||
const blockBottom = block.frame.y + block.frame.height;
|
||||
|
||||
if (
|
||||
block.frame.x < safe.left ||
|
||||
blockRight > screenSize.width - safe.right ||
|
||||
block.frame.y < safe.top ||
|
||||
blockBottom > safe.bottom
|
||||
) {
|
||||
issues.push({
|
||||
type: "out_of_safe_area",
|
||||
screenIndex: screen.index,
|
||||
blockIds: [block.id],
|
||||
message: `屏${screen.index} 的 ${block.type} 超出安全区,需回到可读区域内。`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < blocks.length; i += 1) {
|
||||
for (let j = i + 1; j < blocks.length; j += 1) {
|
||||
if (intersects(blocks[i]!.frame, blocks[j]!.frame)) {
|
||||
issues.push({
|
||||
type: "block_overlap",
|
||||
screenIndex: screen.index,
|
||||
blockIds: [blocks[i]!.id, blocks[j]!.id],
|
||||
message: `屏${screen.index} 存在文字块重叠:${blocks[i]!.type} 与 ${blocks[j]!.type}。`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const title = blocks.find((block) => block.type === "title");
|
||||
const subtitle = blocks.find((block) => block.type === "subtitle");
|
||||
const body = blocks.find((block) => block.type === "body");
|
||||
|
||||
if (title && subtitle && title.frame.y > subtitle.frame.y) {
|
||||
issues.push({
|
||||
type: "reading_order",
|
||||
screenIndex: screen.index,
|
||||
blockIds: [title.id, subtitle.id],
|
||||
message: `屏${screen.index} 的标题和副标题顺序异常,标题必须在副标题上方。`,
|
||||
});
|
||||
}
|
||||
|
||||
if (subtitle && body && subtitle.frame.y > body.frame.y) {
|
||||
issues.push({
|
||||
type: "reading_order",
|
||||
screenIndex: screen.index,
|
||||
blockIds: [subtitle.id, body.id],
|
||||
message: `屏${screen.index} 的副标题和正文顺序异常,副标题必须在正文上方。`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
title &&
|
||||
subtitle &&
|
||||
!(title.style.fontSize > subtitle.style.fontSize)
|
||||
) {
|
||||
issues.push({
|
||||
type: "typography_hierarchy",
|
||||
screenIndex: screen.index,
|
||||
blockIds: [title.id, subtitle.id],
|
||||
message: `屏${screen.index} 的标题字号层级不足,标题应明显大于副标题。`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
subtitle &&
|
||||
body &&
|
||||
!(subtitle.style.fontSize > body.style.fontSize)
|
||||
) {
|
||||
issues.push({
|
||||
type: "typography_hierarchy",
|
||||
screenIndex: screen.index,
|
||||
blockIds: [subtitle.id, body.id],
|
||||
message: `屏${screen.index} 的副标题字号层级不足,副标题应明显大于正文。`,
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function validateDetailUiProtocol(
|
||||
protocol?: DetailUiProtocol | null,
|
||||
): DetailUiValidationResult {
|
||||
if (!protocol) {
|
||||
return {
|
||||
passed: false,
|
||||
issues: [
|
||||
{
|
||||
type: "reading_order",
|
||||
screenIndex: 0,
|
||||
message: "缺少详情 UI 协议,无法校验排版。",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const issues = protocol.screens.flatMap((screen) =>
|
||||
validateSingleScreen(screen, protocol.screenSize),
|
||||
);
|
||||
|
||||
return {
|
||||
passed: issues.length === 0,
|
||||
issues,
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user