470 lines
15 KiB
TypeScript
470 lines
15 KiB
TypeScript
import type {
|
||
UiScreenBounds,
|
||
UiScreenDocument,
|
||
UiScreenLayer,
|
||
} from "@/lib/ui-screen/schema";
|
||
|
||
export type UiScreenValidationSeverity = "info" | "warning" | "error";
|
||
|
||
export type UiScreenValidationCode =
|
||
| "out_of_bounds"
|
||
| "missing_image_source"
|
||
| "empty_text"
|
||
| "tiny_text"
|
||
| "dense_text"
|
||
| "overlap"
|
||
| "overflow_region"
|
||
| "excessive_radius"
|
||
| "heavy_shadow"
|
||
| "negative_letter_spacing"
|
||
| "overlay_too_large"
|
||
| "nested_card"
|
||
| "missing_design_skill_metadata";
|
||
|
||
export interface UiScreenValidationIssue {
|
||
id: string;
|
||
layerId?: string;
|
||
severity: UiScreenValidationSeverity;
|
||
code: UiScreenValidationCode;
|
||
message: string;
|
||
suggestion?: string;
|
||
details?: Record<string, number | string | boolean | undefined>;
|
||
}
|
||
|
||
export interface UiScreenValidationResult {
|
||
passed: boolean;
|
||
score: number;
|
||
issues: UiScreenValidationIssue[];
|
||
}
|
||
|
||
type FlatLayer = {
|
||
layer: UiScreenLayer;
|
||
path: string[];
|
||
ancestorKinds: UiScreenLayer["kind"][];
|
||
parentId?: string;
|
||
absoluteBounds?: UiScreenBounds;
|
||
parentBounds?: UiScreenBounds;
|
||
};
|
||
|
||
const DOCUMENT_EDGE_TOLERANCE = 2;
|
||
const MIN_READABLE_FONT_SIZE = 11;
|
||
|
||
function clampScore(value: number) {
|
||
return Math.max(0, Math.min(100, Math.round(value)));
|
||
}
|
||
|
||
function rectArea(bounds: UiScreenBounds) {
|
||
return Math.max(0, bounds.width) * Math.max(0, bounds.height);
|
||
}
|
||
|
||
function intersectionArea(left: UiScreenBounds, right: UiScreenBounds) {
|
||
const x = Math.max(left.x, right.x);
|
||
const y = Math.max(left.y, right.y);
|
||
const width = Math.min(left.x + left.width, right.x + right.width) - x;
|
||
const height = Math.min(left.y + left.height, right.y + right.height) - y;
|
||
return width > 0 && height > 0 ? width * height : 0;
|
||
}
|
||
|
||
function offsetBounds(bounds: UiScreenBounds, parentBounds?: UiScreenBounds): UiScreenBounds {
|
||
return {
|
||
x: Math.round((parentBounds?.x || 0) + bounds.x),
|
||
y: Math.round((parentBounds?.y || 0) + bounds.y),
|
||
width: Math.round(bounds.width),
|
||
height: Math.round(bounds.height),
|
||
};
|
||
}
|
||
|
||
function flattenLayers(params: {
|
||
layers: UiScreenLayer[];
|
||
parentId?: string;
|
||
parentBounds?: UiScreenBounds;
|
||
path?: string[];
|
||
ancestorKinds?: UiScreenLayer["kind"][];
|
||
}): FlatLayer[] {
|
||
const flattened: FlatLayer[] = [];
|
||
|
||
for (const layer of params.layers) {
|
||
if (layer.visible === false) continue;
|
||
|
||
const absoluteBounds = layer.bounds
|
||
? offsetBounds(layer.bounds, params.parentBounds)
|
||
: undefined;
|
||
const item: FlatLayer = {
|
||
layer,
|
||
path: [...(params.path || []), layer.id],
|
||
ancestorKinds: params.ancestorKinds || [],
|
||
parentId: params.parentId,
|
||
parentBounds: params.parentBounds,
|
||
absoluteBounds,
|
||
};
|
||
|
||
flattened.push(item);
|
||
if (layer.children?.length) {
|
||
flattened.push(
|
||
...flattenLayers({
|
||
layers: layer.children,
|
||
parentId: layer.id,
|
||
parentBounds: absoluteBounds || params.parentBounds,
|
||
path: item.path,
|
||
ancestorKinds: [...item.ancestorKinds, layer.kind],
|
||
})
|
||
);
|
||
}
|
||
}
|
||
|
||
return flattened;
|
||
}
|
||
|
||
function textValue(layer: UiScreenLayer) {
|
||
if (layer.kind === "text") return layer.text || "";
|
||
if (layer.kind === "button" || layer.kind === "badge") {
|
||
return layer.label || layer.text || "";
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function isTextLikeLayer(layer: UiScreenLayer) {
|
||
return layer.kind === "text" || layer.kind === "button" || layer.kind === "badge";
|
||
}
|
||
|
||
function isOverlapCandidate(layer: UiScreenLayer) {
|
||
return (
|
||
layer.kind === "text" ||
|
||
layer.kind === "button" ||
|
||
layer.kind === "badge" ||
|
||
layer.kind === "card"
|
||
);
|
||
}
|
||
|
||
function textWeight(text: string) {
|
||
let weight = 0;
|
||
for (const char of text) {
|
||
if (/\s/.test(char)) {
|
||
weight += 0.25;
|
||
} else if (/[\u3400-\u9fff]/.test(char)) {
|
||
weight += 1;
|
||
} else {
|
||
weight += 0.58;
|
||
}
|
||
}
|
||
return weight;
|
||
}
|
||
|
||
function hasAsset(document: UiScreenDocument, assetId: string | undefined) {
|
||
if (!assetId) return false;
|
||
return Boolean(document.assets?.some((asset) => asset.id === assetId && asset.src));
|
||
}
|
||
|
||
function isAncestor(left: FlatLayer, right: FlatLayer) {
|
||
return (
|
||
left.path.includes(right.layer.id) ||
|
||
right.path.includes(left.layer.id)
|
||
);
|
||
}
|
||
|
||
function issueId(layerId: string | undefined, code: UiScreenValidationCode, suffix = "") {
|
||
return [code, layerId || "document", suffix].filter(Boolean).join(":");
|
||
}
|
||
|
||
function hasVisibleSurface(layer: UiScreenLayer) {
|
||
return Boolean(layer.style?.background || layer.style?.borderColor || layer.style?.boxShadow);
|
||
}
|
||
|
||
function maxShadowPixelValue(boxShadow: string | undefined) {
|
||
if (!boxShadow) return 0;
|
||
const matches = boxShadow.match(/-?\d+(?:\.\d+)?px/g) || [];
|
||
return matches.reduce((max, value) => Math.max(max, Math.abs(Number.parseFloat(value))), 0);
|
||
}
|
||
|
||
function isDetailAgentDocument(document: UiScreenDocument) {
|
||
return document.metadata?.source === "detail-agent-layout";
|
||
}
|
||
|
||
export function validateUiScreenDocument(
|
||
document: UiScreenDocument
|
||
): UiScreenValidationResult {
|
||
const issues: UiScreenValidationIssue[] = [];
|
||
const flattened = flattenLayers({ layers: document.layers });
|
||
const documentArea = Math.max(1, document.width * document.height);
|
||
|
||
if (isDetailAgentDocument(document) && !document.metadata?.detailDesignSkillVersion) {
|
||
issues.push({
|
||
id: issueId(undefined, "missing_design_skill_metadata"),
|
||
severity: "info",
|
||
code: "missing_design_skill_metadata",
|
||
message: "当前详情 UI 文档缺少设计技能版本标记",
|
||
suggestion: "用新版 AI详情 AGENT排版重新生成,或在 metadata 中补齐 detailDesignSkillVersion。",
|
||
});
|
||
}
|
||
|
||
for (const item of flattened) {
|
||
const { layer, absoluteBounds } = item;
|
||
|
||
if (absoluteBounds) {
|
||
const overflowLeft = absoluteBounds.x < -DOCUMENT_EDGE_TOLERANCE;
|
||
const overflowTop = absoluteBounds.y < -DOCUMENT_EDGE_TOLERANCE;
|
||
const overflowRight =
|
||
absoluteBounds.x + absoluteBounds.width > document.width + DOCUMENT_EDGE_TOLERANCE;
|
||
const overflowBottom =
|
||
absoluteBounds.y + absoluteBounds.height > document.height + DOCUMENT_EDGE_TOLERANCE;
|
||
|
||
if (overflowLeft || overflowTop || overflowRight || overflowBottom) {
|
||
issues.push({
|
||
id: issueId(layer.id, "out_of_bounds"),
|
||
layerId: layer.id,
|
||
severity: "error",
|
||
code: "out_of_bounds",
|
||
message: `图层「${layer.name || layer.id}」超出画布边界`,
|
||
suggestion: "将图层 bounds 限制在 ui-screen 文档宽高范围内。",
|
||
details: {
|
||
x: absoluteBounds.x,
|
||
y: absoluteBounds.y,
|
||
width: absoluteBounds.width,
|
||
height: absoluteBounds.height,
|
||
documentWidth: document.width,
|
||
documentHeight: document.height,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
if (layer.bounds && item.parentBounds) {
|
||
const childOverflowsParent =
|
||
layer.bounds.x < -DOCUMENT_EDGE_TOLERANCE ||
|
||
layer.bounds.y < -DOCUMENT_EDGE_TOLERANCE ||
|
||
layer.bounds.x + layer.bounds.width >
|
||
item.parentBounds.width + DOCUMENT_EDGE_TOLERANCE ||
|
||
layer.bounds.y + layer.bounds.height >
|
||
item.parentBounds.height + DOCUMENT_EDGE_TOLERANCE;
|
||
|
||
if (childOverflowsParent) {
|
||
issues.push({
|
||
id: issueId(layer.id, "overflow_region"),
|
||
layerId: layer.id,
|
||
severity: "warning",
|
||
code: "overflow_region",
|
||
message: `图层「${layer.name || layer.id}」溢出父级区域`,
|
||
suggestion: "收窄图层 bounds,或扩大父级容器的有效排版区域。",
|
||
details: {
|
||
parentId: item.parentId,
|
||
x: layer.bounds.x,
|
||
y: layer.bounds.y,
|
||
width: layer.bounds.width,
|
||
height: layer.bounds.height,
|
||
parentWidth: item.parentBounds.width,
|
||
parentHeight: item.parentBounds.height,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
if (layer.kind === "image" && !layer.src && !hasAsset(document, layer.assetId)) {
|
||
issues.push({
|
||
id: issueId(layer.id, "missing_image_source"),
|
||
layerId: layer.id,
|
||
severity: "error",
|
||
code: "missing_image_source",
|
||
message: `图片图层「${layer.name || layer.id}」缺少可渲染素材`,
|
||
suggestion: "为图层设置 src,或补齐匹配 assetId 的 assets 条目。",
|
||
});
|
||
}
|
||
|
||
if (
|
||
isDetailAgentDocument(document) &&
|
||
absoluteBounds &&
|
||
!item.parentId &&
|
||
layer.kind !== "image"
|
||
) {
|
||
const coverage = rectArea(absoluteBounds) / documentArea;
|
||
if (coverage > 0.45) {
|
||
issues.push({
|
||
id: issueId(layer.id, "overlay_too_large"),
|
||
layerId: layer.id,
|
||
severity: "warning",
|
||
code: "overlay_too_large",
|
||
message: `可编辑 UI 区域「${layer.name || layer.id}」覆盖画面过大`,
|
||
suggestion: "将 region 控制在画布 45% 内,让产品/素材主体成为第一视觉。",
|
||
details: {
|
||
coverage: Number(coverage.toFixed(2)),
|
||
maxCoverage: 0.45,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
if (layer.kind === "card" && item.ancestorKinds.includes("card")) {
|
||
issues.push({
|
||
id: issueId(layer.id, "nested_card"),
|
||
layerId: layer.id,
|
||
severity: "warning",
|
||
code: "nested_card",
|
||
message: `卡片「${layer.name || layer.id}」存在卡片套卡片`,
|
||
suggestion: "扁平化信息结构,改用 stack/grid 分组或减少外层卡片。",
|
||
});
|
||
}
|
||
|
||
if (
|
||
layer.style?.borderRadius !== undefined &&
|
||
layer.kind !== "button" &&
|
||
layer.kind !== "badge" &&
|
||
hasVisibleSurface(layer) &&
|
||
layer.style.borderRadius > 16
|
||
) {
|
||
issues.push({
|
||
id: issueId(layer.id, "excessive_radius"),
|
||
layerId: layer.id,
|
||
severity: "warning",
|
||
code: "excessive_radius",
|
||
message: `图层「${layer.name || layer.id}」圆角过大`,
|
||
suggestion: "详情 UI 面板/卡片圆角建议控制在 8-12px,避免廉价模板感。",
|
||
details: {
|
||
borderRadius: layer.style.borderRadius,
|
||
maxRadius: 16,
|
||
},
|
||
});
|
||
}
|
||
|
||
const shadowMax = maxShadowPixelValue(layer.style?.boxShadow);
|
||
if (shadowMax > 56) {
|
||
issues.push({
|
||
id: issueId(layer.id, "heavy_shadow"),
|
||
layerId: layer.id,
|
||
severity: "warning",
|
||
code: "heavy_shadow",
|
||
message: `图层「${layer.name || layer.id}」阴影过重`,
|
||
suggestion: "降低阴影模糊和扩散,使用更轻的层级表达。",
|
||
details: {
|
||
maxShadowPx: shadowMax,
|
||
recommendedMaxPx: 56,
|
||
},
|
||
});
|
||
}
|
||
|
||
if (isTextLikeLayer(layer)) {
|
||
const text = textValue(layer).trim();
|
||
const fontSize = layer.style?.fontSize;
|
||
|
||
if (typeof layer.style?.letterSpacing === "number" && layer.style.letterSpacing < 0) {
|
||
issues.push({
|
||
id: issueId(layer.id, "negative_letter_spacing"),
|
||
layerId: layer.id,
|
||
severity: "warning",
|
||
code: "negative_letter_spacing",
|
||
message: `文字图层「${layer.name || layer.id}」使用了负字距`,
|
||
suggestion: "将 letterSpacing 调整为 0,减少跨字体渲染风险。",
|
||
details: {
|
||
letterSpacing: layer.style.letterSpacing,
|
||
},
|
||
});
|
||
}
|
||
|
||
if (!text) {
|
||
issues.push({
|
||
id: issueId(layer.id, "empty_text"),
|
||
layerId: layer.id,
|
||
severity: "warning",
|
||
code: "empty_text",
|
||
message: `文字图层「${layer.name || layer.id}」为空`,
|
||
suggestion: "补齐真实文案,或隐藏该图层。",
|
||
});
|
||
}
|
||
|
||
if (typeof fontSize === "number" && fontSize > 0 && fontSize < MIN_READABLE_FONT_SIZE) {
|
||
issues.push({
|
||
id: issueId(layer.id, "tiny_text"),
|
||
layerId: layer.id,
|
||
severity: "warning",
|
||
code: "tiny_text",
|
||
message: `文字图层「${layer.name || layer.id}」字号过小`,
|
||
suggestion: `将字号提升到至少 ${MIN_READABLE_FONT_SIZE}px。`,
|
||
details: {
|
||
fontSize,
|
||
minFontSize: MIN_READABLE_FONT_SIZE,
|
||
},
|
||
});
|
||
}
|
||
|
||
if (text && absoluteBounds) {
|
||
const readableFontSize =
|
||
typeof fontSize === "number" && fontSize > 0 ? fontSize : 16;
|
||
const lineHeight =
|
||
typeof layer.style?.lineHeight === "number" && layer.style.lineHeight > 0
|
||
? layer.style.lineHeight
|
||
: 1.28;
|
||
const horizontalCapacity = Math.max(1, absoluteBounds.width / (readableFontSize * 0.64));
|
||
const verticalCapacity = Math.max(
|
||
1,
|
||
absoluteBounds.height / (readableFontSize * lineHeight)
|
||
);
|
||
const estimatedCapacity = horizontalCapacity * verticalCapacity;
|
||
|
||
if (textWeight(text) > estimatedCapacity * 1.24) {
|
||
issues.push({
|
||
id: issueId(layer.id, "dense_text"),
|
||
layerId: layer.id,
|
||
severity: "warning",
|
||
code: "dense_text",
|
||
message: `文字图层「${layer.name || layer.id}」内容密度偏高`,
|
||
suggestion: "减少文案长度、放大文本区域,或降低字号并提高行高。",
|
||
details: {
|
||
textWeight: Math.round(textWeight(text)),
|
||
estimatedCapacity: Math.round(estimatedCapacity),
|
||
width: absoluteBounds.width,
|
||
height: absoluteBounds.height,
|
||
fontSize: readableFontSize,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const overlapCandidates = flattened.filter(
|
||
(item) => item.absoluteBounds && isOverlapCandidate(item.layer)
|
||
);
|
||
|
||
for (let leftIndex = 0; leftIndex < overlapCandidates.length; leftIndex += 1) {
|
||
for (
|
||
let rightIndex = leftIndex + 1;
|
||
rightIndex < overlapCandidates.length;
|
||
rightIndex += 1
|
||
) {
|
||
const left = overlapCandidates[leftIndex];
|
||
const right = overlapCandidates[rightIndex];
|
||
if (!left?.absoluteBounds || !right?.absoluteBounds || isAncestor(left, right)) {
|
||
continue;
|
||
}
|
||
|
||
const intersection = intersectionArea(left.absoluteBounds, right.absoluteBounds);
|
||
const smallerArea = Math.min(rectArea(left.absoluteBounds), rectArea(right.absoluteBounds));
|
||
if (smallerArea <= 0 || intersection / smallerArea < 0.42) continue;
|
||
|
||
issues.push({
|
||
id: issueId(left.layer.id, "overlap", right.layer.id),
|
||
layerId: left.layer.id,
|
||
severity: "warning",
|
||
code: "overlap",
|
||
message: `图层「${left.layer.name || left.layer.id}」与「${
|
||
right.layer.name || right.layer.id
|
||
}」重叠过多`,
|
||
suggestion: "拉开图层间距,或把其中一个图层移动到新的排版区域。",
|
||
details: {
|
||
otherLayerId: right.layer.id,
|
||
overlapRatio: Number((intersection / smallerArea).toFixed(2)),
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
const errorCount = issues.filter((issue) => issue.severity === "error").length;
|
||
const warningCount = issues.filter((issue) => issue.severity === "warning").length;
|
||
const infoCount = issues.filter((issue) => issue.severity === "info").length;
|
||
const score = clampScore(100 - errorCount * 18 - warningCount * 8 - infoCount * 3);
|
||
|
||
return {
|
||
passed: errorCount === 0 && score >= 82,
|
||
score,
|
||
issues,
|
||
};
|
||
}
|