chore: initial import issue hub ai
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { deflateSync, inflateSync } from "node:zlib";
|
||||
|
||||
const designDir = path.resolve("docs/design/images2");
|
||||
const implementationDir = path.resolve("docs/design/implementation-screenshots");
|
||||
const outputDir = path.resolve("docs/design/visual-diff");
|
||||
const threshold = 24;
|
||||
const gridColumns = 12;
|
||||
const gridRows = 8;
|
||||
|
||||
const pageNames = [
|
||||
"dispatcher-01-center-dashboard.png",
|
||||
"dispatcher-02-issue-pool.png",
|
||||
"dispatcher-03-department-queue.png",
|
||||
"dispatcher-04-escalation-center.png",
|
||||
"dispatcher-05-knowledge-review.png",
|
||||
"dispatcher-06-rule-config.png",
|
||||
"dispatcher-07-analytics-board.png",
|
||||
"client-01-workbench.png",
|
||||
"client-02-submit-issue.png",
|
||||
"client-03-issue-detail-handler.png",
|
||||
"client-04-submitter-tracking.png",
|
||||
"client-05-mobile-submit.png"
|
||||
];
|
||||
|
||||
function main() {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
const availableNames = pageNames.filter((fileName) => existsSync(path.join(designDir, fileName)) && existsSync(path.join(implementationDir, fileName)));
|
||||
const missingNames = pageNames.filter((fileName) => !availableNames.includes(fileName));
|
||||
|
||||
const results = availableNames.map((fileName) => comparePage(fileName));
|
||||
writeReport(results, missingNames);
|
||||
|
||||
const failed = results.filter((result) => result.status === "needs-polish").length;
|
||||
const review = results.filter((result) => result.status === "review").length;
|
||||
console.log(`Visual diff complete. Compared ${results.length} pages. ${failed} need polish, ${review} need review.`);
|
||||
console.log(`Report: ${path.relative(process.cwd(), path.join(outputDir, "report.md"))}`);
|
||||
}
|
||||
|
||||
function comparePage(fileName) {
|
||||
const designPath = path.join(designDir, fileName);
|
||||
const implementationPath = path.join(implementationDir, fileName);
|
||||
const expected = readPng(designPath);
|
||||
let actual = readPng(implementationPath);
|
||||
const resized = expected.width !== actual.width || expected.height !== actual.height;
|
||||
|
||||
if (resized) {
|
||||
actual = resizeImage(actual, expected.width, expected.height);
|
||||
}
|
||||
|
||||
const diff = Buffer.alloc(expected.width * expected.height * 4);
|
||||
const grid = createGrid();
|
||||
let changedPixels = 0;
|
||||
let deltaTotal = 0;
|
||||
let deltaSquaredTotal = 0;
|
||||
let maxDelta = 0;
|
||||
|
||||
for (let y = 0; y < expected.height; y += 1) {
|
||||
for (let x = 0; x < expected.width; x += 1) {
|
||||
const offset = (y * expected.width + x) * 4;
|
||||
const dr = expected.data[offset] - actual.data[offset];
|
||||
const dg = expected.data[offset + 1] - actual.data[offset + 1];
|
||||
const db = expected.data[offset + 2] - actual.data[offset + 2];
|
||||
const delta = Math.sqrt((dr * dr + dg * dg + db * db) / 3);
|
||||
const changed = delta > threshold;
|
||||
|
||||
if (changed) changedPixels += 1;
|
||||
deltaTotal += delta;
|
||||
deltaSquaredTotal += delta * delta;
|
||||
if (delta > maxDelta) maxDelta = delta;
|
||||
|
||||
const cell = gridCellFor(x, y, expected.width, expected.height);
|
||||
grid[cell].deltaTotal += delta;
|
||||
grid[cell].changedPixels += changed ? 1 : 0;
|
||||
grid[cell].pixels += 1;
|
||||
|
||||
writeDiffPixel(diff, offset, actual.data, delta, changed);
|
||||
}
|
||||
}
|
||||
|
||||
const pixelCount = expected.width * expected.height;
|
||||
const meanDelta = deltaTotal / pixelCount;
|
||||
const rmsDelta = Math.sqrt(deltaSquaredTotal / pixelCount);
|
||||
const changedRatio = changedPixels / pixelCount;
|
||||
const diffFileName = fileName.replace(/\.png$/, "-diff.png");
|
||||
writePng(path.join(outputDir, diffFileName), {
|
||||
width: expected.width,
|
||||
height: expected.height,
|
||||
data: diff
|
||||
});
|
||||
|
||||
return {
|
||||
fileName,
|
||||
width: expected.width,
|
||||
height: expected.height,
|
||||
resized,
|
||||
meanDelta,
|
||||
rmsDelta,
|
||||
changedRatio,
|
||||
maxDelta,
|
||||
status: classify(meanDelta, changedRatio),
|
||||
diffFileName,
|
||||
hotspots: topHotspots(grid)
|
||||
};
|
||||
}
|
||||
|
||||
function readPng(filePath) {
|
||||
const bytes = readFileSync(filePath);
|
||||
const signature = "89504e470d0a1a0a";
|
||||
if (bytes.subarray(0, 8).toString("hex") !== signature) {
|
||||
throw new Error(`Not a PNG file: ${filePath}`);
|
||||
}
|
||||
|
||||
let offset = 8;
|
||||
let header;
|
||||
const idatChunks = [];
|
||||
|
||||
while (offset < bytes.length) {
|
||||
const length = bytes.readUInt32BE(offset);
|
||||
const type = bytes.subarray(offset + 4, offset + 8).toString("ascii");
|
||||
const data = bytes.subarray(offset + 8, offset + 8 + length);
|
||||
offset += 12 + length;
|
||||
|
||||
if (type === "IHDR") {
|
||||
header = {
|
||||
width: data.readUInt32BE(0),
|
||||
height: data.readUInt32BE(4),
|
||||
bitDepth: data[8],
|
||||
colorType: data[9],
|
||||
compression: data[10],
|
||||
filter: data[11],
|
||||
interlace: data[12]
|
||||
};
|
||||
} else if (type === "IDAT") {
|
||||
idatChunks.push(data);
|
||||
} else if (type === "IEND") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!header) throw new Error(`PNG is missing IHDR: ${filePath}`);
|
||||
if (header.bitDepth !== 8 || (header.colorType !== 2 && header.colorType !== 6)) {
|
||||
throw new Error(`Unsupported PNG format in ${filePath}. Expected 8-bit RGB/RGBA, got bit=${header.bitDepth}, color=${header.colorType}.`);
|
||||
}
|
||||
if (header.compression !== 0 || header.filter !== 0 || header.interlace !== 0) {
|
||||
throw new Error(`Unsupported PNG encoding in ${filePath}. Interlaced images are not supported.`);
|
||||
}
|
||||
|
||||
const channels = header.colorType === 6 ? 4 : 3;
|
||||
const stride = header.width * channels;
|
||||
const raw = inflateSync(Buffer.concat(idatChunks));
|
||||
const pixels = Buffer.alloc(header.width * header.height * 4);
|
||||
let inputOffset = 0;
|
||||
let previous = Buffer.alloc(stride);
|
||||
|
||||
for (let y = 0; y < header.height; y += 1) {
|
||||
const filterType = raw[inputOffset];
|
||||
inputOffset += 1;
|
||||
const encoded = raw.subarray(inputOffset, inputOffset + stride);
|
||||
inputOffset += stride;
|
||||
const decoded = unfilterScanline(encoded, previous, filterType, channels);
|
||||
|
||||
for (let x = 0; x < header.width; x += 1) {
|
||||
const source = x * channels;
|
||||
const target = (y * header.width + x) * 4;
|
||||
pixels[target] = decoded[source];
|
||||
pixels[target + 1] = decoded[source + 1];
|
||||
pixels[target + 2] = decoded[source + 2];
|
||||
pixels[target + 3] = channels === 4 ? decoded[source + 3] : 255;
|
||||
}
|
||||
|
||||
previous = decoded;
|
||||
}
|
||||
|
||||
return {
|
||||
width: header.width,
|
||||
height: header.height,
|
||||
data: pixels
|
||||
};
|
||||
}
|
||||
|
||||
function unfilterScanline(encoded, previous, filterType, bytesPerPixel) {
|
||||
const decoded = Buffer.alloc(encoded.length);
|
||||
for (let index = 0; index < encoded.length; index += 1) {
|
||||
const left = index >= bytesPerPixel ? decoded[index - bytesPerPixel] : 0;
|
||||
const up = previous[index] || 0;
|
||||
const upLeft = index >= bytesPerPixel ? previous[index - bytesPerPixel] || 0 : 0;
|
||||
let value;
|
||||
|
||||
switch (filterType) {
|
||||
case 0:
|
||||
value = encoded[index];
|
||||
break;
|
||||
case 1:
|
||||
value = encoded[index] + left;
|
||||
break;
|
||||
case 2:
|
||||
value = encoded[index] + up;
|
||||
break;
|
||||
case 3:
|
||||
value = encoded[index] + Math.floor((left + up) / 2);
|
||||
break;
|
||||
case 4:
|
||||
value = encoded[index] + paethPredictor(left, up, upLeft);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported PNG filter type: ${filterType}`);
|
||||
}
|
||||
|
||||
decoded[index] = value & 255;
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function paethPredictor(left, up, upLeft) {
|
||||
const estimate = left + up - upLeft;
|
||||
const leftDistance = Math.abs(estimate - left);
|
||||
const upDistance = Math.abs(estimate - up);
|
||||
const upLeftDistance = Math.abs(estimate - upLeft);
|
||||
if (leftDistance <= upDistance && leftDistance <= upLeftDistance) return left;
|
||||
if (upDistance <= upLeftDistance) return up;
|
||||
return upLeft;
|
||||
}
|
||||
|
||||
function writePng(filePath, image) {
|
||||
const rowLength = image.width * 4;
|
||||
const raw = Buffer.alloc((rowLength + 1) * image.height);
|
||||
let rawOffset = 0;
|
||||
for (let y = 0; y < image.height; y += 1) {
|
||||
raw[rawOffset] = 0;
|
||||
rawOffset += 1;
|
||||
image.data.copy(raw, rawOffset, y * rowLength, y * rowLength + rowLength);
|
||||
rawOffset += rowLength;
|
||||
}
|
||||
|
||||
const chunks = [
|
||||
pngChunk("IHDR", createIhdr(image.width, image.height)),
|
||||
pngChunk("IDAT", deflateSync(raw)),
|
||||
pngChunk("IEND", Buffer.alloc(0))
|
||||
];
|
||||
writeFileSync(filePath, Buffer.concat([Buffer.from("89504e470d0a1a0a", "hex"), ...chunks]));
|
||||
}
|
||||
|
||||
function createIhdr(width, height) {
|
||||
const data = Buffer.alloc(13);
|
||||
data.writeUInt32BE(width, 0);
|
||||
data.writeUInt32BE(height, 4);
|
||||
data[8] = 8;
|
||||
data[9] = 6;
|
||||
data[10] = 0;
|
||||
data[11] = 0;
|
||||
data[12] = 0;
|
||||
return data;
|
||||
}
|
||||
|
||||
function pngChunk(type, data) {
|
||||
const typeBytes = Buffer.from(type, "ascii");
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(data.length, 0);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(Buffer.concat([typeBytes, data])), 0);
|
||||
return Buffer.concat([length, typeBytes, data, crc]);
|
||||
}
|
||||
|
||||
const crcTable = Array.from({ length: 256 }, (_, index) => {
|
||||
let value = index;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
||||
}
|
||||
return value >>> 0;
|
||||
});
|
||||
|
||||
function crc32(buffer) {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of buffer) {
|
||||
crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function resizeImage(image, targetWidth, targetHeight) {
|
||||
const output = Buffer.alloc(targetWidth * targetHeight * 4);
|
||||
const xScale = image.width / targetWidth;
|
||||
const yScale = image.height / targetHeight;
|
||||
|
||||
for (let y = 0; y < targetHeight; y += 1) {
|
||||
const sourceY = (y + 0.5) * yScale - 0.5;
|
||||
const y0 = clamp(Math.floor(sourceY), 0, image.height - 1);
|
||||
const y1 = clamp(y0 + 1, 0, image.height - 1);
|
||||
const yMix = sourceY - y0;
|
||||
|
||||
for (let x = 0; x < targetWidth; x += 1) {
|
||||
const sourceX = (x + 0.5) * xScale - 0.5;
|
||||
const x0 = clamp(Math.floor(sourceX), 0, image.width - 1);
|
||||
const x1 = clamp(x0 + 1, 0, image.width - 1);
|
||||
const xMix = sourceX - x0;
|
||||
const target = (y * targetWidth + x) * 4;
|
||||
|
||||
for (let channel = 0; channel < 4; channel += 1) {
|
||||
const a = image.data[(y0 * image.width + x0) * 4 + channel];
|
||||
const b = image.data[(y0 * image.width + x1) * 4 + channel];
|
||||
const c = image.data[(y1 * image.width + x0) * 4 + channel];
|
||||
const d = image.data[(y1 * image.width + x1) * 4 + channel];
|
||||
const top = a + (b - a) * xMix;
|
||||
const bottom = c + (d - c) * xMix;
|
||||
output[target + channel] = clamp(Math.round(top + (bottom - top) * yMix), 0, 255);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
data: output
|
||||
};
|
||||
}
|
||||
|
||||
function writeDiffPixel(diff, offset, actualData, delta, changed) {
|
||||
if (changed) {
|
||||
diff[offset] = 255;
|
||||
diff[offset + 1] = clamp(Math.round(220 - delta), 20, 190);
|
||||
diff[offset + 2] = clamp(Math.round(80 + delta), 120, 255);
|
||||
diff[offset + 3] = 255;
|
||||
return;
|
||||
}
|
||||
|
||||
const gray = Math.round(actualData[offset] * 0.299 + actualData[offset + 1] * 0.587 + actualData[offset + 2] * 0.114);
|
||||
diff[offset] = gray;
|
||||
diff[offset + 1] = gray;
|
||||
diff[offset + 2] = gray;
|
||||
diff[offset + 3] = 255;
|
||||
}
|
||||
|
||||
function createGrid() {
|
||||
return Array.from({ length: gridColumns * gridRows }, () => ({
|
||||
deltaTotal: 0,
|
||||
changedPixels: 0,
|
||||
pixels: 0
|
||||
}));
|
||||
}
|
||||
|
||||
function gridCellFor(x, y, width, height) {
|
||||
const column = Math.min(gridColumns - 1, Math.floor((x / width) * gridColumns));
|
||||
const row = Math.min(gridRows - 1, Math.floor((y / height) * gridRows));
|
||||
return row * gridColumns + column;
|
||||
}
|
||||
|
||||
function topHotspots(grid) {
|
||||
return grid
|
||||
.map((cell, index) => ({
|
||||
index,
|
||||
row: Math.floor(index / gridColumns) + 1,
|
||||
column: (index % gridColumns) + 1,
|
||||
meanDelta: cell.deltaTotal / Math.max(1, cell.pixels),
|
||||
changedRatio: cell.changedPixels / Math.max(1, cell.pixels)
|
||||
}))
|
||||
.sort((a, b) => b.meanDelta - a.meanDelta)
|
||||
.slice(0, 3)
|
||||
.map((cell) => `${zoneLabel(cell.column, cell.row)} ${formatNumber(cell.meanDelta)} / ${formatPercent(cell.changedRatio)}`);
|
||||
}
|
||||
|
||||
function zoneLabel(column, row) {
|
||||
const horizontal = column <= 4 ? "left" : column <= 8 ? "center" : "right";
|
||||
const vertical = row <= 3 ? "top" : row <= 5 ? "middle" : "bottom";
|
||||
return `${vertical}-${horizontal}`;
|
||||
}
|
||||
|
||||
function classify(meanDelta, changedRatio) {
|
||||
if (meanDelta <= 8 && changedRatio <= 0.1) return "close";
|
||||
if (meanDelta <= 18 && changedRatio <= 0.25) return "review";
|
||||
return "needs-polish";
|
||||
}
|
||||
|
||||
function writeReport(results, missingNames) {
|
||||
const lines = [
|
||||
"# Issue Hub AI 视觉差异报告",
|
||||
"",
|
||||
`生成时间:${new Date().toISOString()}`,
|
||||
"",
|
||||
"## 方法",
|
||||
"",
|
||||
`- 对比对象:\`docs/design/images2/\` 高保真设计图 vs \`docs/design/implementation-screenshots/\` 当前实现截图。`,
|
||||
`- 差异阈值:单像素 RGB 均方差大于 ${threshold} 记为变更像素。`,
|
||||
"- 输出:每页一张高亮差异图,灰色代表低差异区域,粉色代表明显差异区域。",
|
||||
"- 用途:这是视觉打磨诊断工具,不替代人工设计评审;AI 生成稿中的文案、图标细节和截图动态数据会天然造成差异。",
|
||||
"",
|
||||
"## 汇总",
|
||||
"",
|
||||
`- 对比页面:${results.length} 张。`,
|
||||
`- 接近设计稿:${results.filter((item) => item.status === "close").length} 张。`,
|
||||
`- 需要人工复核:${results.filter((item) => item.status === "review").length} 张。`,
|
||||
`- 需要继续视觉打磨:${results.filter((item) => item.status === "needs-polish").length} 张。`,
|
||||
"",
|
||||
"| 页面 | 尺寸 | 状态 | 平均差异 | RMS | 变更像素 | 最大差异 | 差异图 | 热点区域 |",
|
||||
"| --- | --- | --- | ---: | ---: | ---: | ---: | --- | --- |"
|
||||
];
|
||||
|
||||
for (const result of results) {
|
||||
lines.push(
|
||||
`| ${result.fileName} | ${result.width}x${result.height}${result.resized ? " resized" : ""} | ${statusLabel(result.status)} | ${formatNumber(result.meanDelta)} | ${formatNumber(result.rmsDelta)} | ${formatPercent(result.changedRatio)} | ${formatNumber(result.maxDelta)} | [diff](${result.diffFileName}) | ${result.hotspots.join("<br>")} |`
|
||||
);
|
||||
}
|
||||
|
||||
if (missingNames.length > 0) {
|
||||
lines.push("", "## 缺失文件", "");
|
||||
for (const fileName of missingNames) {
|
||||
lines.push(`- ${fileName}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"",
|
||||
"## 判读建议",
|
||||
"",
|
||||
"- 优先处理 `needs-polish` 页面中热点集中在 top-left/top-center 的差异,这通常对应侧栏、顶部工具区和首屏结构。",
|
||||
"- 如果热点集中在 right-middle/right-bottom,优先核对右侧检查器、AI 建议面板、操作区密度和滚动状态。",
|
||||
"- 如果热点集中在 center-middle,优先核对表格密度、卡片边界、图表形态和主要工作区信息层级。",
|
||||
"- 当平均差异下降但变更像素仍高,通常说明整体布局接近但文字、图标、间距和局部状态还需要人工修正。"
|
||||
);
|
||||
|
||||
writeFileSync(path.join(outputDir, "report.md"), `${lines.join("\n")}\n`);
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
if (status === "close") return "接近";
|
||||
if (status === "review") return "复核";
|
||||
return "需打磨";
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
return `${(value * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user