- extract-screenshots.mjs: Playwright 截取 IosFrame 内容区域,裁掉设备框 - infer-interactions.mjs: 8 条规则正则匹配源码推断交互行为 - 重写 interaction-rules.yml: patterns 改为代码级正则(非自然语言描述) - templates/spec-template.md: SPEC.md 六章节模板
195 lines
6.7 KiB
JavaScript
195 lines
6.7 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* extract-screenshots.mjs — 从 huashu-design HTML 原型截取 IosFrame 屏幕内容
|
||
*
|
||
* 用法: node extract-screenshots.mjs <html-file> <output-dir>
|
||
*/
|
||
|
||
import { chromium } from 'playwright';
|
||
import { resolve, basename } from 'node:path';
|
||
import { mkdirSync, existsSync } from 'node:fs';
|
||
|
||
const IOS_FRAME_TRIM = { statusBar: 54, homeIndicator: 34, padding: 12 };
|
||
const RENDER_WAIT_TIMEOUT = 10_000;
|
||
const RENDER_POLL_INTERVAL = 200;
|
||
|
||
const ZH_EN_MAP = {
|
||
'完整页': '', '—': '-', ' ': '-',
|
||
'首页': 'home', '我的': 'profile', '登录': 'login', '健康': 'health',
|
||
'消息': 'messages', '咨询': 'consultation', '预约': 'appointment',
|
||
'商城': 'mall', '设置': 'settings', '访客': 'guest', '轮播': 'slide',
|
||
'体检': 'checkup', '报告': 'report', '医生': 'doctor', '患者': 'patient',
|
||
'记录': 'record', '详情': 'detail', '列表': 'list', '管理': 'manage',
|
||
'编辑': 'edit', '新增': 'add', '搜索': 'search', '筛选': 'filter',
|
||
'结果': 'result', '历史': 'history', '数据': 'data', '体征': 'vitals',
|
||
'用药': 'medication', '随访': 'followup', '透析': 'dialysis',
|
||
'日常': 'daily', '监测': 'monitor', '告警': 'alert', '家庭': 'family',
|
||
'成员': 'member', '档案': 'profile', '商品': 'product', '兑换': 'exchange',
|
||
'订单': 'order', '积分': 'points', '活动': 'activity',
|
||
};
|
||
|
||
function translateLabel(label, fallbackIndex) {
|
||
let result = label;
|
||
const sortedKeys = Object.keys(ZH_EN_MAP).sort((a, b) => b.length - a.length);
|
||
for (const zh of sortedKeys) {
|
||
result = result.split(zh).join(ZH_EN_MAP[zh]);
|
||
}
|
||
result = result.replace(/[^\w\-.]/g, '-');
|
||
result = result.replace(/^-+|-+$/g, '').replace(/-+/g, '-');
|
||
return result || `screen-${fallbackIndex}`;
|
||
}
|
||
|
||
function fail(msg) {
|
||
process.stderr.write(`ERROR: ${msg}\n`);
|
||
process.exit(1);
|
||
}
|
||
|
||
async function main() {
|
||
const args = process.argv.slice(2);
|
||
if (args.length < 2) fail('用法: node extract-screenshots.mjs <html-file> <output-dir>');
|
||
|
||
const htmlFile = resolve(args[0]);
|
||
const outputDir = resolve(args[1]);
|
||
if (!existsSync(htmlFile)) fail(`HTML 文件不存在: ${htmlFile}`);
|
||
mkdirSync(outputDir, { recursive: true });
|
||
|
||
let browser;
|
||
try {
|
||
browser = await chromium.launch({ headless: true });
|
||
const context = await browser.newContext({
|
||
viewport: { width: 2400, height: 1400 },
|
||
deviceScaleFactor: 2,
|
||
});
|
||
const page = await context.newPage();
|
||
|
||
const fileUrl = `file:///${htmlFile.replace(/\\/g, '/')}`;
|
||
await page.goto(fileUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
||
|
||
// 等待 React 渲染
|
||
const renderStart = Date.now();
|
||
let rendered = false;
|
||
while (Date.now() - renderStart < RENDER_WAIT_TIMEOUT) {
|
||
rendered = await page.evaluate(() => {
|
||
const root = document.getElementById('root');
|
||
return root ? root.children.length > 0 : false;
|
||
});
|
||
if (rendered) break;
|
||
await page.waitForTimeout(RENDER_POLL_INTERVAL);
|
||
}
|
||
if (!rendered) fail(`React 渲染超时: ${basename(htmlFile)}`);
|
||
await page.waitForTimeout(500);
|
||
|
||
// 查找 .screen-wrap 容器
|
||
const screenCount = await page.locator('.screen-wrap').count();
|
||
|
||
if (screenCount === 0) {
|
||
// 降级:整页截图
|
||
await page.screenshot({ path: resolve(outputDir, 'full-page.png'), fullPage: true });
|
||
process.stdout.write(JSON.stringify({
|
||
source: basename(htmlFile), totalScreens: 0, fallback: true,
|
||
files: [{ label: 'full-page', file: 'full-page.png' }],
|
||
}, null, 2) + '\n');
|
||
await browser.close();
|
||
return;
|
||
}
|
||
|
||
// 使用 page.evaluate 定位并截取每个 screen
|
||
const screenData = await page.evaluate(() => {
|
||
const wraps = document.querySelectorAll('.screen-wrap');
|
||
return Array.from(wraps).map((wrap, i) => {
|
||
// 获取标签
|
||
const labelEl = wrap.querySelector('.screen-label');
|
||
const label = labelEl ? labelEl.textContent.trim() : '';
|
||
|
||
// 找 IosFrame wrapper: screen-wrap 下有 borderRadius:60 + background:#000 的 div
|
||
// 或者用策略:找第一个有 style 含 borderRadius 的 div
|
||
const allDivs = wrap.querySelectorAll(':scope > div');
|
||
let wrapperDiv = null;
|
||
for (const div of allDivs) {
|
||
const style = div.getAttribute('style') || '';
|
||
// IosFrame wrapper 特征: background:#000 或 background: black
|
||
if (style.includes('background:')) {
|
||
wrapperDiv = div;
|
||
break;
|
||
}
|
||
}
|
||
// 如果没找到 background 的,取第一个 div
|
||
if (!wrapperDiv && allDivs.length > 0) wrapperDiv = allDivs[0];
|
||
|
||
if (!wrapperDiv) return { label, index: i, found: false };
|
||
|
||
const wrapperBox = wrapperDiv.getBoundingClientRect();
|
||
return {
|
||
label,
|
||
index: i,
|
||
found: true,
|
||
wrapperBox: {
|
||
x: wrapperBox.x,
|
||
y: wrapperBox.y,
|
||
width: wrapperBox.width,
|
||
height: wrapperBox.height,
|
||
},
|
||
};
|
||
});
|
||
});
|
||
|
||
const { statusBar, homeIndicator, padding } = IOS_FRAME_TRIM;
|
||
const files = [];
|
||
const usedNames = new Set();
|
||
|
||
for (const screen of screenData) {
|
||
if (!screen.found || !screen.wrapperBox) {
|
||
process.stderr.write(`WARNING: screen ${screen.index + 1} 未找到 IosFrame,跳过\n`);
|
||
continue;
|
||
}
|
||
|
||
const box = screen.wrapperBox;
|
||
const clip = {
|
||
x: box.x + padding,
|
||
y: box.y + padding + statusBar,
|
||
width: box.width - padding * 2,
|
||
height: box.height - padding * 2 - statusBar - homeIndicator,
|
||
};
|
||
|
||
if (clip.width <= 0 || clip.height <= 0) {
|
||
process.stderr.write(`WARNING: screen ${screen.index + 1} clip 无效 (${clip.width}x${clip.height})\n`);
|
||
continue;
|
||
}
|
||
|
||
let safeName = translateLabel(screen.label || '', screen.index + 1);
|
||
if (usedNames.has(safeName)) {
|
||
let s = 2;
|
||
while (usedNames.has(`${safeName}-${s}`)) s++;
|
||
safeName = `${safeName}-${s}`;
|
||
}
|
||
usedNames.add(safeName);
|
||
|
||
const filename = `${safeName}.png`;
|
||
await page.screenshot({ path: resolve(outputDir, filename), clip });
|
||
|
||
files.push({
|
||
label: screen.label || `screen-${screen.index + 1}`,
|
||
file: filename,
|
||
clip: {
|
||
x: Math.round(clip.x), y: Math.round(clip.y),
|
||
width: Math.round(clip.width), height: Math.round(clip.height),
|
||
},
|
||
});
|
||
}
|
||
|
||
process.stdout.write(JSON.stringify({
|
||
source: basename(htmlFile),
|
||
totalScreens: screenCount,
|
||
extracted: files.length,
|
||
files,
|
||
}, null, 2) + '\n');
|
||
|
||
} catch (err) {
|
||
fail(`Playwright 执行失败: ${err.message}`);
|
||
} finally {
|
||
if (browser) await browser.close();
|
||
}
|
||
}
|
||
|
||
main();
|