diff --git a/.claude/skills/design-handoff/rules/interaction-rules.yml b/.claude/skills/design-handoff/rules/interaction-rules.yml index 2fb6018..1960a56 100644 --- a/.claude/skills/design-handoff/rules/interaction-rules.yml +++ b/.claude/skills/design-handoff/rules/interaction-rules.yml @@ -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 等语义结构" + - " 0" + - "\\.length === 0" + - "暂无" + - "没有" + require_all: false infer: - interaction: empty_state - trigger: - - "数据加载完成且列表为空时自动展示" - feedback: - - "显示空状态插图 + 引导文案" - - "可选的引导按钮(点击跳转创建页)" + component: null + props: "" + behavior: "条件渲染:有数据显示列表,无数据显示空状态" confidence: medium diff --git a/.claude/skills/design-handoff/scripts/extract-screenshots.mjs b/.claude/skills/design-handoff/scripts/extract-screenshots.mjs new file mode 100644 index 0000000..f081a06 --- /dev/null +++ b/.claude/skills/design-handoff/scripts/extract-screenshots.mjs @@ -0,0 +1,194 @@ +#!/usr/bin/env node +/** + * extract-screenshots.mjs — 从 huashu-design HTML 原型截取 IosFrame 屏幕内容 + * + * 用法: node extract-screenshots.mjs + */ + +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 '); + + 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(); diff --git a/.claude/skills/design-handoff/scripts/infer-interactions.mjs b/.claude/skills/design-handoff/scripts/infer-interactions.mjs new file mode 100644 index 0000000..7c70d03 --- /dev/null +++ b/.claude/skills/design-handoff/scripts/infer-interactions.mjs @@ -0,0 +1,205 @@ +#!/usr/bin/env node +/** + * infer-interactions.mjs -- 对 HTML 原型源码进行静态模式匹配,推断页面交互行为 + * + * 读取 HTML 文件和 interaction-rules.yml,用正则匹配源码中的模式, + * 输出推断的交互组件、props 和行为描述。 + * + * 用法: node infer-interactions.mjs + */ + +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 '); + } + + 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(); diff --git a/.claude/skills/design-handoff/templates/spec-template.md b/.claude/skills/design-handoff/templates/spec-template.md new file mode 100644 index 0000000..ae2fac4 --- /dev/null +++ b/.claude/skills/design-handoff/templates/spec-template.md @@ -0,0 +1,73 @@ +# {{pageTitle}} 设计规格 + +> 来源: {{sourceFile}} | 平台: {{platform}} | 页面数: {{screenCount}} | 生成: {{date}} + +## 页面索引 + +| 页面 | 截图 | 路由 | +|------|------|------| +{{#each screens}} +| {{label}} | ![{{file}}](./screenshots/{{file}}) | {{route}} | +{{/each}} + +## 一、Token 映射 + +| 原型值 | 项目 Token | 状态 | +|--------|-----------|------| +{{#each tokenMap}} +| {{prototypeValue}} ({{key}}) | {{tokenRef}} | {{statusIcon}} | +{{/each}} + +> 状态标记: ✅ confirmed 直接使用 | ⚠️ pending 需人工复核 | ❌ unmatched 需硬编码或新建 Token + +## 二、页面结构 + +{{#each screens}} +### {{ordinal}}. {{label}} + +![{{label}}](./screenshots/{{file}}) + +布局层级(从上到下): + +{{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. "需新建"的组件参考截图和布局描述从头实现