Compare commits
5 Commits
d26ea64ab2
...
c26ca9088b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c26ca9088b | ||
|
|
63a9dac9d3 | ||
|
|
a4732cd2d4 | ||
|
|
35bd60af5b | ||
|
|
b8dce8a42a |
422
.claude/skills/design-handoff/SKILL.md
Normal file
422
.claude/skills/design-handoff/SKILL.md
Normal file
@@ -0,0 +1,422 @@
|
||||
---
|
||||
name: design-handoff
|
||||
description: 将 HTML 原型转换为结构化设计交付包(截图 + SPEC.md + Token 映射),供新会话 LLM 高保真实现
|
||||
---
|
||||
|
||||
# 设计交付 Skill (design-handoff)
|
||||
|
||||
将 huashu-design 生成的 HTML 原型转换为结构化设计交付包,使新会话中的 LLM 能够高保真还原 UI 实现。
|
||||
|
||||
## 触发词
|
||||
|
||||
- `design-handoff`
|
||||
- `设计交付`
|
||||
- `handoff`
|
||||
|
||||
---
|
||||
|
||||
## 前置检查
|
||||
|
||||
执行任何步骤前,必须完成以下检查:
|
||||
|
||||
### 1. 输入验证
|
||||
|
||||
确认输入文件为 huashu-design 产物,须同时满足:
|
||||
- 文件为 `.html` 格式
|
||||
- 包含 React + Babel 的 script 标签(`<script src="...babel...">` 或 `type="text/babel"`)
|
||||
- 包含 Token 定义块(`const T = {` 或等效全局样式常量)
|
||||
|
||||
若不满足,输出错误并终止:
|
||||
```
|
||||
[design-handoff] 错误:输入文件不是 huashu-design 产物。
|
||||
期望特征:
|
||||
- React/Babel script 标签
|
||||
- const T = { ... } 样式定义块
|
||||
请确认输入文件来源。
|
||||
```
|
||||
|
||||
### 2. Token 配置文件初始化
|
||||
|
||||
检查项目根目录下的 `.design/tokens.yml` 是否存在:
|
||||
|
||||
- **存在** → 直接使用,跳过初始化
|
||||
- **不存在** → 从 `defaults/tokens.yml` 复制到 `.design/tokens.yml`
|
||||
|
||||
若 `defaults/tokens.yml` 也不存在,输出警告并使用内置最小 Token 集:
|
||||
```yaml
|
||||
# 内置最小 Token 集(defaults/tokens.yml 缺失时的降级方案)
|
||||
color:
|
||||
primary: "#1890ff"
|
||||
success: "#52c41a"
|
||||
warning: "#faad14"
|
||||
danger: "#ff4d4f"
|
||||
text: "#333333"
|
||||
text_secondary: "#666666"
|
||||
bg: "#ffffff"
|
||||
bg_secondary: "#f5f5f5"
|
||||
border: "#d9d9d9"
|
||||
spacing:
|
||||
xs: 4
|
||||
sm: 8
|
||||
md: 16
|
||||
lg: 24
|
||||
xl: 32
|
||||
fontSize:
|
||||
h1: 28
|
||||
h2: 22
|
||||
body_lg: 18
|
||||
body: 16
|
||||
body_sm: 14
|
||||
caption: 13
|
||||
radius:
|
||||
sm: 4
|
||||
md: 8
|
||||
lg: 16
|
||||
pill: 999
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程(6 步)
|
||||
|
||||
### Step 1: 解析 HTML 原型
|
||||
|
||||
调用 `scripts/parse-prototype.mjs` 解析输入 HTML 文件:
|
||||
|
||||
```bash
|
||||
node scripts/parse-prototype.mjs <html文件路径>
|
||||
```
|
||||
|
||||
**输出(stdout JSON):**
|
||||
- `tokens`: T 对象所有键值对(如 `{"T.pri": "#C4623A", ...}`)
|
||||
- `inlineStyles`: 内联样式值收集(按 CSS 属性分组,如 `{"fontSize": ["32","28",...], ...}`)
|
||||
- `screens`: 每个屏幕的标签和组件名(如 `[{"label": "访客首页", "component": "GuestHome"}]`)
|
||||
- `components`: 所有组件函数定义列表
|
||||
|
||||
**若脚本不存在:** LLM 自行解析 HTML,提取 T 对象和 DOM 结构。
|
||||
|
||||
### Step 2: 截图
|
||||
|
||||
调用 `scripts/extract-screenshots.mjs` 生成截图:
|
||||
|
||||
```bash
|
||||
node scripts/extract-screenshots.mjs <html文件路径> <输出目录>
|
||||
```
|
||||
|
||||
**输出:** PNG 文件到输出目录,文件名由中文标签自动翻译为英文。
|
||||
- 每个 `.screen-wrap` 生成一张截图(裁掉 IosFrame 设备框、状态栏、Home Indicator)
|
||||
- 无 `.screen-wrap` 时降级为整页截图 `full-page.png`
|
||||
|
||||
**若脚本不存在:** 指导用户手动截图,保存到 `docs/design/{原型名}/screenshots/`。
|
||||
|
||||
### Step 3: Token 匹配
|
||||
|
||||
调用 `scripts/match-tokens.mjs` 进行三层 Token 匹配:
|
||||
|
||||
```bash
|
||||
# 先保存 Step 1 的输出到临时文件
|
||||
node scripts/parse-prototype.mjs <html> > /tmp/parse-result.json
|
||||
node scripts/match-tokens.mjs /tmp/parse-result.json .design/tokens.yml
|
||||
```
|
||||
|
||||
**三层匹配算法:**
|
||||
1. **别名直查** — 在 `aliases.prototype_keys` 中查找已确认映射
|
||||
2. **值精确匹配** — 按 CSS 属性上下文消歧(borderRadius→radius, fontSize→typography)
|
||||
3. **色彩模糊匹配** — RGB 欧几里得距离 < 30 视为近似
|
||||
|
||||
**输出(stdout JSON):**
|
||||
- `matched`: 已匹配 Token(含 confidence: confirmed/pending/approximate)
|
||||
- `unmatched`: 未匹配的 Token key 列表
|
||||
- `inlineTokenMap`: 内联样式值的 Token 映射
|
||||
- `summary`: 统计(confirmed/pending/approximate/unmatched 数量)
|
||||
|
||||
**若脚本不存在:** LLM 自行读取 tokens.yml 执行匹配。
|
||||
|
||||
### Step 4: 组件映射推断
|
||||
|
||||
读取 `defaults/components.yml`,然后 LLM 按 DOM 特征推断组件映射。
|
||||
|
||||
**若 `defaults/components.yml` 不存在:** 使用以下内置组件映射规则:
|
||||
|
||||
#### 组件推断规则(8 条)
|
||||
|
||||
按优先级从高到低依次匹配,首个命中的规则决定组件类型:
|
||||
|
||||
| # | DOM 特征 | 推断组件 | 关键判定信号 |
|
||||
|---|----------|----------|-------------|
|
||||
| 1 | 容器节点 + `borderRadius` + `boxShadow` + 内含标题元素 + 正文元素 | `ContentCard` | borderRadius > 0 + boxShadow 存在 + 子节点含标题层级文本 |
|
||||
| 2 | 左侧竖线装饰(`borderLeft` / `::before` 伪元素宽度 3-6px)+ 标题文本 + 可选右侧链接文字 | `SectionTitle` | 3-6px 左侧彩色边框 + 标题级字号 |
|
||||
| 3 | 按钮节点 + 主色背景(匹配 `color.primary`)+ 白色文字 + `height` 44-56px | `PrimaryButton` | 背景色 = primary + 文字色 = 白/接近白 + 按钮语义 |
|
||||
| 4 | 按钮节点 + 边框存在 + 背景透明/白色 + 文字色 = 主色或中性色 | `SecondaryButton` | border 存在 + 背景无填充 + 按钮语义 |
|
||||
| 5 | 页面最外层容器节点 + `padding` + 可滚动(`overflow: auto/scroll` 或页面 body 级) | `PageShell` | DOM 深度 = 1-2 + padding + 无固定高度 |
|
||||
| 6 | 小型容器 + 接近正方形/圆形(宽高比 0.8-1.2)+ 包含数字或短文字 + 有背景色 | `StatusTag` | 宽高比 ≈ 1 + 内容简短 + 背景色非白 |
|
||||
| 7 | 列表行 + 左侧图标区 + 双行文字(主文字 + 副文字)+ 右侧箭头或 chevron 符号 | `ListItem` | 行级容器 + 左图标 + 双行文本 + 右箭头 |
|
||||
| 8 | 未匹配上述任何规则 | `需新建组件` | 标记为 `Unknown__<描述>`,需在 SPEC.md 中描述视觉特征 |
|
||||
|
||||
**推断方法:**
|
||||
1. 对解析后的 DOM 树做深度优先遍历
|
||||
2. 对每个非叶子节点,按上述 8 条规则依次检测
|
||||
3. 命中规则 → 标记组件类型 + 记录判定依据
|
||||
4. 全部未命中 → 标记 `需新建组件` + 附带节点样式摘要
|
||||
5. 输出组件映射表到交付包
|
||||
|
||||
### Step 5: 交互推断
|
||||
|
||||
调用 `scripts/infer-interactions.mjs` 推断交互行为:
|
||||
|
||||
```bash
|
||||
node scripts/infer-interactions.mjs <html文件路径> rules/interaction-rules.yml
|
||||
```
|
||||
|
||||
**输出(stdout JSON):**
|
||||
- `interactions`: 每条规则的匹配结果(matched/unmatched、命中的 pattern、location 定位)
|
||||
- `summary`: matched/unmatched 统计
|
||||
|
||||
**若脚本不存在:** LLM 自行推断:
|
||||
1. 检查 DOM 中的 `onClick` / `onChange` 等事件绑定
|
||||
2. 检查 CSS 中的 `transition` / `animation` / `@keyframes`
|
||||
3. 检查 `cursor: pointer` 元素(隐含可点击)
|
||||
4. 检查 `overflow: scroll/auto` 容器(隐含滚动行为)
|
||||
5. 按钮语义元素(`<button>`、`role="button"`)推断点击行为
|
||||
|
||||
### Step 6: 组装 SPEC.md
|
||||
|
||||
读取 `templates/spec-template.md`,将前 5 步收集的数据组装为 SPEC.md。
|
||||
|
||||
**若模板不存在:** 使用以下结构:
|
||||
|
||||
```markdown
|
||||
# {原型名称} 设计规格
|
||||
|
||||
> 由 design-handoff 自动生成 | 源文件: {HTML文件名} | 生成时间: {ISO日期}
|
||||
|
||||
## 1. 概览
|
||||
|
||||
- **原型文件**: {文件名}
|
||||
- **截图**: screenshots/full.png
|
||||
- **设计 Token 数**: {matched数}/{总Token数} 已匹配
|
||||
- **组件数**: {组件总数}
|
||||
- **交互行为数**: {交互总数}
|
||||
|
||||
## 2. 设计 Token
|
||||
|
||||
### 2.1 颜色
|
||||
|
||||
| Token 名称 | 值 | 使用位置 |
|
||||
|------------|-----|---------|
|
||||
| ... | ... | ... |
|
||||
|
||||
### 2.2 字号
|
||||
|
||||
| Token 名称 | 值 (px) | 使用位置 |
|
||||
|------------|---------|---------|
|
||||
| ... | ... | ... |
|
||||
|
||||
### 2.3 间距
|
||||
|
||||
| Token 名称 | 值 (px) | 使用位置 |
|
||||
|------------|---------|---------|
|
||||
| ... | ... | ... |
|
||||
|
||||
### 2.4 圆角
|
||||
|
||||
| Token 名称 | 值 (px) | 使用位置 |
|
||||
|------------|---------|---------|
|
||||
| ... | ... | ... |
|
||||
|
||||
## 3. 组件清单
|
||||
|
||||
### 3.1 组件树
|
||||
|
||||
{组件层级树形结构}
|
||||
|
||||
### 3.2 组件详情
|
||||
|
||||
#### {组件名}
|
||||
|
||||
- **推断规则**: 命中规则 #{N}
|
||||
- **Token 依赖**: [列出使用的 Token]
|
||||
- **子组件**: [列出子组件]
|
||||
- **截图**: screenshots/sections/{组件名}.png(如有)
|
||||
- **样式属性**:
|
||||
```
|
||||
{原始 CSS 属性键值对}
|
||||
```
|
||||
|
||||
## 4. 交互行为
|
||||
|
||||
| 元素 | 事件类型 | 行为描述 | 实现建议 |
|
||||
|------|---------|---------|---------|
|
||||
| ... | click/scroll/input/transition | ... | ... |
|
||||
|
||||
## 5. 未匹配项
|
||||
|
||||
### 5.1 未匹配 Token
|
||||
|
||||
| 值 | 类型 | 出现位置 | 建议 Token 名称 |
|
||||
|----|------|---------|---------------|
|
||||
| ... | color/fontSize/spacing/radius | ... | ... |
|
||||
|
||||
### 5.2 需新建组件
|
||||
|
||||
| 组件名建议 | 视觉特征摘要 | 出现位置 |
|
||||
|-----------|------------|---------|
|
||||
| ... | ... | ... |
|
||||
|
||||
## 6. 实施注意事项
|
||||
|
||||
{LLM 根据原型复杂度自动生成的实现提示}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 输出流程
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
docs/design/
|
||||
mp-00-visitor.html # 原始原型(不修改)
|
||||
mp-00-visitor/ # 交付包目录(与原型同名)
|
||||
├── SPEC.md # 设计规格文档(Step 6 产物)
|
||||
├── screenshots/ # 截图目录
|
||||
│ ├── home.png
|
||||
│ └── profile.png
|
||||
├── tokens.json # Token 匹配结果(Step 3 产物)
|
||||
└── META.yml # 元数据
|
||||
```
|
||||
|
||||
### META.yml 格式
|
||||
|
||||
```yaml
|
||||
prototype: {原型文件名}
|
||||
source: {HTML文件绝对路径}
|
||||
generated_at: {ISO 8601 时间戳}
|
||||
tokens:
|
||||
matched: {数}
|
||||
unmatched: {数}
|
||||
pending: {数}
|
||||
components:
|
||||
total: {数}
|
||||
mapped: {数}
|
||||
new: {数}
|
||||
interactions: {数}
|
||||
```
|
||||
|
||||
### 终端输出
|
||||
|
||||
完成全部步骤后,在终端输出实施 Prompt 模板:
|
||||
|
||||
```
|
||||
============================================================
|
||||
设计交付完成: {原型名}
|
||||
输出目录: docs/design/{原型名}/
|
||||
============================================================
|
||||
|
||||
下一步:在新会话中粘贴以下 Prompt 实现设计
|
||||
|
||||
--- BEGIN PROMPT ---
|
||||
请根据设计规格实现 {原型名称} 页面。
|
||||
|
||||
设计规格文件: docs/design/{原型名}/SPEC.md
|
||||
截图参考: docs/design/{原型名}/screenshots/full.png
|
||||
Token 文件: docs/design/{原型名}/tokens.json
|
||||
|
||||
实现要求:
|
||||
1. 严格按照 SPEC.md 中的 Token 值设置样式
|
||||
2. 使用 SPEC.md 中映射的组件,若标记为"需新建"则先创建组件
|
||||
3. 实现所有交互行为(见 SPEC.md 第 4 节)
|
||||
4. 未匹配的 Token 需确认后补充到 .design/tokens.yml
|
||||
--- END PROMPT ---
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 批处理模式
|
||||
|
||||
当输入为 glob 模式(如 `docs/design/*.html`)或多个文件路径时:
|
||||
|
||||
1. 展开为文件列表
|
||||
2. 逐个执行 6 步流程(串行,避免截图冲突)
|
||||
3. 每个文件输出到 `docs/design/{各自原型名}/`
|
||||
4. 全部完成后生成索引文件:
|
||||
|
||||
### docs/design/INDEX.md
|
||||
|
||||
```markdown
|
||||
# 设计交付索引
|
||||
|
||||
> 自动生成于 {ISO 日期}
|
||||
|
||||
| # | 原型名 | 截图 | SPEC | Token 匹配率 | 组件数 | 状态 |
|
||||
|---|--------|------|------|------------|--------|------|
|
||||
| 1 | {名称} | [截图](./{名称}/screenshots/full.png) | [SPEC](./{名称}/SPEC.md) | {N}/{M} | {数} | 待实施 |
|
||||
| 2 | ... | ... | ... | ... | ... | ... |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 待确认项交互
|
||||
|
||||
完成 6 步流程后,若有 `pending` 或 `unmatched` Token,需与用户交互确认:
|
||||
|
||||
### 输出确认清单
|
||||
|
||||
```
|
||||
[design-handoff] 以下 Token 需要确认:
|
||||
|
||||
【待确认 (pending)】
|
||||
1. 值 "#2b85e4" ≈ color.primary (#1890ff) — 是否作为别名?
|
||||
2. 值 15px ≈ spacing.lg (24px) — 是否新增 Token spacing.md_lg?
|
||||
|
||||
【未匹配 (unmatched)】
|
||||
3. 值 "#722ed1" — 建议新增 Token: color.accent_purple
|
||||
4. 值 10px — 建议新增 Token: spacing.ms
|
||||
|
||||
请回复序号+确认结果(如 "1:是, 2:新增, 3:color.purple, 4:忽略")
|
||||
```
|
||||
|
||||
### 处理用户确认
|
||||
|
||||
用户确认后:
|
||||
1. 将 `pending` 项的别名关系写入 `.design/tokens.yml` 的 `aliases` 字段
|
||||
2. 将 `unmatched` 项中确认新增的 Token 写入 `.design/tokens.yml` 对应分类
|
||||
3. 更新 `tokens.json` 和 `SPEC.md` 中的匹配状态
|
||||
|
||||
### aliases 格式示例
|
||||
|
||||
```yaml
|
||||
# .design/tokens.yml
|
||||
color:
|
||||
primary: "#1890ff"
|
||||
aliases:
|
||||
blue_hover: "#2b85e4" # 用户确认: primary 的 hover 态别名
|
||||
accent_purple: "#722ed1" # 用户确认: 新增 accent 色值
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 脚本说明
|
||||
|
||||
本 Skill 引用以下脚本(位于 `scripts/` 目录):
|
||||
|
||||
| 脚本 | 用途 | 输入 | 输出 |
|
||||
|------|------|------|------|
|
||||
| `parse-prototype.mjs` | 解析 HTML 原型为结构化 JSON | HTML 文件路径 | JSON DOM 树 |
|
||||
| `extract-screenshots.mjs` | 使用 Puppeteer/Playwright 截图 | HTML 文件路径 + 输出目录 | PNG 截图文件 |
|
||||
| `match-tokens.mjs` | 样式值与 Token 库匹配 | 解析 JSON + tokens.yml | 匹配报告 JSON |
|
||||
| `infer-interactions.mjs` | 推断交互行为 | 解析 JSON | 交互清单 JSON |
|
||||
|
||||
**降级策略:** 每个脚本缺失时,LLM 自行执行对应步骤(详见各 Step 说明)。脚本仅为加速工具,不影响核心功能。
|
||||
|
||||
## 模板说明
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `templates/spec-template.md` | SPEC.md 的自定义模板(可选) |
|
||||
| `defaults/tokens.yml` | Token 初始定义(新项目首次使用时复制到 `.design/`) |
|
||||
| `defaults/components.yml` | 组件映射规则的自定义扩展(可选,覆盖内置 8 条规则) |
|
||||
|
||||
## 规则文件
|
||||
|
||||
`rules/` 目录可放置额外的设计规范约束文件(如品牌色限制、无障碍要求等),在 Step 4 组件推断和 Step 6 组装 SPEC 时自动加载。
|
||||
139
.claude/skills/design-handoff/defaults/components.yml
Normal file
139
.claude/skills/design-handoff/defaults/components.yml
Normal file
@@ -0,0 +1,139 @@
|
||||
version: 1
|
||||
updated: "2026-05-17"
|
||||
|
||||
# ============================================================================
|
||||
# 小程序 UI 组件映射注册表
|
||||
# 数据源: apps/miniprogram/src/components/ui/
|
||||
# 每个组件记录 import 路径和实际 props(从源码接口定义提取)
|
||||
# ============================================================================
|
||||
|
||||
components:
|
||||
# --- 数据展示 ---
|
||||
ContentCard:
|
||||
miniprogram:
|
||||
import: "@components/ui/ContentCard"
|
||||
props: "variant('default'|'outlined'|'elevated'), padding('none'|'sm'|'md'|'lg'), margin('none'|'md'), activeFeedback('bg'|'opacity'|'scale'|'none'), onPress, className, style, children"
|
||||
notes: "通用卡片容器,padding 映射到 --tk-card-padding-* token"
|
||||
|
||||
AlertCard:
|
||||
miniprogram:
|
||||
import: "@components/ui/AlertCard"
|
||||
props: "variant('gradient'|'left-border'|'bordered'), title, subtitle, children, className"
|
||||
notes: "告警/提示卡片,默认 left-border 变体"
|
||||
|
||||
VitalCard:
|
||||
miniprogram:
|
||||
import: "@components/ui/VitalCard"
|
||||
props: "label, value, unit, status, onPress, className"
|
||||
notes: "体征数据卡片,内嵌 StatusTag 显示状态"
|
||||
|
||||
ListItem:
|
||||
miniprogram:
|
||||
import: "@components/ui/ListItem"
|
||||
props: "title, subtitle, extra(ReactNode), leftIcon(ReactNode), onPress, showArrow, unread, className"
|
||||
notes: "列表行组件,支持图标/箭头/未读标记"
|
||||
|
||||
InfoRow:
|
||||
miniprogram:
|
||||
import: "@components/ui/InfoRow"
|
||||
props: "label, value, valueNode(ReactNode), last, className"
|
||||
notes: "键值对信息行,last 控制底部分隔线"
|
||||
|
||||
ChatBubble:
|
||||
miniprogram:
|
||||
import: "@components/ui/ChatBubble"
|
||||
props: "content, isMine, time, className"
|
||||
notes: "聊天气泡,isMine 控制左右侧和配色"
|
||||
|
||||
# --- 导航与标题 ---
|
||||
SectionTitle:
|
||||
miniprogram:
|
||||
import: "@components/ui/SectionTitle"
|
||||
props: "title, subtitle, action({text, onPress}), icon(ReactNode)"
|
||||
notes: "区块标题,左侧竖线装饰 + 可选右侧操作链接"
|
||||
|
||||
TabFilter:
|
||||
miniprogram:
|
||||
import: "@components/ui/TabFilter"
|
||||
props: "tabs(string[]), activeIndex, onChange(index), variant('fill'|'pill'|'segment'), className"
|
||||
notes: "标签页筛选器,三种视觉变体"
|
||||
|
||||
GradientHeader:
|
||||
miniprogram:
|
||||
import: "@components/ui/GradientHeader"
|
||||
props: "children, className"
|
||||
notes: "渐变头部容器,承载页面顶部标题区域"
|
||||
|
||||
# --- 输入控件 ---
|
||||
FormInput:
|
||||
miniprogram:
|
||||
import: "@components/ui/FormInput"
|
||||
props: "label, placeholder, value, onInput(value), type('text'|'number'|'idcard'|'digit'), maxLength, disabled, error, className"
|
||||
notes: "表单输入框,支持错误提示和多种输入类型"
|
||||
|
||||
# --- 按钮 ---
|
||||
PrimaryButton:
|
||||
miniprogram:
|
||||
import: "@components/ui/PrimaryButton"
|
||||
props: "children, onClick, disabled, loading, size('default'|'large'), className"
|
||||
notes: "主色按钮,高度映射 --tk-btn-primary-h"
|
||||
|
||||
SecondaryButton:
|
||||
miniprogram:
|
||||
import: "@components/ui/SecondaryButton"
|
||||
props: "children, onClick, disabled, className"
|
||||
notes: "次要/描边按钮,与 PrimaryButton 配套使用"
|
||||
|
||||
# --- 状态指示 ---
|
||||
StatusTag:
|
||||
miniprogram:
|
||||
import: "@components/ui/StatusTag"
|
||||
props: "status, colorMap(Record<string, TagColor>), size('sm'|'md'), className, children"
|
||||
notes: "状态标签,内置 status→color 映射(success/warning/error/info/default)"
|
||||
|
||||
ProgressRing:
|
||||
miniprogram:
|
||||
import: "@components/ui/ProgressRing"
|
||||
props: "progress(0-1), size('sm'|'lg'), label, className"
|
||||
notes: "环形进度条,使用 conic-gradient + --tk-pri 色"
|
||||
|
||||
LoadingCard:
|
||||
miniprogram:
|
||||
import: "@components/ui/LoadingCard"
|
||||
props: "count, layout('card'|'list'|'detail')"
|
||||
notes: "骨架屏加载占位,三种布局形态"
|
||||
|
||||
PageShell:
|
||||
miniprogram:
|
||||
import: "@components/ui/PageShell"
|
||||
props: "padding('none'|'sm'|'md'|'lg'), safeBottom, scroll, className, children"
|
||||
notes: "页面外壳,统一页面内边距和底部安全区,scroll 控制是否 ScrollView 包裹"
|
||||
|
||||
# ============================================================================
|
||||
# 框架/平台组件(非自研,Taro/微信原生)
|
||||
# ============================================================================
|
||||
framework_components:
|
||||
Swiper:
|
||||
import: "@tarojs/components"
|
||||
props: "autoplay, interval, duration, circular, indicatorDots, indicatorColor, indicatorActiveColor, onChange, children"
|
||||
notes: "Taro Swiper 组件,用于轮播图场景"
|
||||
|
||||
TabBar:
|
||||
import: "framework-config"
|
||||
props: "N/A(app.config.ts 中声明 tabBar 配置)"
|
||||
notes: "微信小程序原生 TabBar,在 app.config.ts 的 tabBar 字段配置"
|
||||
|
||||
ScrollView:
|
||||
import: "@tarojs/components"
|
||||
props: "scrollY, scrollX, scrollTop, onScroll, onScrollToLower, onScrollToUpper, children"
|
||||
notes: "Taro ScrollView,PageShell 内部使用"
|
||||
|
||||
Input:
|
||||
import: "@tarojs/components"
|
||||
props: "value, placeholder, type, maxlength, disabled, onInput, onFocus, onBlur"
|
||||
notes: "Taro Input,FormInput 内部使用"
|
||||
|
||||
Picker:
|
||||
import: "@tarojs/components"
|
||||
props: "mode('date'|'time'|'selector'), value, range, onChange, children"
|
||||
notes: "Taro Picker,用于日期/时间/选项选择"
|
||||
405
.claude/skills/design-handoff/defaults/tokens.yml
Normal file
405
.claude/skills/design-handoff/defaults/tokens.yml
Normal file
@@ -0,0 +1,405 @@
|
||||
version: 1
|
||||
updated: "2026-05-17"
|
||||
|
||||
# ============================================================================
|
||||
# Design Token 注册表
|
||||
# 数据源: apps/miniprogram/src/styles/tokens.scss + variables.scss
|
||||
# ============================================================================
|
||||
|
||||
colors:
|
||||
# --- 主色系(赤土橙) ---
|
||||
- token: --tk-pri
|
||||
value: "#C4623A"
|
||||
scss_var: "$pri"
|
||||
role: 主色/赤土橙 accent
|
||||
|
||||
- token: --tk-pri-l
|
||||
value: "#F0DDD4"
|
||||
scss_var: "$pri-l"
|
||||
role: 主色浅/赤土浅
|
||||
|
||||
- token: --tk-pri-d
|
||||
value: "#8B3E1F"
|
||||
scss_var: "$pri-d"
|
||||
role: 主色深/赤土深
|
||||
|
||||
# --- 阴影色(含透明度) ---
|
||||
- token: --tk-shadow-btn
|
||||
value: "0 4px 16px rgba(196, 98, 58, 0.3)"
|
||||
scss_var: "$shadow-btn"
|
||||
role: 主按钮阴影
|
||||
|
||||
- token: --tk-shadow-tab
|
||||
value: "0 2px 8px rgba(196, 98, 58, 0.25)"
|
||||
scss_var: "$shadow-tab"
|
||||
role: 选中Tab阴影
|
||||
|
||||
# --- 文字色 ---
|
||||
- token: --tk-text-secondary
|
||||
value: "#78716C"
|
||||
scss_var: "$tx3"
|
||||
role: 淡文字/辅助文字
|
||||
|
||||
# --- 卡片背景 ---
|
||||
- token: --tk-card-bg
|
||||
value: "#FFFFFF"
|
||||
scss_var: "$card"
|
||||
role: 卡片白底
|
||||
|
||||
# --- 医生端覆盖色(.doctor-mode 下自动替换 --tk-pri*) ---
|
||||
- token: --tk-pri.doctor
|
||||
value: "#3A6B8C"
|
||||
scss_var: "$doc-pri"
|
||||
role: 医生端主色/靛蓝
|
||||
note: 仅在 .doctor-mode 下覆盖 --tk-pri
|
||||
|
||||
- token: --tk-pri-l.doctor
|
||||
value: "#D4E5F0"
|
||||
scss_var: "$doc-pri-l"
|
||||
role: 医生端浅色
|
||||
|
||||
- token: --tk-pri-d.doctor
|
||||
value: "#2A4F6A"
|
||||
scss_var: "$doc-pri-d"
|
||||
role: 医生端深色
|
||||
|
||||
# --- 未映射到 Token 的 SCSS 变量(原型中有但 tokens.scss 未声明为 CSS 变量) ---
|
||||
unmapped_scss_variables:
|
||||
- scss_var: "$bg"
|
||||
value: "#F5F0EB"
|
||||
role: 页面主背景/温润米底
|
||||
note: "原型 T.bg 映射目标,tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
- scss_var: "$tx"
|
||||
value: "#2D2A26"
|
||||
role: 主文字色/暖黑
|
||||
note: "原型 T.tx 映射目标,tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
- scss_var: "$tx2"
|
||||
value: "#5A554F"
|
||||
role: 次文字色/暖灰
|
||||
note: "原型 T.tx2 近似映射,elder-mode 下 --tk-text-secondary 覆盖为此值"
|
||||
|
||||
- scss_var: "$bd"
|
||||
value: "#E8E2DC"
|
||||
role: 边框色
|
||||
note: "原型 T.bd 映射目标,tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
- scss_var: "$bd-l"
|
||||
value: "#F0EBE5"
|
||||
role: 浅边框色
|
||||
|
||||
- scss_var: "$acc"
|
||||
value: "#5B7A5E"
|
||||
role: 鼠尾草绿/成功色
|
||||
note: "原型中成功色,tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
- scss_var: "$acc-l"
|
||||
value: "#E8F0E8"
|
||||
role: 成功浅色
|
||||
|
||||
- scss_var: "$dan"
|
||||
value: "#B54A4A"
|
||||
role: 危险色/柔红
|
||||
note: "原型中危险色,tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
- scss_var: "$dan-l"
|
||||
value: "#FDEAEA"
|
||||
role: 危险浅色
|
||||
|
||||
- scss_var: "$wrn"
|
||||
value: "#C4873A"
|
||||
role: 警告色/暖琥珀
|
||||
note: "原型中警告色,tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
- scss_var: "$wrn-l"
|
||||
value: "#FFF3E0"
|
||||
role: 警告浅色
|
||||
|
||||
- scss_var: "$surface-alt"
|
||||
value: "#EDE8E2"
|
||||
role: 辅助底色
|
||||
|
||||
- scss_var: "$wechat"
|
||||
value: "#07C160"
|
||||
role: 微信绿
|
||||
|
||||
typography:
|
||||
- token: --tk-font-display
|
||||
value: "72px"
|
||||
note: 大屏展示
|
||||
elder: "80px"
|
||||
|
||||
- token: --tk-font-hero
|
||||
value: "48px"
|
||||
note: 启动页标题
|
||||
elder: "56px"
|
||||
|
||||
- token: --tk-font-h1
|
||||
value: "28px"
|
||||
note: 页面标题 serif bold
|
||||
elder: "32px"
|
||||
|
||||
- token: --tk-font-h2
|
||||
value: "22px"
|
||||
note: 副标题、用户名 serif bold
|
||||
elder: "25px"
|
||||
|
||||
- token: --tk-font-body-lg
|
||||
value: "18px"
|
||||
note: 按钮文字、section 标题 fontWeight:600
|
||||
elder: "22px"
|
||||
|
||||
- token: --tk-font-body
|
||||
value: "16px"
|
||||
note: 正文、输入框、icon 文字(最常用 UI 字号)
|
||||
elder: "22px"
|
||||
|
||||
- token: --tk-font-body-sm
|
||||
value: "14px"
|
||||
note: 副文本、描述
|
||||
elder: "19px"
|
||||
|
||||
- token: --tk-font-num
|
||||
value: "30px"
|
||||
note: 数值 serif bold
|
||||
elder: "34px"
|
||||
|
||||
- token: --tk-font-num-lg
|
||||
value: "34px"
|
||||
note: 大数值
|
||||
elder: "40px"
|
||||
|
||||
- token: --tk-font-cap
|
||||
value: "13px"
|
||||
note: 说明文字(第一高频字号)
|
||||
elder: "18px"
|
||||
|
||||
- token: --tk-font-nav
|
||||
value: "18px"
|
||||
note: 导航栏标题 serif bold
|
||||
elder: "22px"
|
||||
|
||||
- token: --tk-font-micro
|
||||
value: "11px"
|
||||
note: 角标、tag
|
||||
elder: "17px"
|
||||
|
||||
structure:
|
||||
- token: --tk-line-height
|
||||
value: "1.5"
|
||||
elder: "1.7"
|
||||
|
||||
spacing:
|
||||
- token: --tk-gap-2xs
|
||||
value: "4px"
|
||||
scss_var: "$sp-2xs"
|
||||
elder: "6px"
|
||||
|
||||
- token: --tk-gap-xs
|
||||
value: "8px"
|
||||
scss_var: "$sp-xs"
|
||||
elder: "12px"
|
||||
|
||||
- token: --tk-gap-sm
|
||||
value: "12px"
|
||||
scss_var: "$sp-sm"
|
||||
elder: "16px"
|
||||
|
||||
- token: --tk-gap-md
|
||||
value: "16px"
|
||||
scss_var: "$sp-md"
|
||||
elder: "20px"
|
||||
|
||||
- token: --tk-section-gap
|
||||
value: "20px"
|
||||
scss_var: "$sp-section"
|
||||
elder: "28px"
|
||||
|
||||
- token: --tk-gap-lg
|
||||
value: "24px"
|
||||
scss_var: "$sp-lg"
|
||||
elder: "32px"
|
||||
|
||||
- token: --tk-gap-xl
|
||||
value: "32px"
|
||||
scss_var: "$sp-xl"
|
||||
elder: "40px"
|
||||
|
||||
- token: --tk-gap-2xl
|
||||
value: "48px"
|
||||
scss_var: "$sp-2xl"
|
||||
elder: "56px"
|
||||
|
||||
- token: --tk-page-padding
|
||||
value: "20px"
|
||||
elder: "28px"
|
||||
|
||||
- token: --tk-card-padding
|
||||
value: "20px"
|
||||
elder: "28px"
|
||||
|
||||
- token: --tk-card-padding-sm
|
||||
value: "16px"
|
||||
elder: "20px"
|
||||
|
||||
- token: --tk-card-padding-lg
|
||||
value: "28px"
|
||||
elder: "36px"
|
||||
|
||||
radius:
|
||||
- token: --tk-card-radius
|
||||
value: "16px"
|
||||
scss_var: "$r"
|
||||
elder: "20px"
|
||||
|
||||
radius_unmapped:
|
||||
- scss_var: "$r-sm"
|
||||
value: "12px"
|
||||
note: "原型 T.rSm 映射目标"
|
||||
- scss_var: "$r-xs"
|
||||
value: "8px"
|
||||
note: "原型 T.rXs 映射目标"
|
||||
- scss_var: "$r-lg"
|
||||
value: "20px"
|
||||
- scss_var: "$r-pill"
|
||||
value: "999px"
|
||||
|
||||
sizing:
|
||||
- token: --tk-touch-min
|
||||
value: "48px"
|
||||
role: 最小触控区
|
||||
elder: "56px"
|
||||
|
||||
- token: --tk-btn-primary-h
|
||||
value: "52px"
|
||||
role: 主按钮高度
|
||||
elder: "60px"
|
||||
|
||||
- token: --tk-input-height
|
||||
value: "56px"
|
||||
role: 输入框高度
|
||||
elder: "64px"
|
||||
|
||||
- token: --tk-tabbar-space
|
||||
value: "100px"
|
||||
role: TabBar 底部安全区
|
||||
elder: "120px"
|
||||
|
||||
feedback:
|
||||
- token: --tk-touch-feedback-opacity
|
||||
value: "0.85"
|
||||
role: 触控反馈透明度
|
||||
elder: "0.8"
|
||||
|
||||
tag:
|
||||
- token: --tk-tag-font-size
|
||||
value: "11px"
|
||||
elder: "13px"
|
||||
|
||||
- token: --tk-tag-padding-v
|
||||
value: "3px"
|
||||
elder: "5px"
|
||||
|
||||
- token: --tk-tag-padding-h
|
||||
value: "8px"
|
||||
elder: "12px"
|
||||
|
||||
shadow_unmapped:
|
||||
# tokens.scss 中的 --tk-shadow-btn/tab 是复合值(含偏移+模糊+颜色)
|
||||
# 以下为 variables.scss 中的其他阴影,未声明为 CSS Token
|
||||
- scss_var: "$shadow-sm"
|
||||
value: "0 1px 4px rgba(45, 42, 38, 0.06)"
|
||||
role: 小阴影
|
||||
|
||||
- scss_var: "$shadow-md"
|
||||
value: "0 2px 12px rgba(45, 42, 38, 0.10)"
|
||||
role: 中阴影
|
||||
|
||||
- scss_var: "$shadow-lg"
|
||||
value: "0 8px 32px rgba(45, 42, 38, 0.15)"
|
||||
role: 大阴影
|
||||
|
||||
# ============================================================================
|
||||
# 原型 Key → Token 映射(aliases)
|
||||
# 用于设计移交时自动匹配原型属性到实际 Token
|
||||
# ============================================================================
|
||||
aliases:
|
||||
prototype_keys:
|
||||
T.pri:
|
||||
token: --tk-pri
|
||||
status: exact_match
|
||||
|
||||
T.priL:
|
||||
token: --tk-pri-l
|
||||
status: exact_match
|
||||
|
||||
T.priD:
|
||||
token: --tk-pri-d
|
||||
status: exact_match
|
||||
|
||||
T.bg:
|
||||
token: null
|
||||
nearest: --tk-card-bg
|
||||
scss_var: "$bg"
|
||||
value: "#F5F0EB"
|
||||
status: unmatched
|
||||
note: "原型页面背景色,tokens.scss 未声明为 CSS 变量,直接用 $bg SCSS 变量"
|
||||
|
||||
T.card:
|
||||
token: --tk-card-bg
|
||||
status: exact_match
|
||||
|
||||
T.surface:
|
||||
token: --tk-card-bg
|
||||
status: approximate
|
||||
note: "原型中 surface ≈ 卡片白底"
|
||||
|
||||
T.tx:
|
||||
token: null
|
||||
nearest: --tk-text-secondary
|
||||
scss_var: "$tx"
|
||||
value: "#2D2A26"
|
||||
status: unmatched
|
||||
note: "主文字色,tokens.scss 未声明为 CSS 变量,直接用 $tx SCSS 变量"
|
||||
|
||||
T.tx2:
|
||||
token: null
|
||||
nearest: --tk-text-secondary
|
||||
scss_var: "$tx2"
|
||||
value: "#5A554F"
|
||||
status: unmatched
|
||||
note: "次文字色,tokens.scss 未声明,elder-mode 下 --tk-text-secondary 覆盖为此值"
|
||||
|
||||
T.tx3:
|
||||
token: --tk-text-secondary
|
||||
scss_var: "$tx3"
|
||||
value: "#78716C"
|
||||
status: exact_match
|
||||
|
||||
T.bd:
|
||||
token: null
|
||||
scss_var: "$bd"
|
||||
value: "#E8E2DC"
|
||||
status: unmatched
|
||||
note: "边框色(不是圆角),tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
T.r:
|
||||
token: --tk-card-radius
|
||||
scss_var: "$r"
|
||||
value: "16px"
|
||||
status: exact_match
|
||||
|
||||
T.rSm:
|
||||
token: null
|
||||
scss_var: "$r-sm"
|
||||
value: "12px"
|
||||
status: unmatched
|
||||
note: "tokens.scss 未声明,需添加 --tk-radius-sm 或直接用 $r-sm SCSS 变量"
|
||||
|
||||
T.rXs:
|
||||
token: null
|
||||
scss_var: "$r-xs"
|
||||
value: "8px"
|
||||
status: unmatched
|
||||
note: "tokens.scss 未声明,需添加 --tk-radius-xs 或直接用 $r-xs SCSS 变量"
|
||||
34
.claude/skills/design-handoff/package-lock.json
generated
Normal file
34
.claude/skills/design-handoff/package-lock.json
generated
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "design-handoff",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "design-handoff",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"js-yaml": "^4.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
.claude/skills/design-handoff/package.json
Normal file
16
.claude/skills/design-handoff/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "design-handoff",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"js-yaml": "^4.1.1"
|
||||
}
|
||||
}
|
||||
108
.claude/skills/design-handoff/rules/interaction-rules.yml
Normal file
108
.claude/skills/design-handoff/rules/interaction-rules.yml
Normal file
@@ -0,0 +1,108 @@
|
||||
version: 1
|
||||
updated: "2026-05-17"
|
||||
|
||||
# ============================================================================
|
||||
# 交互推断规则
|
||||
# patterns 使用正则表达式,匹配 HTML/JS 源码中的实际代码模式
|
||||
# require_all: true 表示所有 pattern 必须同时匹配(默认 false,任一匹配即可)
|
||||
# ============================================================================
|
||||
|
||||
rules:
|
||||
- id: swiper-autoplay
|
||||
name: "自动轮播 + 手动滑动"
|
||||
patterns:
|
||||
- "linear-gradient"
|
||||
- "width.*24.*width.*8"
|
||||
require_all: false
|
||||
infer:
|
||||
component: "Swiper"
|
||||
props: "autoplay circular indicatorDots"
|
||||
behavior: "自动轮播,3-5秒切换"
|
||||
confidence: high
|
||||
|
||||
- id: card-tap
|
||||
name: "卡片点击跳转"
|
||||
patterns:
|
||||
- "\\.map\\("
|
||||
require_all: false
|
||||
infer:
|
||||
component: "ContentCard"
|
||||
props: "activeFeedback onPress"
|
||||
behavior: "卡片可点击,带触控反馈"
|
||||
confidence: medium
|
||||
|
||||
- id: form-submit
|
||||
name: "表单提交"
|
||||
patterns:
|
||||
- "<input"
|
||||
- "<button"
|
||||
require_all: false
|
||||
infer:
|
||||
component: "FormInput + PrimaryButton"
|
||||
props: ""
|
||||
behavior: "表单输入+提交按钮"
|
||||
confidence: high
|
||||
|
||||
- id: list-scroll
|
||||
name: "列表滚动"
|
||||
patterns:
|
||||
- "overflow.*auto|scroll"
|
||||
- "\\.map\\(.*\\.map\\(.*\\.map\\("
|
||||
require_all: false
|
||||
infer:
|
||||
component: "ScrollView"
|
||||
props: "scrollY onScrollToLower"
|
||||
behavior: "可滚动列表,支持上拉加载"
|
||||
confidence: medium
|
||||
|
||||
- id: tab-switch
|
||||
name: "标签页切换"
|
||||
patterns:
|
||||
- "tab"
|
||||
- "segment"
|
||||
- "filter"
|
||||
require_all: false
|
||||
infer:
|
||||
component: "TabFilter"
|
||||
props: "tabs onChange"
|
||||
behavior: "标签页切换筛选"
|
||||
confidence: medium
|
||||
|
||||
- id: static-decoration
|
||||
name: "纯装饰无交互"
|
||||
patterns:
|
||||
- "position.*absolute"
|
||||
- "opacity.*0\\."
|
||||
require_all: true
|
||||
infer:
|
||||
component: null
|
||||
props: ""
|
||||
behavior: "纯装饰性元素,无交互"
|
||||
confidence: high
|
||||
|
||||
- id: login-cta
|
||||
name: "登录/注册触发"
|
||||
patterns:
|
||||
- "登录"
|
||||
- "立即"
|
||||
- "注册"
|
||||
require_all: false
|
||||
infer:
|
||||
component: "PrimaryButton"
|
||||
props: "onClick"
|
||||
behavior: "登录/注册引导按钮"
|
||||
confidence: high
|
||||
|
||||
- id: empty-fallback
|
||||
name: "空数据降级"
|
||||
patterns:
|
||||
- "\\.length > 0"
|
||||
- "\\.length === 0"
|
||||
- "暂无"
|
||||
- "没有"
|
||||
require_all: false
|
||||
infer:
|
||||
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();
|
||||
666
.claude/skills/design-handoff/scripts/match-tokens.mjs
Normal file
666
.claude/skills/design-handoff/scripts/match-tokens.mjs
Normal file
@@ -0,0 +1,666 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* match-tokens.mjs — 三层 Token 匹配
|
||||
*
|
||||
* 接收 parse-prototype.mjs 的输出 JSON 和 tokens.yml 配置,
|
||||
* 对每个原型 Token(别名 / 内联样式值)执行三层匹配,
|
||||
* 输出映射关系到 stdout。
|
||||
*
|
||||
* 三层匹配算法:
|
||||
* Layer 1: 别名直查(aliases.prototype_keys)
|
||||
* Layer 2: 值精确匹配(带 CSS 属性上下文消歧)
|
||||
* Layer 3: 色彩模糊匹配(RGB 欧几里得距离 < 30)
|
||||
*
|
||||
* 用法:
|
||||
* node match-tokens.mjs <parse-result.json> <tokens.yml>
|
||||
*/
|
||||
|
||||
import yaml from 'js-yaml';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 工具函数
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 解析 hex 颜色字符串为 {r, g, b}
|
||||
* 支持 #RGB / #RRGGBB / RRGGBB
|
||||
*/
|
||||
function hexToRgb(hex) {
|
||||
if (!hex || typeof hex !== 'string') return null;
|
||||
const cleaned = hex.replace(/^#/, '').trim();
|
||||
if (cleaned.length === 3) {
|
||||
const r = parseInt(cleaned[0] + cleaned[0], 16);
|
||||
const g = parseInt(cleaned[1] + cleaned[1], 16);
|
||||
const b = parseInt(cleaned[2] + cleaned[2], 16);
|
||||
return { r, g, b };
|
||||
}
|
||||
if (cleaned.length === 6) {
|
||||
const r = parseInt(cleaned.substring(0, 2), 16);
|
||||
const g = parseInt(cleaned.substring(2, 4), 16);
|
||||
const b = parseInt(cleaned.substring(4, 6), 16);
|
||||
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;
|
||||
return { r, g, b };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* RGB 欧几里得距离
|
||||
*/
|
||||
function colorDistance(rgb1, rgb2) {
|
||||
return Math.sqrt(
|
||||
(rgb1.r - rgb2.r) ** 2 +
|
||||
(rgb1.g - rgb2.g) ** 2 +
|
||||
(rgb1.b - rgb2.b) ** 2,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字符串是否为颜色值(hex 格式)
|
||||
*/
|
||||
function isHexColor(value) {
|
||||
if (!value || typeof value !== 'string') return false;
|
||||
return /^#?[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(value.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化数值:去掉 px 后缀,返回数值字符串(保留小数)
|
||||
*/
|
||||
function normalizeNumericValue(value) {
|
||||
if (value == null) return null;
|
||||
const str = String(value).trim();
|
||||
if (str.endsWith('px')) {
|
||||
return str.slice(0, -2).trim();
|
||||
}
|
||||
if (str.endsWith('rem') || str.endsWith('em')) {
|
||||
return null; // rem/em 标记为 pending,不参与数值匹配
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化颜色值:统一为小写 #rrggbb
|
||||
*/
|
||||
function normalizeColor(value) {
|
||||
if (!value) return null;
|
||||
const str = String(value).trim();
|
||||
const hex = str.startsWith('#') ? str : `#${str}`;
|
||||
const rgb = hexToRgb(hex);
|
||||
if (!rgb) return null;
|
||||
return `#${rgb.r.toString(16).padStart(2, '0')}${rgb.g.toString(16).padStart(2, '0')}${rgb.b.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS 属性到 token 类别的映射
|
||||
*/
|
||||
const CSS_PROPERTY_CATEGORIES = {
|
||||
// 颜色类
|
||||
color: 'color',
|
||||
backgroundColor: 'color',
|
||||
'background-color': 'color',
|
||||
background: 'color',
|
||||
borderColor: 'color',
|
||||
'border-color': 'color',
|
||||
borderTopColor: 'color',
|
||||
borderBottomColor: 'color',
|
||||
borderLeftColor: 'color',
|
||||
borderRightColor: 'color',
|
||||
outlineColor: 'color',
|
||||
textColor: 'color',
|
||||
fill: 'color',
|
||||
|
||||
// 间距类
|
||||
padding: 'spacing',
|
||||
paddingTop: 'spacing',
|
||||
paddingBottom: 'spacing',
|
||||
paddingLeft: 'spacing',
|
||||
paddingRight: 'spacing',
|
||||
paddingVertical: 'spacing',
|
||||
paddingHorizontal: 'spacing',
|
||||
margin: 'spacing',
|
||||
marginTop: 'spacing',
|
||||
marginBottom: 'spacing',
|
||||
marginLeft: 'spacing',
|
||||
marginRight: 'spacing',
|
||||
gap: 'spacing',
|
||||
rowGap: 'spacing',
|
||||
columnGap: 'spacing',
|
||||
top: 'spacing',
|
||||
bottom: 'spacing',
|
||||
left: 'spacing',
|
||||
right: 'spacing',
|
||||
|
||||
// 排版类
|
||||
fontSize: 'typography',
|
||||
lineHeight: 'typography',
|
||||
fontWeight: 'typography',
|
||||
letterSpacing: 'typography',
|
||||
|
||||
// 圆角类
|
||||
borderRadius: 'radius',
|
||||
borderTopLeftRadius: 'radius',
|
||||
borderTopRightRadius: 'radius',
|
||||
borderBottomLeftRadius: 'radius',
|
||||
borderBottomRightRadius: 'radius',
|
||||
|
||||
// 尺寸类
|
||||
width: 'sizing',
|
||||
height: 'sizing',
|
||||
minWidth: 'sizing',
|
||||
minHeight: 'sizing',
|
||||
maxWidth: 'sizing',
|
||||
maxHeight: 'sizing',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token 注册表构建
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 从 tokens.yml 构建可查找的 Token 列表
|
||||
* 返回 { all: [...], byCategory: { color: [...], spacing: [...], ... } }
|
||||
*/
|
||||
function buildTokenRegistry(tokensConfig) {
|
||||
const all = [];
|
||||
const byCategory = {};
|
||||
|
||||
function addToken(entry, category) {
|
||||
const record = {
|
||||
token: entry.token || null,
|
||||
scssVar: entry.scss_var || null,
|
||||
value: entry.value || null,
|
||||
category,
|
||||
role: entry.role || null,
|
||||
note: entry.note || null,
|
||||
};
|
||||
all.push(record);
|
||||
if (!byCategory[category]) byCategory[category] = [];
|
||||
byCategory[category].push(record);
|
||||
}
|
||||
|
||||
// colors
|
||||
if (Array.isArray(tokensConfig.colors)) {
|
||||
for (const c of tokensConfig.colors) {
|
||||
addToken(c, 'color');
|
||||
}
|
||||
}
|
||||
|
||||
// unmapped_scss_variables(颜色类)
|
||||
if (Array.isArray(tokensConfig.unmapped_scss_variables)) {
|
||||
for (const v of tokensConfig.unmapped_scss_variables) {
|
||||
addToken(v, 'color');
|
||||
}
|
||||
}
|
||||
|
||||
// typography
|
||||
if (Array.isArray(tokensConfig.typography)) {
|
||||
for (const t of tokensConfig.typography) {
|
||||
addToken(t, 'typography');
|
||||
}
|
||||
}
|
||||
|
||||
// structure (line-height 归入 typography)
|
||||
if (Array.isArray(tokensConfig.structure)) {
|
||||
for (const s of tokensConfig.structure) {
|
||||
addToken(s, 'typography');
|
||||
}
|
||||
}
|
||||
|
||||
// spacing
|
||||
if (Array.isArray(tokensConfig.spacing)) {
|
||||
for (const s of tokensConfig.spacing) {
|
||||
addToken(s, 'spacing');
|
||||
}
|
||||
}
|
||||
|
||||
// radius
|
||||
if (Array.isArray(tokensConfig.radius)) {
|
||||
for (const r of tokensConfig.radius) {
|
||||
if (r && typeof r === 'object') {
|
||||
addToken(r, 'radius');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(tokensConfig.radius_unmapped)) {
|
||||
for (const r of tokensConfig.radius_unmapped) {
|
||||
if (r && typeof r === 'object') {
|
||||
addToken(r, 'radius');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sizing
|
||||
if (Array.isArray(tokensConfig.sizing)) {
|
||||
for (const s of tokensConfig.sizing) {
|
||||
addToken(s, 'sizing');
|
||||
}
|
||||
}
|
||||
|
||||
// feedback(归入 sizing 或单独)
|
||||
if (Array.isArray(tokensConfig.feedback)) {
|
||||
for (const f of tokensConfig.feedback) {
|
||||
addToken(f, 'feedback');
|
||||
}
|
||||
}
|
||||
|
||||
// tag
|
||||
if (Array.isArray(tokensConfig.tag)) {
|
||||
for (const t of tokensConfig.tag) {
|
||||
addToken(t, 'tag');
|
||||
}
|
||||
}
|
||||
|
||||
// shadow_unmapped
|
||||
if (Array.isArray(tokensConfig.shadow_unmapped)) {
|
||||
for (const s of tokensConfig.shadow_unmapped) {
|
||||
addToken(s, 'shadow');
|
||||
}
|
||||
}
|
||||
|
||||
return { all, byCategory };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 三层匹配
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Layer 1: 别名直查
|
||||
* @returns {object|null} 匹配结果或 null
|
||||
*/
|
||||
function matchByAlias(key, aliases) {
|
||||
if (!aliases || !aliases.prototype_keys) return null;
|
||||
const alias = aliases.prototype_keys[key];
|
||||
if (!alias) return null;
|
||||
|
||||
const result = {
|
||||
method: 'alias',
|
||||
confidence: null,
|
||||
};
|
||||
|
||||
if (alias.status === 'exact_match') {
|
||||
result.token = alias.token || null;
|
||||
result.scssVar = alias.scss_var || null;
|
||||
result.confidence = 'confirmed';
|
||||
} else if (alias.status === 'unmatched') {
|
||||
result.token = alias.token || null;
|
||||
result.scssVar = alias.scss_var || null;
|
||||
result.tokenValue = alias.value || null;
|
||||
result.confidence = 'pending';
|
||||
if (alias.note) result.note = alias.note;
|
||||
if (!result.token && alias.scssVar) {
|
||||
result.note = result.note || `tokens.scss 未声明为 CSS 变量,直接使用 SCSS 变量 ${alias.scssVar}`;
|
||||
}
|
||||
} else if (alias.status === 'approximate') {
|
||||
result.token = alias.token || null;
|
||||
result.scssVar = alias.scss_var || null;
|
||||
result.confidence = 'approximate';
|
||||
if (alias.note) result.note = alias.note;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用于值匹配的候选 token 列表
|
||||
* 根据 CSS 属性确定查找类别
|
||||
*/
|
||||
function getCandidatesByProperty(propertyName, registry) {
|
||||
const category = CSS_PROPERTY_CATEGORIES[propertyName];
|
||||
|
||||
if (!category) {
|
||||
// 未知属性,在所有 token 中搜索
|
||||
return registry.all;
|
||||
}
|
||||
|
||||
if (category === 'color') {
|
||||
// color 类同时搜索 colors + unmapped_scss_variables
|
||||
return [
|
||||
...(registry.byCategory.color || []),
|
||||
...(registry.byCategory.shadow || []), // 阴影也含颜色信息
|
||||
];
|
||||
}
|
||||
|
||||
if (category === 'spacing') {
|
||||
return [
|
||||
...(registry.byCategory.spacing || []),
|
||||
...(registry.byCategory.tag || []), // tag 也有 padding
|
||||
];
|
||||
}
|
||||
|
||||
if (category === 'typography') {
|
||||
return registry.byCategory.typography || [];
|
||||
}
|
||||
|
||||
if (category === 'radius') {
|
||||
return registry.byCategory.radius || [];
|
||||
}
|
||||
|
||||
if (category === 'sizing') {
|
||||
return [
|
||||
...(registry.byCategory.sizing || []),
|
||||
...(registry.byCategory.feedback || []),
|
||||
];
|
||||
}
|
||||
|
||||
return registry.byCategory[category] || registry.all;
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer 2: 值精确匹配(带 CSS 属性上下文消歧)
|
||||
* @param {string} value - 原型中的值
|
||||
* @param {string|null} cssProperty - 关联的 CSS 属性名(用于消歧)
|
||||
* @param {object} registry - Token 注册表
|
||||
* @returns {object|null} 匹配结果
|
||||
*/
|
||||
function matchByValue(value, cssProperty, registry) {
|
||||
if (value == null) return null;
|
||||
|
||||
const candidates = getCandidatesByProperty(cssProperty, registry);
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
// 颜色匹配
|
||||
if (isHexColor(value)) {
|
||||
const normalizedInput = normalizeColor(value);
|
||||
if (!normalizedInput) return null;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.value) continue;
|
||||
if (!isHexColor(candidate.value)) continue;
|
||||
const normalizedCandidate = normalizeColor(candidate.value);
|
||||
if (normalizedCandidate === normalizedInput) {
|
||||
return buildValueMatchResult(candidate, value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 数值匹配(含 px 后缀)
|
||||
const normalizedInput = normalizeNumericValue(value);
|
||||
if (normalizedInput === null) {
|
||||
// rem/em — 标记为 pending
|
||||
return {
|
||||
token: null,
|
||||
prototypeValue: value,
|
||||
method: 'value_exact',
|
||||
confidence: 'pending',
|
||||
note: 'rem/em 值无法自动匹配,需手动确认',
|
||||
};
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.value) continue;
|
||||
const normalizedCandidate = normalizeNumericValue(candidate.value);
|
||||
if (normalizedCandidate === null) continue;
|
||||
if (normalizedCandidate === normalizedInput) {
|
||||
return buildValueMatchResult(candidate, value);
|
||||
}
|
||||
}
|
||||
|
||||
// 无单位数值直接比较(如 lineHeight: 1.5)
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.value) continue;
|
||||
if (String(candidate.value).trim() === String(value).trim()) {
|
||||
return buildValueMatchResult(candidate, value);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建值匹配结果
|
||||
*/
|
||||
function buildValueMatchResult(candidate, prototypeValue) {
|
||||
const result = {
|
||||
token: candidate.token || null,
|
||||
scssVar: candidate.scssVar || null,
|
||||
prototypeValue,
|
||||
tokenValue: candidate.value,
|
||||
method: 'value_exact',
|
||||
confidence: candidate.token ? 'confirmed' : 'pending',
|
||||
};
|
||||
if (candidate.role) result.role = candidate.role;
|
||||
if (!candidate.token && candidate.scssVar) {
|
||||
result.note = `匹配到 SCSS 变量 ${candidate.scssVar},但无对应 CSS Token`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Layer 3: 色彩模糊匹配(RGB 欧几里得距离)
|
||||
* @param {string} value - hex 颜色值
|
||||
* @param {object} registry - Token 注册表
|
||||
* @param {number} threshold - 距离阈值,默认 30
|
||||
* @returns {object|null} 最佳近似匹配
|
||||
*/
|
||||
function matchByColorFuzzy(value, registry, threshold = 30) {
|
||||
const inputRgb = hexToRgb(value);
|
||||
if (!inputRgb) return null;
|
||||
|
||||
// 在所有颜色类 token 中搜索
|
||||
const colorCandidates = [
|
||||
...(registry.byCategory.color || []),
|
||||
];
|
||||
|
||||
let bestMatch = null;
|
||||
let bestDistance = Infinity;
|
||||
|
||||
for (const candidate of colorCandidates) {
|
||||
if (!candidate.value) continue;
|
||||
const candidateRgb = hexToRgb(candidate.value);
|
||||
if (!candidateRgb) continue;
|
||||
|
||||
const dist = colorDistance(inputRgb, candidateRgb);
|
||||
if (dist < bestDistance) {
|
||||
bestDistance = dist;
|
||||
bestMatch = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch && bestDistance < threshold) {
|
||||
return {
|
||||
token: bestMatch.token || null,
|
||||
scssVar: bestMatch.scssVar || null,
|
||||
prototypeValue: value,
|
||||
tokenValue: bestMatch.value,
|
||||
method: 'color_fuzzy',
|
||||
confidence: 'approximate',
|
||||
distance: Math.round(bestDistance * 100) / 100,
|
||||
note: `颜色近似匹配(RGB 距离 ${Math.round(bestDistance * 100) / 100})`,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对单个值执行完整三层匹配
|
||||
* @param {string} value - 要匹配的值
|
||||
* @param {string|null} cssProperty - CSS 属性名(消歧用)
|
||||
* @param {object} registry - Token 注册表
|
||||
* @returns {object} 匹配结果
|
||||
*/
|
||||
function fullMatchValue(value, cssProperty, registry) {
|
||||
// Layer 2: 值精确匹配
|
||||
const exact = matchByValue(value, cssProperty, registry);
|
||||
if (exact) return exact;
|
||||
|
||||
// Layer 3: 颜色模糊匹配(仅对颜色值)
|
||||
if (isHexColor(value)) {
|
||||
const fuzzy = matchByColorFuzzy(value, registry);
|
||||
if (fuzzy) return fuzzy;
|
||||
}
|
||||
|
||||
// 未匹配
|
||||
return {
|
||||
token: null,
|
||||
scssVar: null,
|
||||
prototypeValue: value,
|
||||
method: 'none',
|
||||
confidence: 'unmatched',
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 主流程
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length < 2) {
|
||||
process.stderr.write('用法: node match-tokens.mjs <parse-result.json> <tokens.yml>\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [parseResultPath, tokensYmlPath] = args;
|
||||
|
||||
// 读取输入文件
|
||||
let parseResult;
|
||||
try {
|
||||
const raw = readFileSync(resolve(parseResultPath), 'utf-8');
|
||||
parseResult = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
process.stderr.write(`无法读取/解析 parse-result JSON: ${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let tokensConfig;
|
||||
try {
|
||||
const raw = readFileSync(resolve(tokensYmlPath), 'utf-8');
|
||||
tokensConfig = yaml.load(raw, { schema: yaml.JSON_SCHEMA });
|
||||
} catch (err) {
|
||||
process.stderr.write(`无法读取/解析 tokens.yml: ${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 构建 Token 注册表
|
||||
const registry = buildTokenRegistry(tokensConfig);
|
||||
const aliases = tokensConfig.aliases || null;
|
||||
|
||||
// ---- 匹配原型 Token 别名 ----
|
||||
const matched = {};
|
||||
const unmatched = [];
|
||||
|
||||
const prototypeTokens = parseResult.tokens || {};
|
||||
for (const [key, value] of Object.entries(prototypeTokens)) {
|
||||
// Layer 1: 别名直查
|
||||
const aliasResult = matchByAlias(key, aliases);
|
||||
if (aliasResult) {
|
||||
matched[key] = {
|
||||
...aliasResult,
|
||||
prototypeValue: value,
|
||||
// 如果 alias 没有覆盖 tokenValue,从 token 注册表中查找
|
||||
tokenValue: aliasResult.tokenValue || null,
|
||||
};
|
||||
// 补充 tokenValue
|
||||
if (aliasResult.token && !matched[key].tokenValue) {
|
||||
const found = registry.all.find(t => t.token === aliasResult.token);
|
||||
if (found) matched[key].tokenValue = found.value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 无别名,尝试值匹配(需要推断 CSS 属性上下文)
|
||||
const inferredProperty = inferPropertyFromTokenKey(key);
|
||||
const valueResult = fullMatchValue(value, inferredProperty, registry);
|
||||
if (valueResult.confidence !== 'unmatched') {
|
||||
matched[key] = valueResult;
|
||||
} else {
|
||||
unmatched.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 匹配内联样式值 ----
|
||||
const inlineTokenMap = {};
|
||||
const inlineStyles = parseResult.inlineStyles || {};
|
||||
|
||||
for (const [cssProperty, values] of Object.entries(inlineStyles)) {
|
||||
for (const rawValue of values) {
|
||||
const mapKey = `${cssProperty}:${rawValue}`;
|
||||
const result = fullMatchValue(rawValue, cssProperty, registry);
|
||||
if (result.confidence !== 'unmatched') {
|
||||
inlineTokenMap[mapKey] = {
|
||||
token: result.token,
|
||||
scssVar: result.scssVar || undefined,
|
||||
tokenValue: result.tokenValue || undefined,
|
||||
confidence: result.confidence,
|
||||
method: result.method,
|
||||
};
|
||||
if (result.note) inlineTokenMap[mapKey].note = result.note;
|
||||
if (result.distance != null) inlineTokenMap[mapKey].distance = result.distance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 汇总统计 ----
|
||||
let confirmed = 0;
|
||||
let pending = 0;
|
||||
let approximate = 0;
|
||||
|
||||
for (const entry of Object.values(matched)) {
|
||||
if (entry.confidence === 'confirmed') confirmed++;
|
||||
else if (entry.confidence === 'pending') pending++;
|
||||
else if (entry.confidence === 'approximate') approximate++;
|
||||
}
|
||||
|
||||
for (const entry of Object.values(inlineTokenMap)) {
|
||||
if (entry.confidence === 'confirmed') confirmed++;
|
||||
else if (entry.confidence === 'pending') pending++;
|
||||
else if (entry.confidence === 'approximate') approximate++;
|
||||
}
|
||||
|
||||
const totalAlias = Object.keys(prototypeTokens).length;
|
||||
const totalInline = Object.values(inlineStyles).reduce((sum, arr) => sum + arr.length, 0);
|
||||
|
||||
// ---- 输出 ----
|
||||
const output = {
|
||||
source: parseResult.source || null,
|
||||
matched,
|
||||
unmatched,
|
||||
inlineTokenMap,
|
||||
summary: {
|
||||
total: totalAlias + totalInline,
|
||||
aliasTokens: totalAlias,
|
||||
inlineValues: totalInline,
|
||||
confirmed,
|
||||
pending,
|
||||
approximate,
|
||||
unmatched: unmatched.length,
|
||||
},
|
||||
};
|
||||
|
||||
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Token 别名 key 推断 CSS 属性(用于无别名时的消歧)
|
||||
*/
|
||||
function inferPropertyFromTokenKey(key) {
|
||||
const lower = key.toLowerCase();
|
||||
|
||||
if (lower.includes('pri') || lower.includes('color') || lower.includes('bg') ||
|
||||
lower.includes('tx') || lower.includes('bd') || lower.includes('card') ||
|
||||
lower.includes('surface') || lower.includes('acc') || lower.includes('dan') ||
|
||||
lower.includes('wrn') || lower.includes('wechat')) {
|
||||
return 'color';
|
||||
}
|
||||
if (lower.includes('font') || lower.includes('text') && !lower.includes('color')) {
|
||||
return 'fontSize';
|
||||
}
|
||||
if (lower.includes('r') && (lower.includes('sm') || lower.includes('xs') || lower.length <= 3)) {
|
||||
return 'borderRadius';
|
||||
}
|
||||
if (lower.includes('gap') || lower.includes('pad') || lower.includes('margin')) {
|
||||
return 'padding';
|
||||
}
|
||||
|
||||
return null; // 未知,不限制类别
|
||||
}
|
||||
|
||||
// 启动
|
||||
main();
|
||||
385
.claude/skills/design-handoff/scripts/parse-prototype.mjs
Normal file
385
.claude/skills/design-handoff/scripts/parse-prototype.mjs
Normal file
@@ -0,0 +1,385 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* parse-prototype.mjs — 解析 huashu-design 产出的 HTML 原型文件
|
||||
*
|
||||
* 提取设计 Token (T 对象)、内联样式硬编码值、屏幕信息和组件定义。
|
||||
* 输出 JSON 到 stdout。
|
||||
*
|
||||
* 用法: node parse-prototype.mjs <html-file-path>
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// ─── 工具函数 ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 输出错误 JSON 到 stderr 并以 code 1 退出
|
||||
*/
|
||||
function fail(message) {
|
||||
process.stderr.write(JSON.stringify({ valid: false, error: message }, null, 2) + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 括号深度计数法:从 source[startPos] 开始(startPos 应指向 '{'),
|
||||
* 提取完整的平衡花括号内容。
|
||||
*
|
||||
* 正确处理:
|
||||
* - 单引号/双引号内的花括号不计数
|
||||
* - 模板字符串(`)内的花括号不计数
|
||||
* - 转义字符(\" \' \\ \x \u)跳过
|
||||
*
|
||||
* 返回包含外层花括号的完整字符串,或在无法平衡时返回 null。
|
||||
*/
|
||||
function extractBalancedBraces(source, startPos) {
|
||||
if (source[startPos] !== '{') return null;
|
||||
|
||||
let depth = 0;
|
||||
let i = startPos;
|
||||
const len = source.length;
|
||||
|
||||
while (i < len) {
|
||||
const ch = source[i];
|
||||
|
||||
if (ch === "'" || ch === '"') {
|
||||
// 字符串字面量:跳到配对的结束引号
|
||||
const quote = ch;
|
||||
i++;
|
||||
while (i < len) {
|
||||
if (source[i] === '\\') {
|
||||
i += 2; // 跳过转义字符
|
||||
continue;
|
||||
}
|
||||
if (source[i] === quote) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '`') {
|
||||
// 模板字符串:处理 ${...} 内的嵌套,以及转义
|
||||
i++;
|
||||
while (i < len) {
|
||||
if (source[i] === '\\') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (source[i] === '`') {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
if (source[i] === '$' && i + 1 < len && source[i + 1] === '{') {
|
||||
// 模板表达式 ${...},需要单独平衡
|
||||
i += 2; // 跳过 ${
|
||||
let tmplDepth = 1;
|
||||
while (i < len && tmplDepth > 0) {
|
||||
const tc = source[i];
|
||||
if (tc === '{') tmplDepth++;
|
||||
else if (tc === '}') tmplDepth--;
|
||||
// 简化:模板表达式内也可以有字符串,但这里不递归处理
|
||||
// 因为 T 对象的值不太可能有如此复杂的嵌套
|
||||
if (tc === "'" || tc === '"') {
|
||||
const tq = tc;
|
||||
i++;
|
||||
while (i < len) {
|
||||
if (source[i] === '\\') { i += 2; continue; }
|
||||
if (source[i] === tq) { i++; break; }
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (tc === '`') {
|
||||
// 嵌套模板字符串
|
||||
i++;
|
||||
let nestedTmplDepth = 0;
|
||||
while (i < len) {
|
||||
if (source[i] === '\\') { i += 2; continue; }
|
||||
if (source[i] === '`') { i++; break; }
|
||||
if (source[i] === '$' && i + 1 < len && source[i + 1] === '{') {
|
||||
// 这里简化处理,不递归
|
||||
i += 2;
|
||||
nestedTmplDepth++;
|
||||
continue;
|
||||
}
|
||||
if (source[i] === '{') nestedTmplDepth++;
|
||||
if (source[i] === '}') {
|
||||
if (nestedTmplDepth > 0) nestedTmplDepth--;
|
||||
else { i++; break; }
|
||||
}
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '/' && i + 1 < len) {
|
||||
// 注释处理
|
||||
if (source[i + 1] === '/') {
|
||||
// 单行注释
|
||||
while (i < len && source[i] !== '\n') i++;
|
||||
continue;
|
||||
}
|
||||
if (source[i + 1] === '*') {
|
||||
// 多行注释
|
||||
i += 2;
|
||||
while (i + 1 < len && !(source[i] === '*' && source[i + 1] === '/')) i++;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
return source.slice(startPos, i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return null; // 未平衡
|
||||
}
|
||||
|
||||
// ─── Step 0: 格式校验 ────────────────────────────────────────
|
||||
|
||||
function validateSource(source) {
|
||||
const hasReact = source.includes('react@') || source.includes('react.production');
|
||||
const hasBabel = source.includes('@babel/standalone') || source.includes('babel.min');
|
||||
const hasToken = /const\s+T\s*=\s*\{/.test(source);
|
||||
|
||||
if (!hasReact) return '文件不包含 React 引用';
|
||||
if (!hasBabel) return '文件不包含 Babel 引用';
|
||||
if (!hasToken) return '文件不包含 T 对象定义 (const T = {)';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Step 1: 提取 T 对象 ─────────────────────────────────────
|
||||
|
||||
function extractTokens(source) {
|
||||
// 匹配 const T = { / const T ={ / const T={ 等各种空格变体
|
||||
const markerPattern = /const\s+T\s*=\s*\{/;
|
||||
const markerMatch = markerPattern.exec(source);
|
||||
if (!markerMatch) return {};
|
||||
|
||||
const braceStart = markerMatch.index + markerMatch[0].length - 1; // 指向 '{'
|
||||
const rawObject = extractBalancedBraces(source, braceStart);
|
||||
|
||||
if (!rawObject) return {};
|
||||
|
||||
// 将 JS 对象字面量转为可求值字符串,用 Function 构造器执行
|
||||
try {
|
||||
const fn = new Function('return (' + rawObject + ')');
|
||||
const tObj = fn();
|
||||
|
||||
// 展平为 "T.key": "value" 格式
|
||||
const flat = {};
|
||||
for (const [key, value] of Object.entries(tObj)) {
|
||||
flat[`T.${key}`] = String(value);
|
||||
}
|
||||
return flat;
|
||||
} catch {
|
||||
// 如果 Function 执行失败,尝试用正则提取简单键值对作为降级方案
|
||||
const fallback = {};
|
||||
const kvPattern = /(\w+)\s*:\s*'([^']*)'/g;
|
||||
const kvPattern2 = /(\w+)\s*:\s*"([^"]*)"/g;
|
||||
const kvPatternNum = /(\w+)\s*:\s*(\d+(?:\.\d+)?)/g;
|
||||
|
||||
let match;
|
||||
const innerContent = rawObject.slice(1, -1);
|
||||
while ((match = kvPattern.exec(innerContent)) !== null) {
|
||||
fallback[`T.${match[1]}`] = match[2];
|
||||
}
|
||||
while ((match = kvPattern2.exec(innerContent)) !== null) {
|
||||
fallback[`T.${match[1]}`] = match[2];
|
||||
}
|
||||
while ((match = kvPatternNum.exec(innerContent)) !== null) {
|
||||
fallback[`T.${match[1]}`] = match[2];
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Step 2: 提取内联样式硬编码值 ─────────────────────────────
|
||||
|
||||
const STYLE_PROPERTIES = ['fontSize', 'padding', 'borderRadius', 'gap', 'margin', 'fontWeight', 'lineHeight', 'width', 'height', 'letterSpacing'];
|
||||
|
||||
function extractInlineStyles(source) {
|
||||
const result = {};
|
||||
|
||||
for (const prop of STYLE_PROPERTIES) {
|
||||
const values = new Set();
|
||||
|
||||
// 匹配 camelCase 属性: fontSize: 16, fontSize: '16px', fontSize: "16px"
|
||||
// 也匹配 object shorthand: { fontSize: 16 }
|
||||
const pattern = new RegExp(
|
||||
prop + '\\s*:\\s*([\'"`]?)([\\w\\s%.,()-]+?)\\1\\s*[,}]',
|
||||
'g'
|
||||
);
|
||||
|
||||
let match;
|
||||
while ((match = pattern.exec(source)) !== null) {
|
||||
let val = match[2].trim();
|
||||
// 过滤掉变量引用(如 T.pri, T.r 等)
|
||||
if (val.startsWith('T.') || val.startsWith('${')) continue;
|
||||
// 过滤模板字符串插值
|
||||
if (val.includes('${')) continue;
|
||||
// 过滤掉 CSS 变量引用
|
||||
if (val.startsWith('var(')) continue;
|
||||
values.add(val);
|
||||
}
|
||||
|
||||
if (values.size > 0) {
|
||||
// 去重并排序:数值优先(降序),字符串按字母序
|
||||
const sorted = [...values].sort((a, b) => {
|
||||
const numA = parseFloat(a);
|
||||
const numB = parseFloat(b);
|
||||
if (!isNaN(numA) && !isNaN(numB)) return numB - numA;
|
||||
if (!isNaN(numA)) return -1;
|
||||
if (!isNaN(numB)) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
result[prop] = sorted;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Step 3: 提取 screen 信息 ────────────────────────────────
|
||||
|
||||
function extractScreens(source) {
|
||||
const screens = [];
|
||||
|
||||
// 策略:找所有 screen-wrap 块,从中提取 screen-label 文本和组件名
|
||||
// 两种形式:
|
||||
// 1) <div className="screen-wrap"> ... <span className="screen-label">文本</span> ... <IosFrame> ... </div>
|
||||
// 2) <div class="screen-wrap"> ... <span class="screen-label">文本</span> ... <IosFrame> ... </div>
|
||||
|
||||
// 匹配所有 screen-wrap 块
|
||||
const wrapPattern = /<div\s+(?:className|class)="screen-wrap"[^>]*>([\s\S]*?)<\/div>\s*(?=<div\s+(?:className|class)="screen-wrap"|<\/div>\s*<\/div>|\s*$)/g;
|
||||
|
||||
// 更可靠的方式:先找所有 screen-label,再在上下文中找组件
|
||||
// 因为 screen-wrap 的 HTML 嵌套可能很复杂
|
||||
|
||||
// 提取所有 screen-label 文本
|
||||
const labelPattern = /<(?:span|div)\s+(?:className|class)="screen-label"[^>]*>([^<]*)<\/(?:span|div)>/g;
|
||||
const labels = [];
|
||||
let labelMatch;
|
||||
while ((labelMatch = labelPattern.exec(source)) !== null) {
|
||||
labels.push(labelMatch[1].trim());
|
||||
}
|
||||
|
||||
// 提取所有 screen-wrap 内的 IosFrame children 组件
|
||||
// 模式: <IosFrame ...>\n <ComponentName />\n</IosFrame>
|
||||
// 或: <IosFrame ...><ComponentName /></IosFrame>
|
||||
const iosFramePattern = /<IosFrame[^>]*>\s*(?:<React\.Fragment[^>]*>)?\s*<(\w+)\s*\/?>/g;
|
||||
const components = [];
|
||||
let compMatch;
|
||||
while ((compMatch = iosFramePattern.exec(source)) !== null) {
|
||||
const name = compMatch[1];
|
||||
// 排除 HTML 标签和 React 内置
|
||||
if (name !== 'div' && name !== 'span' && name[0] === name[0].toUpperCase()) {
|
||||
components.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
// 将 labels 和 components 配对
|
||||
const count = Math.max(labels.length, components.length);
|
||||
for (let i = 0; i < count; i++) {
|
||||
screens.push({
|
||||
label: labels[i] || '',
|
||||
component: components[i] || '',
|
||||
});
|
||||
}
|
||||
|
||||
return screens;
|
||||
}
|
||||
|
||||
// ─── Step 4: 识别组件函数 ─────────────────────────────────────
|
||||
|
||||
function extractComponents(source) {
|
||||
const components = new Set();
|
||||
|
||||
// 匹配 function 声明: function XxxPage() / function Xxx()
|
||||
const funcPattern = /function\s+([A-Z]\w*)\s*\(/g;
|
||||
let match;
|
||||
while ((match = funcPattern.exec(source)) !== null) {
|
||||
components.add(match[1]);
|
||||
}
|
||||
|
||||
// 匹配 const Xxx = () => / const Xxx = () =>
|
||||
const arrowPattern = /const\s+([A-Z]\w*)\s*=\s*(?:\([^)]*\)|[^=])\s*=>/g;
|
||||
while ((match = arrowPattern.exec(source)) !== null) {
|
||||
components.add(match[1]);
|
||||
}
|
||||
|
||||
return [...components].sort((a, b) => {
|
||||
// IosFrame 排到最前面(基础设施组件)
|
||||
if (a === 'IosFrame') return -1;
|
||||
if (b === 'IosFrame') return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 主流程 ──────────────────────────────────────────────────
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 1) {
|
||||
fail('用法: node parse-prototype.mjs <html-file-path>');
|
||||
}
|
||||
|
||||
const filePath = resolve(args[0]);
|
||||
|
||||
let source;
|
||||
try {
|
||||
source = readFileSync(filePath, 'utf-8');
|
||||
} catch (err) {
|
||||
fail(`无法读取文件: ${filePath} — ${err.message}`);
|
||||
}
|
||||
|
||||
// Step 0: 格式校验
|
||||
const validationError = validateSource(source);
|
||||
if (validationError) {
|
||||
fail(`格式校验失败: ${validationError}`);
|
||||
}
|
||||
|
||||
// Step 1: 提取 T 对象
|
||||
const tokens = extractTokens(source);
|
||||
|
||||
// Step 2: 提取内联样式
|
||||
const inlineStyles = extractInlineStyles(source);
|
||||
|
||||
// Step 3: 提取 screen 信息
|
||||
const screens = extractScreens(source);
|
||||
|
||||
// Step 4: 识别组件函数
|
||||
const components = extractComponents(source);
|
||||
|
||||
// 组装输出
|
||||
const result = {
|
||||
valid: true,
|
||||
filePath: filePath.replace(/\\/g, '/'),
|
||||
tokens,
|
||||
inlineStyles,
|
||||
screens,
|
||||
components,
|
||||
};
|
||||
|
||||
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. "需新建"的组件参考截图和布局描述从头实现
|
||||
405
.design/tokens.yml
Normal file
405
.design/tokens.yml
Normal file
@@ -0,0 +1,405 @@
|
||||
version: 1
|
||||
updated: "2026-05-17"
|
||||
|
||||
# ============================================================================
|
||||
# Design Token 注册表
|
||||
# 数据源: apps/miniprogram/src/styles/tokens.scss + variables.scss
|
||||
# ============================================================================
|
||||
|
||||
colors:
|
||||
# --- 主色系(赤土橙) ---
|
||||
- token: --tk-pri
|
||||
value: "#C4623A"
|
||||
scss_var: "$pri"
|
||||
role: 主色/赤土橙 accent
|
||||
|
||||
- token: --tk-pri-l
|
||||
value: "#F0DDD4"
|
||||
scss_var: "$pri-l"
|
||||
role: 主色浅/赤土浅
|
||||
|
||||
- token: --tk-pri-d
|
||||
value: "#8B3E1F"
|
||||
scss_var: "$pri-d"
|
||||
role: 主色深/赤土深
|
||||
|
||||
# --- 阴影色(含透明度) ---
|
||||
- token: --tk-shadow-btn
|
||||
value: "0 4px 16px rgba(196, 98, 58, 0.3)"
|
||||
scss_var: "$shadow-btn"
|
||||
role: 主按钮阴影
|
||||
|
||||
- token: --tk-shadow-tab
|
||||
value: "0 2px 8px rgba(196, 98, 58, 0.25)"
|
||||
scss_var: "$shadow-tab"
|
||||
role: 选中Tab阴影
|
||||
|
||||
# --- 文字色 ---
|
||||
- token: --tk-text-secondary
|
||||
value: "#78716C"
|
||||
scss_var: "$tx3"
|
||||
role: 淡文字/辅助文字
|
||||
|
||||
# --- 卡片背景 ---
|
||||
- token: --tk-card-bg
|
||||
value: "#FFFFFF"
|
||||
scss_var: "$card"
|
||||
role: 卡片白底
|
||||
|
||||
# --- 医生端覆盖色(.doctor-mode 下自动替换 --tk-pri*) ---
|
||||
- token: --tk-pri.doctor
|
||||
value: "#3A6B8C"
|
||||
scss_var: "$doc-pri"
|
||||
role: 医生端主色/靛蓝
|
||||
note: 仅在 .doctor-mode 下覆盖 --tk-pri
|
||||
|
||||
- token: --tk-pri-l.doctor
|
||||
value: "#D4E5F0"
|
||||
scss_var: "$doc-pri-l"
|
||||
role: 医生端浅色
|
||||
|
||||
- token: --tk-pri-d.doctor
|
||||
value: "#2A4F6A"
|
||||
scss_var: "$doc-pri-d"
|
||||
role: 医生端深色
|
||||
|
||||
# --- 未映射到 Token 的 SCSS 变量(原型中有但 tokens.scss 未声明为 CSS 变量) ---
|
||||
unmapped_scss_variables:
|
||||
- scss_var: "$bg"
|
||||
value: "#F5F0EB"
|
||||
role: 页面主背景/温润米底
|
||||
note: "原型 T.bg 映射目标,tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
- scss_var: "$tx"
|
||||
value: "#2D2A26"
|
||||
role: 主文字色/暖黑
|
||||
note: "原型 T.tx 映射目标,tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
- scss_var: "$tx2"
|
||||
value: "#5A554F"
|
||||
role: 次文字色/暖灰
|
||||
note: "原型 T.tx2 近似映射,elder-mode 下 --tk-text-secondary 覆盖为此值"
|
||||
|
||||
- scss_var: "$bd"
|
||||
value: "#E8E2DC"
|
||||
role: 边框色
|
||||
note: "原型 T.bd 映射目标,tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
- scss_var: "$bd-l"
|
||||
value: "#F0EBE5"
|
||||
role: 浅边框色
|
||||
|
||||
- scss_var: "$acc"
|
||||
value: "#5B7A5E"
|
||||
role: 鼠尾草绿/成功色
|
||||
note: "原型中成功色,tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
- scss_var: "$acc-l"
|
||||
value: "#E8F0E8"
|
||||
role: 成功浅色
|
||||
|
||||
- scss_var: "$dan"
|
||||
value: "#B54A4A"
|
||||
role: 危险色/柔红
|
||||
note: "原型中危险色,tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
- scss_var: "$dan-l"
|
||||
value: "#FDEAEA"
|
||||
role: 危险浅色
|
||||
|
||||
- scss_var: "$wrn"
|
||||
value: "#C4873A"
|
||||
role: 警告色/暖琥珀
|
||||
note: "原型中警告色,tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
- scss_var: "$wrn-l"
|
||||
value: "#FFF3E0"
|
||||
role: 警告浅色
|
||||
|
||||
- scss_var: "$surface-alt"
|
||||
value: "#EDE8E2"
|
||||
role: 辅助底色
|
||||
|
||||
- scss_var: "$wechat"
|
||||
value: "#07C160"
|
||||
role: 微信绿
|
||||
|
||||
typography:
|
||||
- token: --tk-font-display
|
||||
value: "72px"
|
||||
note: 大屏展示
|
||||
elder: "80px"
|
||||
|
||||
- token: --tk-font-hero
|
||||
value: "48px"
|
||||
note: 启动页标题
|
||||
elder: "56px"
|
||||
|
||||
- token: --tk-font-h1
|
||||
value: "28px"
|
||||
note: 页面标题 serif bold
|
||||
elder: "32px"
|
||||
|
||||
- token: --tk-font-h2
|
||||
value: "22px"
|
||||
note: 副标题、用户名 serif bold
|
||||
elder: "25px"
|
||||
|
||||
- token: --tk-font-body-lg
|
||||
value: "18px"
|
||||
note: 按钮文字、section 标题 fontWeight:600
|
||||
elder: "22px"
|
||||
|
||||
- token: --tk-font-body
|
||||
value: "16px"
|
||||
note: 正文、输入框、icon 文字(最常用 UI 字号)
|
||||
elder: "22px"
|
||||
|
||||
- token: --tk-font-body-sm
|
||||
value: "14px"
|
||||
note: 副文本、描述
|
||||
elder: "19px"
|
||||
|
||||
- token: --tk-font-num
|
||||
value: "30px"
|
||||
note: 数值 serif bold
|
||||
elder: "34px"
|
||||
|
||||
- token: --tk-font-num-lg
|
||||
value: "34px"
|
||||
note: 大数值
|
||||
elder: "40px"
|
||||
|
||||
- token: --tk-font-cap
|
||||
value: "13px"
|
||||
note: 说明文字(第一高频字号)
|
||||
elder: "18px"
|
||||
|
||||
- token: --tk-font-nav
|
||||
value: "18px"
|
||||
note: 导航栏标题 serif bold
|
||||
elder: "22px"
|
||||
|
||||
- token: --tk-font-micro
|
||||
value: "11px"
|
||||
note: 角标、tag
|
||||
elder: "17px"
|
||||
|
||||
structure:
|
||||
- token: --tk-line-height
|
||||
value: "1.5"
|
||||
elder: "1.7"
|
||||
|
||||
spacing:
|
||||
- token: --tk-gap-2xs
|
||||
value: "4px"
|
||||
scss_var: "$sp-2xs"
|
||||
elder: "6px"
|
||||
|
||||
- token: --tk-gap-xs
|
||||
value: "8px"
|
||||
scss_var: "$sp-xs"
|
||||
elder: "12px"
|
||||
|
||||
- token: --tk-gap-sm
|
||||
value: "12px"
|
||||
scss_var: "$sp-sm"
|
||||
elder: "16px"
|
||||
|
||||
- token: --tk-gap-md
|
||||
value: "16px"
|
||||
scss_var: "$sp-md"
|
||||
elder: "20px"
|
||||
|
||||
- token: --tk-section-gap
|
||||
value: "20px"
|
||||
scss_var: "$sp-section"
|
||||
elder: "28px"
|
||||
|
||||
- token: --tk-gap-lg
|
||||
value: "24px"
|
||||
scss_var: "$sp-lg"
|
||||
elder: "32px"
|
||||
|
||||
- token: --tk-gap-xl
|
||||
value: "32px"
|
||||
scss_var: "$sp-xl"
|
||||
elder: "40px"
|
||||
|
||||
- token: --tk-gap-2xl
|
||||
value: "48px"
|
||||
scss_var: "$sp-2xl"
|
||||
elder: "56px"
|
||||
|
||||
- token: --tk-page-padding
|
||||
value: "20px"
|
||||
elder: "28px"
|
||||
|
||||
- token: --tk-card-padding
|
||||
value: "20px"
|
||||
elder: "28px"
|
||||
|
||||
- token: --tk-card-padding-sm
|
||||
value: "16px"
|
||||
elder: "20px"
|
||||
|
||||
- token: --tk-card-padding-lg
|
||||
value: "28px"
|
||||
elder: "36px"
|
||||
|
||||
radius:
|
||||
- token: --tk-card-radius
|
||||
value: "16px"
|
||||
scss_var: "$r"
|
||||
elder: "20px"
|
||||
|
||||
radius_unmapped:
|
||||
- scss_var: "$r-sm"
|
||||
value: "12px"
|
||||
note: "原型 T.rSm 映射目标"
|
||||
- scss_var: "$r-xs"
|
||||
value: "8px"
|
||||
note: "原型 T.rXs 映射目标"
|
||||
- scss_var: "$r-lg"
|
||||
value: "20px"
|
||||
- scss_var: "$r-pill"
|
||||
value: "999px"
|
||||
|
||||
sizing:
|
||||
- token: --tk-touch-min
|
||||
value: "48px"
|
||||
role: 最小触控区
|
||||
elder: "56px"
|
||||
|
||||
- token: --tk-btn-primary-h
|
||||
value: "52px"
|
||||
role: 主按钮高度
|
||||
elder: "60px"
|
||||
|
||||
- token: --tk-input-height
|
||||
value: "56px"
|
||||
role: 输入框高度
|
||||
elder: "64px"
|
||||
|
||||
- token: --tk-tabbar-space
|
||||
value: "100px"
|
||||
role: TabBar 底部安全区
|
||||
elder: "120px"
|
||||
|
||||
feedback:
|
||||
- token: --tk-touch-feedback-opacity
|
||||
value: "0.85"
|
||||
role: 触控反馈透明度
|
||||
elder: "0.8"
|
||||
|
||||
tag:
|
||||
- token: --tk-tag-font-size
|
||||
value: "11px"
|
||||
elder: "13px"
|
||||
|
||||
- token: --tk-tag-padding-v
|
||||
value: "3px"
|
||||
elder: "5px"
|
||||
|
||||
- token: --tk-tag-padding-h
|
||||
value: "8px"
|
||||
elder: "12px"
|
||||
|
||||
shadow_unmapped:
|
||||
# tokens.scss 中的 --tk-shadow-btn/tab 是复合值(含偏移+模糊+颜色)
|
||||
# 以下为 variables.scss 中的其他阴影,未声明为 CSS Token
|
||||
- scss_var: "$shadow-sm"
|
||||
value: "0 1px 4px rgba(45, 42, 38, 0.06)"
|
||||
role: 小阴影
|
||||
|
||||
- scss_var: "$shadow-md"
|
||||
value: "0 2px 12px rgba(45, 42, 38, 0.10)"
|
||||
role: 中阴影
|
||||
|
||||
- scss_var: "$shadow-lg"
|
||||
value: "0 8px 32px rgba(45, 42, 38, 0.15)"
|
||||
role: 大阴影
|
||||
|
||||
# ============================================================================
|
||||
# 原型 Key → Token 映射(aliases)
|
||||
# 用于设计移交时自动匹配原型属性到实际 Token
|
||||
# ============================================================================
|
||||
aliases:
|
||||
prototype_keys:
|
||||
T.pri:
|
||||
token: --tk-pri
|
||||
status: exact_match
|
||||
|
||||
T.priL:
|
||||
token: --tk-pri-l
|
||||
status: exact_match
|
||||
|
||||
T.priD:
|
||||
token: --tk-pri-d
|
||||
status: exact_match
|
||||
|
||||
T.bg:
|
||||
token: null
|
||||
nearest: --tk-card-bg
|
||||
scss_var: "$bg"
|
||||
value: "#F5F0EB"
|
||||
status: unmatched
|
||||
note: "原型页面背景色,tokens.scss 未声明为 CSS 变量,直接用 $bg SCSS 变量"
|
||||
|
||||
T.card:
|
||||
token: --tk-card-bg
|
||||
status: exact_match
|
||||
|
||||
T.surface:
|
||||
token: --tk-card-bg
|
||||
status: approximate
|
||||
note: "原型中 surface ≈ 卡片白底"
|
||||
|
||||
T.tx:
|
||||
token: null
|
||||
nearest: --tk-text-secondary
|
||||
scss_var: "$tx"
|
||||
value: "#2D2A26"
|
||||
status: unmatched
|
||||
note: "主文字色,tokens.scss 未声明为 CSS 变量,直接用 $tx SCSS 变量"
|
||||
|
||||
T.tx2:
|
||||
token: null
|
||||
nearest: --tk-text-secondary
|
||||
scss_var: "$tx2"
|
||||
value: "#5A554F"
|
||||
status: unmatched
|
||||
note: "次文字色,tokens.scss 未声明,elder-mode 下 --tk-text-secondary 覆盖为此值"
|
||||
|
||||
T.tx3:
|
||||
token: --tk-text-secondary
|
||||
scss_var: "$tx3"
|
||||
value: "#78716C"
|
||||
status: exact_match
|
||||
|
||||
T.bd:
|
||||
token: null
|
||||
scss_var: "$bd"
|
||||
value: "#E8E2DC"
|
||||
status: unmatched
|
||||
note: "边框色(不是圆角),tokens.scss 未声明为 CSS 变量"
|
||||
|
||||
T.r:
|
||||
token: --tk-card-radius
|
||||
scss_var: "$r"
|
||||
value: "16px"
|
||||
status: exact_match
|
||||
|
||||
T.rSm:
|
||||
token: null
|
||||
scss_var: "$r-sm"
|
||||
value: "12px"
|
||||
status: unmatched
|
||||
note: "tokens.scss 未声明,需添加 --tk-radius-sm 或直接用 $r-sm SCSS 变量"
|
||||
|
||||
T.rXs:
|
||||
token: null
|
||||
scss_var: "$r-xs"
|
||||
value: "8px"
|
||||
status: unmatched
|
||||
note: "tokens.scss 未声明,需添加 --tk-radius-xs 或直接用 $r-xs SCSS 变量"
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -72,4 +72,6 @@ tmp/
|
||||
screenshots/
|
||||
server-log.txt
|
||||
snapshot_*.txt
|
||||
uploads/
|
||||
uploads/g:/hms/.superpowers/
|
||||
.claude/skills/design-handoff/node_modules/
|
||||
.design/config.yml
|
||||
|
||||
15
docs/design/mp-00-visitor/META.yml
Normal file
15
docs/design/mp-00-visitor/META.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
prototype: mp-00-visitor.html
|
||||
source: docs/design/mp-00-visitor.html
|
||||
generated_at: "2026-05-18T00:07:00+08:00"
|
||||
tokens:
|
||||
matched: 20
|
||||
unmatched: 2
|
||||
confirmed: 19
|
||||
pending: 14
|
||||
approximate: 1
|
||||
components:
|
||||
total: 7
|
||||
mapped: 0
|
||||
new: 0
|
||||
interactions: 5
|
||||
screens: 5
|
||||
BIN
docs/design/mp-00-visitor/screenshots/guest-profile.png
Normal file
BIN
docs/design/mp-00-visitor/screenshots/guest-profile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
BIN
docs/design/mp-00-visitor/screenshots/guesthome-slide-1.png
Normal file
BIN
docs/design/mp-00-visitor/screenshots/guesthome-slide-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 500 KiB |
BIN
docs/design/mp-00-visitor/screenshots/guesthome-slide-2.png
Normal file
BIN
docs/design/mp-00-visitor/screenshots/guesthome-slide-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 278 KiB |
BIN
docs/design/mp-00-visitor/screenshots/guesthome-slide-3.png
Normal file
BIN
docs/design/mp-00-visitor/screenshots/guesthome-slide-3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 276 KiB |
BIN
docs/design/mp-00-visitor/screenshots/guesthome.png
Normal file
BIN
docs/design/mp-00-visitor/screenshots/guesthome.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 308 KiB |
291
docs/design/mp-00-visitor/tokens.json
Normal file
291
docs/design/mp-00-visitor/tokens.json
Normal file
@@ -0,0 +1,291 @@
|
||||
{
|
||||
"source": null,
|
||||
"matched": {
|
||||
"T.pri": {
|
||||
"method": "alias",
|
||||
"confidence": "confirmed",
|
||||
"token": "--tk-pri",
|
||||
"scssVar": null,
|
||||
"prototypeValue": "#C4623A",
|
||||
"tokenValue": "#C4623A"
|
||||
},
|
||||
"T.priL": {
|
||||
"method": "alias",
|
||||
"confidence": "confirmed",
|
||||
"token": "--tk-pri-l",
|
||||
"scssVar": null,
|
||||
"prototypeValue": "#F0DDD4",
|
||||
"tokenValue": "#F0DDD4"
|
||||
},
|
||||
"T.priD": {
|
||||
"method": "alias",
|
||||
"confidence": "confirmed",
|
||||
"token": "--tk-pri-d",
|
||||
"scssVar": null,
|
||||
"prototypeValue": "#8B3E1F",
|
||||
"tokenValue": "#8B3E1F"
|
||||
},
|
||||
"T.bg": {
|
||||
"method": "alias",
|
||||
"confidence": "pending",
|
||||
"token": null,
|
||||
"scssVar": "$bg",
|
||||
"tokenValue": "#F5F0EB",
|
||||
"note": "原型页面背景色,tokens.scss 未声明为 CSS 变量,直接用 $bg SCSS 变量",
|
||||
"prototypeValue": "#F5F0EB"
|
||||
},
|
||||
"T.card": {
|
||||
"method": "alias",
|
||||
"confidence": "confirmed",
|
||||
"token": "--tk-card-bg",
|
||||
"scssVar": null,
|
||||
"prototypeValue": "#FFFFFF",
|
||||
"tokenValue": "#FFFFFF"
|
||||
},
|
||||
"T.surface": {
|
||||
"method": "alias",
|
||||
"confidence": "approximate",
|
||||
"token": "--tk-card-bg",
|
||||
"scssVar": null,
|
||||
"note": "原型中 surface ≈ 卡片白底",
|
||||
"prototypeValue": "#EDE8E2",
|
||||
"tokenValue": "#FFFFFF"
|
||||
},
|
||||
"T.tx": {
|
||||
"method": "alias",
|
||||
"confidence": "pending",
|
||||
"token": null,
|
||||
"scssVar": "$tx",
|
||||
"tokenValue": "#2D2A26",
|
||||
"note": "主文字色,tokens.scss 未声明为 CSS 变量,直接用 $tx SCSS 变量",
|
||||
"prototypeValue": "#2D2A26"
|
||||
},
|
||||
"T.tx2": {
|
||||
"method": "alias",
|
||||
"confidence": "pending",
|
||||
"token": null,
|
||||
"scssVar": "$tx2",
|
||||
"tokenValue": "#5A554F",
|
||||
"note": "次文字色,tokens.scss 未声明,elder-mode 下 --tk-text-secondary 覆盖为此值",
|
||||
"prototypeValue": "#5A554F"
|
||||
},
|
||||
"T.tx3": {
|
||||
"method": "alias",
|
||||
"confidence": "confirmed",
|
||||
"token": "--tk-text-secondary",
|
||||
"scssVar": "$tx3",
|
||||
"prototypeValue": "#78716C",
|
||||
"tokenValue": "#78716C"
|
||||
},
|
||||
"T.bd": {
|
||||
"method": "alias",
|
||||
"confidence": "pending",
|
||||
"token": null,
|
||||
"scssVar": "$bd",
|
||||
"tokenValue": "#E8E2DC",
|
||||
"note": "边框色(不是圆角),tokens.scss 未声明为 CSS 变量",
|
||||
"prototypeValue": "#E8E2DC"
|
||||
},
|
||||
"T.bdL": {
|
||||
"token": null,
|
||||
"scssVar": "$bd-l",
|
||||
"prototypeValue": "#F0EBE5",
|
||||
"tokenValue": "#F0EBE5",
|
||||
"method": "value_exact",
|
||||
"confidence": "pending",
|
||||
"role": "浅边框色",
|
||||
"note": "匹配到 SCSS 变量 $bd-l,但无对应 CSS Token"
|
||||
},
|
||||
"T.acc": {
|
||||
"token": null,
|
||||
"scssVar": "$acc",
|
||||
"prototypeValue": "#5B7A5E",
|
||||
"tokenValue": "#5B7A5E",
|
||||
"method": "value_exact",
|
||||
"confidence": "pending",
|
||||
"role": "鼠尾草绿/成功色",
|
||||
"note": "匹配到 SCSS 变量 $acc,但无对应 CSS Token"
|
||||
},
|
||||
"T.accL": {
|
||||
"token": null,
|
||||
"scssVar": "$acc-l",
|
||||
"prototypeValue": "#E8F0E8",
|
||||
"tokenValue": "#E8F0E8",
|
||||
"method": "value_exact",
|
||||
"confidence": "pending",
|
||||
"role": "成功浅色",
|
||||
"note": "匹配到 SCSS 变量 $acc-l,但无对应 CSS Token"
|
||||
},
|
||||
"T.wrn": {
|
||||
"token": null,
|
||||
"scssVar": "$wrn",
|
||||
"prototypeValue": "#C4873A",
|
||||
"tokenValue": "#C4873A",
|
||||
"method": "value_exact",
|
||||
"confidence": "pending",
|
||||
"role": "警告色/暖琥珀",
|
||||
"note": "匹配到 SCSS 变量 $wrn,但无对应 CSS Token"
|
||||
},
|
||||
"T.wrnL": {
|
||||
"token": null,
|
||||
"scssVar": "$wrn-l",
|
||||
"prototypeValue": "#FFF3E0",
|
||||
"tokenValue": "#FFF3E0",
|
||||
"method": "value_exact",
|
||||
"confidence": "pending",
|
||||
"role": "警告浅色",
|
||||
"note": "匹配到 SCSS 变量 $wrn-l,但无对应 CSS Token"
|
||||
},
|
||||
"T.dan": {
|
||||
"token": null,
|
||||
"scssVar": "$dan",
|
||||
"prototypeValue": "#B54A4A",
|
||||
"tokenValue": "#B54A4A",
|
||||
"method": "value_exact",
|
||||
"confidence": "pending",
|
||||
"role": "危险色/柔红",
|
||||
"note": "匹配到 SCSS 变量 $dan,但无对应 CSS Token"
|
||||
},
|
||||
"T.danL": {
|
||||
"token": null,
|
||||
"scssVar": "$dan-l",
|
||||
"prototypeValue": "#FDEAEA",
|
||||
"tokenValue": "#FDEAEA",
|
||||
"method": "value_exact",
|
||||
"confidence": "pending",
|
||||
"role": "危险浅色",
|
||||
"note": "匹配到 SCSS 变量 $dan-l,但无对应 CSS Token"
|
||||
},
|
||||
"T.r": {
|
||||
"method": "alias",
|
||||
"confidence": "confirmed",
|
||||
"token": "--tk-card-radius",
|
||||
"scssVar": "$r",
|
||||
"prototypeValue": "16",
|
||||
"tokenValue": "16px"
|
||||
},
|
||||
"T.rSm": {
|
||||
"method": "alias",
|
||||
"confidence": "pending",
|
||||
"token": null,
|
||||
"scssVar": "$r-sm",
|
||||
"tokenValue": "12px",
|
||||
"note": "tokens.scss 未声明,需添加 --tk-radius-sm 或直接用 $r-sm SCSS 变量",
|
||||
"prototypeValue": "12"
|
||||
},
|
||||
"T.rXs": {
|
||||
"method": "alias",
|
||||
"confidence": "pending",
|
||||
"token": null,
|
||||
"scssVar": "$r-xs",
|
||||
"tokenValue": "8px",
|
||||
"note": "tokens.scss 未声明,需添加 --tk-radius-xs 或直接用 $r-xs SCSS 变量",
|
||||
"prototypeValue": "8"
|
||||
}
|
||||
},
|
||||
"unmatched": [
|
||||
"T.serif",
|
||||
"T.sans"
|
||||
],
|
||||
"inlineTokenMap": {
|
||||
"fontSize:28": {
|
||||
"token": "--tk-font-h1",
|
||||
"tokenValue": "28px",
|
||||
"confidence": "confirmed",
|
||||
"method": "value_exact"
|
||||
},
|
||||
"fontSize:22": {
|
||||
"token": "--tk-font-h2",
|
||||
"tokenValue": "22px",
|
||||
"confidence": "confirmed",
|
||||
"method": "value_exact"
|
||||
},
|
||||
"fontSize:18": {
|
||||
"token": "--tk-font-body-lg",
|
||||
"tokenValue": "18px",
|
||||
"confidence": "confirmed",
|
||||
"method": "value_exact"
|
||||
},
|
||||
"fontSize:16": {
|
||||
"token": "--tk-font-body",
|
||||
"tokenValue": "16px",
|
||||
"confidence": "confirmed",
|
||||
"method": "value_exact"
|
||||
},
|
||||
"fontSize:14": {
|
||||
"token": "--tk-font-body-sm",
|
||||
"tokenValue": "14px",
|
||||
"confidence": "confirmed",
|
||||
"method": "value_exact"
|
||||
},
|
||||
"fontSize:13": {
|
||||
"token": "--tk-font-cap",
|
||||
"tokenValue": "13px",
|
||||
"confidence": "confirmed",
|
||||
"method": "value_exact"
|
||||
},
|
||||
"fontSize:11": {
|
||||
"token": "--tk-font-micro",
|
||||
"tokenValue": "11px",
|
||||
"confidence": "confirmed",
|
||||
"method": "value_exact"
|
||||
},
|
||||
"padding:20": {
|
||||
"token": "--tk-section-gap",
|
||||
"scssVar": "$sp-section",
|
||||
"tokenValue": "20px",
|
||||
"confidence": "confirmed",
|
||||
"method": "value_exact"
|
||||
},
|
||||
"padding:16": {
|
||||
"token": "--tk-gap-md",
|
||||
"scssVar": "$sp-md",
|
||||
"tokenValue": "16px",
|
||||
"confidence": "confirmed",
|
||||
"method": "value_exact"
|
||||
},
|
||||
"padding:12": {
|
||||
"token": "--tk-gap-sm",
|
||||
"scssVar": "$sp-sm",
|
||||
"tokenValue": "12px",
|
||||
"confidence": "confirmed",
|
||||
"method": "value_exact"
|
||||
},
|
||||
"borderRadius:20": {
|
||||
"token": null,
|
||||
"scssVar": "$r-lg",
|
||||
"tokenValue": "20px",
|
||||
"confidence": "pending",
|
||||
"method": "value_exact",
|
||||
"note": "匹配到 SCSS 变量 $r-lg,但无对应 CSS Token"
|
||||
},
|
||||
"gap:16": {
|
||||
"token": "--tk-gap-md",
|
||||
"scssVar": "$sp-md",
|
||||
"tokenValue": "16px",
|
||||
"confidence": "confirmed",
|
||||
"method": "value_exact"
|
||||
},
|
||||
"lineHeight:1.5": {
|
||||
"token": "--tk-line-height",
|
||||
"tokenValue": "1.5",
|
||||
"confidence": "confirmed",
|
||||
"method": "value_exact"
|
||||
},
|
||||
"height:52": {
|
||||
"token": "--tk-btn-primary-h",
|
||||
"tokenValue": "52px",
|
||||
"confidence": "confirmed",
|
||||
"method": "value_exact"
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"total": 102,
|
||||
"aliasTokens": 22,
|
||||
"inlineValues": 80,
|
||||
"confirmed": 19,
|
||||
"pending": 14,
|
||||
"approximate": 1,
|
||||
"unmatched": 2
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user