Files
hms/.claude/skills/design-handoff/scripts/extract-screenshots.mjs
iven a4732cd2d4 feat(skills): 添加截图提取 + 交互推断脚本 + SPEC 模板
- extract-screenshots.mjs: Playwright 截取 IosFrame 内容区域,裁掉设备框
- infer-interactions.mjs: 8 条规则正则匹配源码推断交互行为
- 重写 interaction-rules.yml: patterns 改为代码级正则(非自然语言描述)
- templates/spec-template.md: SPEC.md 六章节模板
2026-05-18 00:04:31 +08:00

195 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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();