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:
@@ -3,142 +3,106 @@ updated: "2026-05-17"
|
||||
|
||||
# ============================================================================
|
||||
# 交互推断规则
|
||||
# 用于从静态原型 HTML 自动推断交互行为
|
||||
# 每条规则: id / name / patterns(匹配模式) / infer(推断结果) / confidence
|
||||
# patterns 使用正则表达式,匹配 HTML/JS 源码中的实际代码模式
|
||||
# require_all: true 表示所有 pattern 必须同时匹配(默认 false,任一匹配即可)
|
||||
# ============================================================================
|
||||
|
||||
rules:
|
||||
- id: swiper-autoplay
|
||||
name: "自动轮播 + 手动滑动"
|
||||
patterns:
|
||||
- "渐变背景容器内含多张图片/卡片"
|
||||
- "底部指示点宽窄交替(当前页宽、其他窄)"
|
||||
- "容器设置了 overflow:hidden 且子元素横向排列"
|
||||
- "存在 swiper / carousel / banner 类名"
|
||||
- "linear-gradient"
|
||||
- "width.*24.*width.*8"
|
||||
require_all: false
|
||||
infer:
|
||||
interaction: swiper
|
||||
trigger:
|
||||
- "自动播放(interval 3000-5000ms)"
|
||||
- "手势滑动切换"
|
||||
feedback:
|
||||
- "指示点跟随切换高亮"
|
||||
- "平滑过渡动画(cubic-bezier)"
|
||||
component: "Swiper"
|
||||
props: "autoplay circular indicatorDots"
|
||||
behavior: "自动轮播,3-5秒切换"
|
||||
confidence: high
|
||||
|
||||
- id: card-tap
|
||||
name: "卡片点击跳转"
|
||||
patterns:
|
||||
- "卡片容器(ContentCard/圆角矩形)内含标题 + 摘要 + 配图"
|
||||
- "卡片有 hover/active 视觉反馈样式"
|
||||
- "卡片内无独立按钮,整体可点击"
|
||||
- "存在 card / item 等语义类名"
|
||||
- "\\.map\\("
|
||||
require_all: false
|
||||
infer:
|
||||
interaction: navigate
|
||||
trigger:
|
||||
- "点击整个卡片区域"
|
||||
feedback:
|
||||
- "按下时背景色变化或缩放反馈(--tk-touch-feedback-opacity)"
|
||||
- "跳转到详情页"
|
||||
confidence: high
|
||||
component: "ContentCard"
|
||||
props: "activeFeedback onPress"
|
||||
behavior: "卡片可点击,带触控反馈"
|
||||
confidence: medium
|
||||
|
||||
- id: form-submit
|
||||
name: "表单提交"
|
||||
patterns:
|
||||
- "输入框(input/textarea)与按钮在同一个 form 容器内"
|
||||
- "按钮文案为"提交"/"确认"/"保存"等动作词"
|
||||
- "输入框有 label + placeholder 配对"
|
||||
- "存在 form / input-group 等语义结构"
|
||||
- "<input"
|
||||
- "<button"
|
||||
require_all: false
|
||||
infer:
|
||||
interaction: form_submit
|
||||
trigger:
|
||||
- "点击提交按钮"
|
||||
- "输入框回车(单输入场景)"
|
||||
feedback:
|
||||
- "按钮 loading 状态"
|
||||
- "成功后 toast 提示或页面跳转"
|
||||
- "失败时输入框下方显示 error 信息"
|
||||
component: "FormInput + PrimaryButton"
|
||||
props: ""
|
||||
behavior: "表单输入+提交按钮"
|
||||
confidence: high
|
||||
|
||||
- id: list-scroll
|
||||
name: "下拉刷新 + 上拉加载"
|
||||
name: "列表滚动"
|
||||
patterns:
|
||||
- "3+ 同类卡片连续纵向排列"
|
||||
- "容器设置了 overflow-y:scroll 或使用 ScrollView"
|
||||
- "列表底部有 loading 骨架屏占位"
|
||||
- "列表顶部有刷新指示区域"
|
||||
- "存在 list / scroll-view 等语义结构"
|
||||
- "overflow.*auto|scroll"
|
||||
- "\\.map\\(.*\\.map\\(.*\\.map\\("
|
||||
require_all: false
|
||||
infer:
|
||||
interaction: infinite_scroll
|
||||
trigger:
|
||||
- "下拉刷新(距顶部 < threshold)"
|
||||
- "上拉加载(距底部 < threshold)"
|
||||
feedback:
|
||||
- "下拉时显示刷新指示器"
|
||||
- "加载时底部显示 LoadingCard"
|
||||
- "无更多数据时显示"没有更多了"提示"
|
||||
component: "ScrollView"
|
||||
props: "scrollY onScrollToLower"
|
||||
behavior: "可滚动列表,支持上拉加载"
|
||||
confidence: medium
|
||||
|
||||
- id: tab-switch
|
||||
name: "标签页切换"
|
||||
patterns:
|
||||
- "平行区域以 tab 形式排列(2-5个选项)"
|
||||
- "选中态与未选中态有明显视觉差异(颜色/粗细/底色)"
|
||||
- "关键词包含 tab / filter / segment"
|
||||
- "切换后下方内容区域整体替换"
|
||||
- "tab"
|
||||
- "segment"
|
||||
- "filter"
|
||||
require_all: false
|
||||
infer:
|
||||
interaction: tab_switch
|
||||
trigger:
|
||||
- "点击 tab 项"
|
||||
- "滑动切换(segment 模式)"
|
||||
feedback:
|
||||
- "选中项高亮(主色底/加粗/底线)"
|
||||
- "内容区平滑切换或淡入淡出"
|
||||
- "选中 tab 阴影变化(--tk-shadow-tab)"
|
||||
confidence: high
|
||||
component: "TabFilter"
|
||||
props: "tabs onChange"
|
||||
behavior: "标签页切换筛选"
|
||||
confidence: medium
|
||||
|
||||
- id: static-decoration
|
||||
name: "纯装饰无交互"
|
||||
patterns:
|
||||
- "元素仅有渐变背景 + 装饰性圆形/波浪"
|
||||
- "无 hover/active/click 事件绑定"
|
||||
- "opacity < 1 的背景装饰层"
|
||||
- "pointer-events: none"
|
||||
- "z-index 为负值或明确低于内容层"
|
||||
- "position.*absolute"
|
||||
- "opacity.*0\\."
|
||||
require_all: true
|
||||
infer:
|
||||
interaction: none
|
||||
trigger: []
|
||||
feedback: []
|
||||
component: null
|
||||
props: ""
|
||||
behavior: "纯装饰性元素,无交互"
|
||||
confidence: high
|
||||
|
||||
- id: login-cta
|
||||
name: "登录/注册触发"
|
||||
patterns:
|
||||
- "按钮文案包含"登录"/"注册"/"立即登录"/"微信登录""
|
||||
- "按钮使用主色(--tk-pri)背景"
|
||||
- "位于页面底部或居中位置"
|
||||
- "周围有辅助文案如"登录后查看更多""
|
||||
- "登录"
|
||||
- "立即"
|
||||
- "注册"
|
||||
require_all: false
|
||||
infer:
|
||||
interaction: auth_action
|
||||
trigger:
|
||||
- "点击按钮"
|
||||
feedback:
|
||||
- "微信登录:调起微信授权"
|
||||
- "账号密码登录:展开登录表单或跳转登录页"
|
||||
- "按钮 loading 状态防止重复点击"
|
||||
component: "PrimaryButton"
|
||||
props: "onClick"
|
||||
behavior: "登录/注册引导按钮"
|
||||
confidence: high
|
||||
|
||||
- id: empty-fallback
|
||||
name: "空数据降级"
|
||||
patterns:
|
||||
- "条件渲染(v-if/ng-if/三元表达式)控制内容区"
|
||||
- "占位文字如"暂无数据"/"还没有记录""
|
||||
- "空状态插图或图标(空盒子/空列表图标)"
|
||||
- "引导文案 + 操作按钮(如"去创建""去预约")"
|
||||
- "\\.length > 0"
|
||||
- "\\.length === 0"
|
||||
- "暂无"
|
||||
- "没有"
|
||||
require_all: false
|
||||
infer:
|
||||
interaction: empty_state
|
||||
trigger:
|
||||
- "数据加载完成且列表为空时自动展示"
|
||||
feedback:
|
||||
- "显示空状态插图 + 引导文案"
|
||||
- "可选的引导按钮(点击跳转创建页)"
|
||||
component: null
|
||||
props: ""
|
||||
behavior: "条件渲染:有数据显示列表,无数据显示空状态"
|
||||
confidence: medium
|
||||
|
||||
194
.claude/skills/design-handoff/scripts/extract-screenshots.mjs
Normal file
194
.claude/skills/design-handoff/scripts/extract-screenshots.mjs
Normal 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();
|
||||
205
.claude/skills/design-handoff/scripts/infer-interactions.mjs
Normal file
205
.claude/skills/design-handoff/scripts/infer-interactions.mjs
Normal 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();
|
||||
73
.claude/skills/design-handoff/templates/spec-template.md
Normal file
73
.claude/skills/design-handoff/templates/spec-template.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# {{pageTitle}} 设计规格
|
||||
|
||||
> 来源: {{sourceFile}} | 平台: {{platform}} | 页面数: {{screenCount}} | 生成: {{date}}
|
||||
|
||||
## 页面索引
|
||||
|
||||
| 页面 | 截图 | 路由 |
|
||||
|------|------|------|
|
||||
{{#each screens}}
|
||||
| {{label}} |  | {{route}} |
|
||||
{{/each}}
|
||||
|
||||
## 一、Token 映射
|
||||
|
||||
| 原型值 | 项目 Token | 状态 |
|
||||
|--------|-----------|------|
|
||||
{{#each tokenMap}}
|
||||
| {{prototypeValue}} ({{key}}) | {{tokenRef}} | {{statusIcon}} |
|
||||
{{/each}}
|
||||
|
||||
> 状态标记: ✅ confirmed 直接使用 | ⚠️ pending 需人工复核 | ❌ unmatched 需硬编码或新建 Token
|
||||
|
||||
## 二、页面结构
|
||||
|
||||
{{#each screens}}
|
||||
### {{ordinal}}. {{label}}
|
||||
|
||||

|
||||
|
||||
布局层级(从上到下):
|
||||
|
||||
{{layoutDescription}}
|
||||
|
||||
{{/each}}
|
||||
|
||||
## 三、组件映射
|
||||
|
||||
| 原型元素 | 推荐组件 | 来源 | 备注 |
|
||||
|----------|---------|------|------|
|
||||
{{#each componentMap}}
|
||||
| {{prototypeElement}} | {{component}} | {{source}} | {{notes}} |
|
||||
{{/each}}
|
||||
|
||||
{{#each newComponents}}
|
||||
> ⚠️ **需新建**: {{name}} — {{reason}}
|
||||
{{/each}}
|
||||
|
||||
## 四、交互规格
|
||||
|
||||
| 元素 | 交互 | 触发 | 反馈 | 备注 |
|
||||
|------|------|------|------|------|
|
||||
{{#each interactions}}
|
||||
| {{element}} | {{type}} | {{trigger}} | {{feedback}} | {{notes}} |
|
||||
{{/each}}
|
||||
|
||||
## 五、状态变体
|
||||
|
||||
{{#each stateVariants}}
|
||||
- **{{name}}**: {{description}}
|
||||
{{/each}}
|
||||
|
||||
## 六、样式清单
|
||||
|
||||
{{styleSummary}}
|
||||
|
||||
---
|
||||
|
||||
> 此规格由 design-handoff skill 自动生成。LLM 实施时请:
|
||||
> 1. 先阅读截图建立视觉印象
|
||||
> 2. 按 Token 映射表使用项目 Token(✅ 标记的直接用)
|
||||
> 3. 优先使用"组件映射"中列出的已有组件
|
||||
> 4. 参考"交互规格"实现对应的交互逻辑
|
||||
> 5. "需新建"的组件参考截图和布局描述从头实现
|
||||
Reference in New Issue
Block a user