feat(skills): 添加截图提取 + 交互推断脚本 + SPEC 模板

- extract-screenshots.mjs: Playwright 截取 IosFrame 内容区域,裁掉设备框
- infer-interactions.mjs: 8 条规则正则匹配源码推断交互行为
- 重写 interaction-rules.yml: patterns 改为代码级正则(非自然语言描述)
- templates/spec-template.md: SPEC.md 六章节模板
This commit is contained in:
iven
2026-05-18 00:04:31 +08:00
parent 35bd60af5b
commit a4732cd2d4
4 changed files with 528 additions and 92 deletions

View File

@@ -0,0 +1,194 @@
#!/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();

View File

@@ -0,0 +1,205 @@
#!/usr/bin/env node
/**
* infer-interactions.mjs -- 对 HTML 原型源码进行静态模式匹配,推断页面交互行为
*
* 读取 HTML 文件和 interaction-rules.yml用正则匹配源码中的模式
* 输出推断的交互组件、props 和行为描述。
*
* 用法: node infer-interactions.mjs <html-file> <interaction-rules.yml>
*/
import { readFileSync } from 'node:fs';
import { resolve, basename } from 'node:path';
import { createRequire } from 'node:module';
// js-yaml 是 CJS 包,需要用 createRequire 加载
const require = createRequire(import.meta.url);
const yaml = require('js-yaml');
// -- 工具函数 ----------------------------------------------------------------
/**
* 输出错误到 stderr 并以 code 1 退出
*/
function fail(message) {
process.stderr.write(JSON.stringify({ error: message }, null, 2) + '\n');
process.exit(1);
}
/**
* 读取文件内容,失败时调用 fail
*/
function readFileOrFail(filePath, description) {
try {
return readFileSync(filePath, 'utf-8');
} catch (err) {
fail(`无法读取${description}: ${filePath} -- ${err.message}`);
}
}
// -- 组件函数位置追踪 ---------------------------------------------------------
/**
* 提取源码中所有 function/const 组件声明的位置和名称。
* 返回 [{ name, start, end }],按 start 排序。
*
* end 的计算采用简化策略:找到下一个同级 function/const 声明或源码末尾。
* 对于 locations 追踪来说已经足够精确——只要知道 pattern 落在哪个函数内即可。
*/
function extractFunctionRanges(source) {
const ranges = [];
// 匹配 function 声明: function XxxName(
const funcPattern = /function\s+([A-Z]\w*)\s*\(/g;
let match;
while ((match = funcPattern.exec(source)) !== null) {
ranges.push({ name: match[1], start: match.index });
}
// 匹配 const 箭头函数: const XxxName = (...
const arrowPattern = /const\s+([A-Z]\w*)\s*=\s*(?:\([^)]*\)|\w+)\s*=>/g;
while ((match = arrowPattern.exec(source)) !== null) {
ranges.push({ name: match[1], start: match.index });
}
// 按 start 排序,计算 end下一个函数的 start 或源码末尾)
ranges.sort((a, b) => a.start - b.start);
for (let i = 0; i < ranges.length; i++) {
const nextStart = i + 1 < ranges.length ? ranges[i + 1].start : source.length;
ranges[i].end = nextStart;
}
return ranges;
}
/**
* 给定 pattern 在 source 中的匹配位置,找到它所属的函数名。
* 如果不在任何函数内,返回 '全局'。
*/
function findEnclosingFunction(ranges, matchIndex) {
for (const range of ranges) {
if (matchIndex >= range.start && matchIndex < range.end) {
return range.name;
}
}
return '全局';
}
// -- 核心匹配逻辑 ------------------------------------------------------------
/**
* 对单条 rule 执行模式匹配。
* 返回 { matched, matchedPatterns, locations }。
*/
function matchRule(rule, source, funcRanges) {
const requireAll = rule.require_all === true;
const matchedPatterns = [];
const locationSet = new Set();
for (const patternStr of rule.patterns) {
try {
const regex = new RegExp(patternStr, 'g');
let match;
let patternMatched = false;
while ((match = regex.exec(source)) !== null) {
patternMatched = true;
const enclosing = findEnclosingFunction(funcRanges, match.index);
locationSet.add(enclosing);
}
if (patternMatched) {
matchedPatterns.push(patternStr);
}
} catch (err) {
// 正则语法错误,跳过此 pattern 并报告到 stderr
process.stderr.write(`警告: rule "${rule.id}" 的 pattern "${patternStr}" 正则语法错误: ${err.message}\n`);
}
}
const allMatched = requireAll
? matchedPatterns.length === rule.patterns.length
: matchedPatterns.length > 0;
return {
matched: allMatched,
matchedPatterns: allMatched ? matchedPatterns : [],
locations: allMatched ? [...locationSet] : [],
};
}
// -- 主流程 ------------------------------------------------------------------
function main() {
const args = process.argv.slice(2);
if (args.length < 2) {
fail('用法: node infer-interactions.mjs <html-file> <interaction-rules.yml>');
}
const htmlPath = resolve(args[0]);
const rulesPath = resolve(args[1]);
// 读取源文件
const source = readFileOrFail(htmlPath, 'HTML 文件');
// 读取并解析 rules YAML
const rulesContent = readFileOrFail(rulesPath, 'interaction-rules.yml');
let rulesDoc;
try {
rulesDoc = yaml.load(rulesContent);
} catch (err) {
fail(`YAML 解析失败: ${err.message}`);
}
if (!rulesDoc || !Array.isArray(rulesDoc.rules)) {
fail('interaction-rules.yml 格式错误: 缺少顶层的 rules 数组');
}
const rules = rulesDoc.rules;
// 提取函数位置范围表(用于 locations 追踪)
const funcRanges = extractFunctionRanges(source);
// 逐条匹配
const interactions = [];
let matchedCount = 0;
for (const rule of rules) {
const { matched, matchedPatterns, locations } = matchRule(rule, source, funcRanges);
const entry = {
id: rule.id,
name: rule.name,
matched,
};
if (matched) {
matchedCount++;
entry.matchedPatterns = matchedPatterns;
entry.component = rule.infer?.component ?? null;
entry.props = rule.infer?.props ?? '';
entry.behavior = rule.infer?.behavior ?? '';
entry.confidence = rule.confidence ?? 'medium';
if (locations.length > 0) {
entry.locations = locations;
}
}
interactions.push(entry);
}
// 组装输出
const result = {
source: basename(htmlPath),
interactions,
summary: {
total: rules.length,
matched: matchedCount,
unmatched: rules.length - matchedCount,
},
};
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
main();