Compare commits
210 Commits
d26ea64ab2
...
feat/media
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d683dfe82 | ||
|
|
ee5ae9e1fb | ||
|
|
01a0fffc43 | ||
|
|
976b9d94a0 | ||
|
|
5d61f19966 | ||
|
|
1982698b79 | ||
|
|
76a89dc7de | ||
|
|
201a91580c | ||
|
|
a5c67d6bec | ||
|
|
958110cc73 | ||
|
|
13705a3eaf | ||
|
|
92ffd8cecb | ||
|
|
6d073840aa | ||
|
|
f96e88b17b | ||
|
|
dc5d689d11 | ||
|
|
695b61f850 | ||
|
|
8d3b3a0491 | ||
|
|
bc3c056c8d | ||
|
|
3e36e31cf6 | ||
|
|
ec404a3e25 | ||
|
|
7924768df3 | ||
|
|
ac9896d375 | ||
|
|
a86219c8a0 | ||
|
|
432c5d96f2 | ||
|
|
aa6d93129d | ||
|
|
9a67bf80c1 | ||
|
|
03ead44385 | ||
|
|
ddf5c196e4 | ||
|
|
23cd0b14a7 | ||
|
|
803a27fb84 | ||
|
|
a4d09269a4 | ||
|
|
b0323ec89c | ||
|
|
2324d770bc | ||
|
|
823d69a3c3 | ||
|
|
7d1b1f9c7c | ||
|
|
e94f5bc00c | ||
|
|
0a1f4cb9a9 | ||
|
|
23c5bbdb40 | ||
|
|
2ccf0801b7 | ||
|
|
86dbd74f3f | ||
|
|
0edb475638 | ||
|
|
a7526455b4 | ||
|
|
dda8be9079 | ||
|
|
af2484e63b | ||
|
|
10c28df152 | ||
|
|
3c7b48b6f6 | ||
|
|
3972db4f98 | ||
|
|
9d6a92e1d7 | ||
|
|
42299a6722 | ||
|
|
a2864713d6 | ||
|
|
ba93e6585c | ||
|
|
d7fb5da873 | ||
|
|
8027cdd1d9 | ||
|
|
8ad4329632 | ||
|
|
1a376a255d | ||
|
|
485b9bb926 | ||
|
|
185f411495 | ||
|
|
a24c18155f | ||
|
|
ef1b8eb348 | ||
|
|
befdeba77c | ||
|
|
b14d0d347f | ||
|
|
1e59007bd5 | ||
|
|
675f8a4b10 | ||
|
|
e56ed9814a | ||
|
|
f11dd59382 | ||
|
|
f7d98a59f0 | ||
|
|
b3f53cd437 | ||
|
|
7f324466bf | ||
|
|
0748d20b4c | ||
|
|
09013ab94a | ||
|
|
1d443ab894 | ||
|
|
c81c3b73d0 | ||
|
|
5816ebb5e6 | ||
|
|
22e33114b1 | ||
|
|
0dfbe3130c | ||
|
|
d24aefe750 | ||
|
|
490ae075b7 | ||
|
|
437f5d1ae9 | ||
|
|
c2c9657b4d | ||
|
|
a5efab2a13 | ||
|
|
be8ae84d45 | ||
|
|
148cd875dc | ||
|
|
4fcbf705ca | ||
|
|
c9fe654d44 | ||
|
|
bdc2d07c1c | ||
|
|
8d2c377b68 | ||
|
|
b44ed6dfd2 | ||
|
|
2aa393dd65 | ||
|
|
ca9d065d31 | ||
|
|
96a6196373 | ||
|
|
898e22c715 | ||
|
|
02a96682f6 | ||
|
|
21f8040994 | ||
|
|
29543ef0e7 | ||
|
|
408527375f | ||
|
|
9c61156ab3 | ||
|
|
6c21f9eb2a | ||
|
|
685cf53673 | ||
|
|
89fa322d7a | ||
|
|
093b9fe9a3 | ||
|
|
a7b5548b35 | ||
|
|
d70b027f20 | ||
|
|
4b40d47b71 | ||
|
|
21481dbd88 | ||
|
|
fd994edf3e | ||
|
|
ee7dd0d6e1 | ||
|
|
7571ad74cb | ||
|
|
05e679b5ef | ||
|
|
f59e40e6fe | ||
|
|
bc571c7749 | ||
|
|
8e616f2210 | ||
|
|
58afc59676 | ||
|
|
d213afc649 | ||
|
|
b0f96258ee | ||
|
|
7ad5ddb898 | ||
|
|
4e9eb7b397 | ||
|
|
6338cd7428 | ||
|
|
23f7bcb8ce | ||
|
|
43795b2fb7 | ||
|
|
ce0561001f | ||
|
|
6e33c106d7 | ||
|
|
fde510f8a3 | ||
|
|
345e46002a | ||
|
|
d576b8ba8f | ||
|
|
652cccf66c | ||
|
|
e769a5785a | ||
|
|
831d2ba598 | ||
|
|
8c9d177642 | ||
|
|
c1458b1e4b | ||
|
|
b8c84ed9af | ||
|
|
2644926fb6 | ||
|
|
4d8658ae98 | ||
|
|
a3c84fc12a | ||
|
|
c5caed73b3 | ||
|
|
41a865cf68 | ||
|
|
9033ec8ca2 | ||
|
|
ec7f76127d | ||
|
|
5877342a4d | ||
|
|
4728794604 | ||
|
|
e3318e8266 | ||
|
|
80ee83861c | ||
|
|
853a0ca2b4 | ||
|
|
65cf96f119 | ||
|
|
fa1dc764a3 | ||
|
|
5f34e5715a | ||
|
|
17114d492e | ||
|
|
e83101dd23 | ||
|
|
3c94f5d585 | ||
|
|
03c50f6712 | ||
|
|
6e8239daf0 | ||
|
|
f3bf8b3b1d | ||
|
|
d74c7a61de | ||
|
|
c6d4e76b62 | ||
|
|
8fbe1543cb | ||
|
|
975928233f | ||
|
|
8e5bc97f93 | ||
|
|
a48a3d9906 | ||
|
|
de342f9195 | ||
|
|
b03ea47fed | ||
|
|
bcff978ea0 | ||
|
|
8064db3475 | ||
|
|
8b59f2d7d9 | ||
|
|
6f088347ce | ||
|
|
7edf1ed1d3 | ||
|
|
8b88cb4a50 | ||
|
|
c0570dfbfc | ||
|
|
7658bc3cdf | ||
|
|
9576e80175 | ||
|
|
2660f1afff | ||
|
|
205f6fb5a2 | ||
|
|
1e2ad6170a | ||
|
|
b2053d5bcc | ||
|
|
89581b070f | ||
|
|
5ba28ea349 | ||
|
|
7e3d27ecf3 | ||
|
|
281c71ebfc | ||
|
|
bf37acc681 | ||
|
|
d623f8b2ff | ||
|
|
38b0d91407 | ||
|
|
20714661d2 | ||
|
|
edea8f49d1 | ||
|
|
882b27ab7a | ||
|
|
e47fe547c8 | ||
|
|
aab4dfea79 | ||
|
|
f42669f934 | ||
|
|
2d62605812 | ||
|
|
877e9831f6 | ||
|
|
f668f0995a | ||
|
|
46b30504a5 | ||
|
|
f42e3ba611 | ||
|
|
64456d0172 | ||
|
|
cad48a97d5 | ||
|
|
01c75dbf5d | ||
|
|
4e12298ff3 | ||
|
|
e149a61ce6 | ||
|
|
3aa71a94d2 | ||
|
|
ded37830fe | ||
|
|
e555496528 | ||
|
|
2698c98888 | ||
|
|
31771168dd | ||
|
|
b0892706c8 | ||
|
|
530262590e | ||
|
|
6759723731 | ||
|
|
1c8319fb4d | ||
|
|
6151fde7c4 | ||
|
|
c26ca9088b | ||
|
|
63a9dac9d3 | ||
|
|
a4732cd2d4 | ||
|
|
35bd60af5b | ||
|
|
b8dce8a42a |
244
.claude/skills/design-handoff/SKILL.md
Normal file
244
.claude/skills/design-handoff/SKILL.md
Normal file
@@ -0,0 +1,244 @@
|
||||
---
|
||||
name: design-handoff
|
||||
description: 设计交付编排器 — 调用 huashu-design 设计原型、解析生成 SPEC,再由本 skill 根据 SPEC 实施代码,覆盖从需求到实现的完整流程
|
||||
---
|
||||
|
||||
# 设计交付编排器 (design-handoff)
|
||||
|
||||
编排从需求到代码实现的完整流程:
|
||||
1. 调用 huashu-design 设计 HTML 原型
|
||||
2. 解析原型生成 SPEC.md + 截图 + Token 映射
|
||||
3. 根据 SPEC 直接实施代码(不调用 huashu-design)
|
||||
|
||||
## 触发词
|
||||
|
||||
- `design-handoff`
|
||||
- `设计交付`
|
||||
- `handoff`
|
||||
- `设计并实现`
|
||||
- `从设计到代码`
|
||||
|
||||
---
|
||||
|
||||
## 输入模式
|
||||
|
||||
### 模式 A:从需求开始(完整流程)
|
||||
|
||||
用户提供功能需求描述(无 HTML 文件),skill 执行 7 步完整流程。
|
||||
|
||||
**输入示例:**
|
||||
```
|
||||
/design-handoff 患者端家庭档案管理页面,包含家庭成员列表、添加/编辑/删除操作
|
||||
```
|
||||
|
||||
### 模式 B:从原型开始(仅解析+实施)
|
||||
|
||||
用户提供已有 HTML 原型文件路径,跳过 Step 0,从 Step 1 开始。
|
||||
|
||||
**输入示例:**
|
||||
```
|
||||
/design-handoff docs/design/mp-13-family-profile.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程(7 步)
|
||||
|
||||
### Step 0: 设计原型(仅模式 A)
|
||||
|
||||
调用 huashu-design 生成 HTML 原型:
|
||||
|
||||
```
|
||||
Skill("huashu-design", "设计 {用户需求描述}。要求:1) 使用 HMS 设计系统 Token(const T = {...})2) 包含 IosFrame 设备框 3) 每个页面用 .screen-wrap 包裹 4) 输出到 docs/design/ 目录")
|
||||
```
|
||||
|
||||
**关键要求传递给 huashu-design:**
|
||||
- 使用项目 Token 系统(`const T = { pri: ..., bg: ..., r: ... }`)
|
||||
- 每个页面/状态用 `.screen-wrap` 包裹并配中文 label
|
||||
- 使用 `IosFrame` 组件呈现
|
||||
- 输出为单文件 HTML,保存到 `docs/design/mp-{编号}-{名称}.html`
|
||||
|
||||
**完成后获得:** HTML 原型文件路径,进入 Step 1。
|
||||
|
||||
### Step 1: 解析 HTML 原型
|
||||
|
||||
调用 `scripts/parse-prototype.mjs` 解析 HTML 文件:
|
||||
|
||||
```bash
|
||||
node .claude/skills/design-handoff/scripts/parse-prototype.mjs <html文件路径>
|
||||
```
|
||||
|
||||
**输出(stdout JSON):**
|
||||
- `tokens`: T 对象所有键值对(如 `{"T.pri": "#C4623A", ...}`)
|
||||
- `inlineStyles`: 内联样式值收集(按 CSS 属性分组)
|
||||
- `screens`: 每个屏幕的标签和组件名
|
||||
- `components`: 所有组件函数定义列表
|
||||
- `variant`: `"patient"` 或 `"doctor"`(根据 T.pri 值自动检测)
|
||||
|
||||
**若脚本不存在:** LLM 自行解析 HTML,提取 T 对象和 DOM 结构。
|
||||
|
||||
### Step 2: 截图
|
||||
|
||||
调用 `scripts/extract-screenshots.mjs` 生成截图:
|
||||
|
||||
```bash
|
||||
node .claude/skills/design-handoff/scripts/extract-screenshots.mjs <html文件路径> <输出目录>
|
||||
```
|
||||
|
||||
**输出:** PNG 文件到输出目录,文件名由中文标签自动翻译为英文。
|
||||
|
||||
**若脚本不存在:** 指导用户手动截图,保存到 `docs/design/{原型名}/screenshots/`。
|
||||
|
||||
### Step 3: Token 匹配
|
||||
|
||||
调用 `scripts/match-tokens.mjs` 进行三层 Token 匹配:
|
||||
|
||||
```bash
|
||||
node .claude/skills/design-handoff/scripts/parse-prototype.mjs <html> > /tmp/parse-result.json
|
||||
node .claude/skills/design-handoff/scripts/match-tokens.mjs /tmp/parse-result.json .design/tokens.yml
|
||||
```
|
||||
|
||||
**三层匹配算法:**
|
||||
1. **别名直查** — 在 `aliases.prototype_keys` 中查找已确认映射(支持值条件匹配,区分 patient/doctor variant)
|
||||
2. **值精确匹配** — 按 CSS 属性上下文消歧(borderRadius→radius, fontSize→typography)
|
||||
3. **色彩模糊匹配** — RGB 欧几里得距离 < 30 视为近似
|
||||
|
||||
**若脚本不存在:** LLM 自行读取 tokens.yml 执行匹配。
|
||||
|
||||
### Step 4: 组件映射推断
|
||||
|
||||
读取 `defaults/components.yml`,按 DOM 特征推断组件映射。
|
||||
|
||||
### Step 5: 交互推断
|
||||
|
||||
调用 `scripts/infer-interactions.mjs` 推断交互行为:
|
||||
|
||||
```bash
|
||||
node .claude/skills/design-handoff/scripts/infer-interactions.mjs <html文件路径> .claude/skills/design-handoff/rules/interaction-rules.yml
|
||||
```
|
||||
|
||||
### Step 6: 组装 SPEC.md
|
||||
|
||||
将前 5 步数据组装为 SPEC.md,输出到 `docs/design/{原型名}/SPEC.md`。
|
||||
|
||||
### Step 7: 实施代码
|
||||
|
||||
根据 Step 1-6 产出的 SPEC.md、截图和 Token 映射,**在当前会话中直接实施代码**。
|
||||
|
||||
**实施流程:**
|
||||
|
||||
1. **读取 SPEC.md** — 获取 Token 映射、组件清单、交互行为、未匹配项
|
||||
2. **读取截图** — 对照截图确认视觉还原度
|
||||
3. **创建/修改文件** — 在 `apps/miniprogram/src/pages/` 下实现 Taro 页面
|
||||
4. **验证** — `pnpm build` 编译通过 + 长者模式适配
|
||||
|
||||
**实施规则:**
|
||||
|
||||
- 样式使用 `var(--tk-*)` CSS 变量,不硬编码数值
|
||||
- 使用组件库已有组件(ContentCard, PrimaryButton, SectionTitle 等),参见 `defaults/components.yml`
|
||||
- 交互行为按 SPEC.md 第 4 节实现
|
||||
- 遵循 CLAUDE.md 中的小程序代码规范
|
||||
- 长者模式:字号 ≥ 22px,间距按 elder 值
|
||||
- 未匹配的 Token 需与用户确认后再补充到 `.design/tokens.yml`
|
||||
- 新建组件时遵循 `feedback-component-consistency` 原则:不适配则新建,不外部覆盖
|
||||
|
||||
---
|
||||
|
||||
## 前置检查
|
||||
|
||||
执行任何步骤前,必须完成以下检查:
|
||||
|
||||
### 1. 输入验证
|
||||
|
||||
**模式 A(从需求开始):** 无需输入文件验证,直接进入 Step 0。
|
||||
|
||||
**模式 B(从原型开始):** 确认输入文件为 huashu-design 产物:
|
||||
- 文件为 `.html` 格式
|
||||
- 包含 React + Babel 的 script 标签
|
||||
- 包含 Token 定义块(`const T = {`)
|
||||
|
||||
### 2. Token 配置文件初始化
|
||||
|
||||
检查 `.design/tokens.yml` 是否存在:
|
||||
- **存在** → 直接使用
|
||||
- **不存在** → 从 `defaults/tokens.yml` 复制
|
||||
|
||||
---
|
||||
|
||||
## 输出目录结构
|
||||
|
||||
```
|
||||
docs/design/
|
||||
mp-00-visitor.html # 原始原型(Step 0 产物或已有文件)
|
||||
mp-00-visitor/ # 交付包目录
|
||||
├── SPEC.md # 设计规格文档(Step 6 产物)
|
||||
├── screenshots/ # 截图目录(Step 2 产物)
|
||||
│ ├── home.png
|
||||
│ └── profile.png
|
||||
├── tokens.json # Token 匹配结果(Step 3 产物)
|
||||
└── META.yml # 元数据
|
||||
```
|
||||
|
||||
### META.yml 格式
|
||||
|
||||
```yaml
|
||||
prototype: {原型文件名}
|
||||
source: {HTML文件路径}
|
||||
variant: patient | doctor
|
||||
generated_at: {ISO 8601}
|
||||
tokens:
|
||||
matched: {数}
|
||||
unmatched: {数}
|
||||
components:
|
||||
total: {数}
|
||||
mapped: {数}
|
||||
new: {数}
|
||||
interactions: {数}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 终端输出
|
||||
|
||||
完成全部步骤后:
|
||||
|
||||
```
|
||||
============================================================
|
||||
设计交付完成: {原型名}
|
||||
输出目录: docs/design/{原型名}/
|
||||
模式: {完整流程 | 仅解析}
|
||||
Variant: {patient | doctor}
|
||||
Token 匹配: {matched}/{total} ({unmatched} 未匹配)
|
||||
组件: {total} ({new} 需新建)
|
||||
交互: {数} 项
|
||||
下一步: 根据 SPEC 实施代码(Step 7)
|
||||
============================================================
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 待确认项交互
|
||||
|
||||
完成 Step 1-6 后,若有 `pending` 或 `unmatched` Token,需与用户交互确认后再进入 Step 7。
|
||||
|
||||
---
|
||||
|
||||
## 脚本说明
|
||||
|
||||
| 脚本 | 用途 |
|
||||
|------|------|
|
||||
| `scripts/parse-prototype.mjs` | 解析 HTML 原型为结构化 JSON |
|
||||
| `scripts/extract-screenshots.mjs` | Playwright 截图(裁掉设备框) |
|
||||
| `scripts/match-tokens.mjs` | 三层 Token 匹配 |
|
||||
| `scripts/infer-interactions.mjs` | 交互行为推断 |
|
||||
|
||||
**降级策略:** 每个脚本缺失时,LLM 自行执行对应步骤。
|
||||
|
||||
## 模板与规则
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `templates/spec-template.md` | SPEC.md 模板 |
|
||||
| `defaults/tokens.yml` | Token 注册表 + 别名映射 |
|
||||
| `defaults/components.yml` | 组件映射规则 |
|
||||
| `rules/interaction-rules.yml` | 交互推断规则 |
|
||||
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,用于日期/时间/选项选择"
|
||||
423
.claude/skills/design-handoff/defaults/tokens.yml
Normal file
423
.claude/skills/design-handoff/defaults/tokens.yml
Normal file
@@ -0,0 +1,423 @@
|
||||
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:
|
||||
- value: "#C4623A"
|
||||
token: --tk-pri
|
||||
status: exact_match
|
||||
variant: patient
|
||||
- value: "#3A6B8C"
|
||||
token: --tk-pri.doctor
|
||||
status: exact_match
|
||||
variant: doctor
|
||||
|
||||
T.priL:
|
||||
- value: "#F0DDD4"
|
||||
token: --tk-pri-l
|
||||
status: exact_match
|
||||
variant: patient
|
||||
- value: "#D4E5F0"
|
||||
token: --tk-pri-l.doctor
|
||||
status: exact_match
|
||||
variant: doctor
|
||||
|
||||
T.priD:
|
||||
- value: "#8B3E1F"
|
||||
token: --tk-pri-d
|
||||
status: exact_match
|
||||
variant: patient
|
||||
- value: "#2A4F6A"
|
||||
token: --tk-pri-d.doctor
|
||||
status: exact_match
|
||||
variant: doctor
|
||||
|
||||
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 变量"
|
||||
79
.claude/skills/design-handoff/package-lock.json
generated
Normal file
79
.claude/skills/design-handoff/package-lock.json
generated
Normal file
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"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",
|
||||
"playwright": "^1.58.0"
|
||||
}
|
||||
},
|
||||
"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/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.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"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz",
|
||||
"integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz",
|
||||
"integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
.claude/skills/design-handoff/package.json
Normal file
17
.claude/skills/design-handoff/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"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",
|
||||
"playwright": "^1.58.0"
|
||||
}
|
||||
}
|
||||
114
.claude/skills/design-handoff/rules/interaction-rules.yml
Normal file
114
.claude/skills/design-handoff/rules/interaction-rules.yml
Normal file
@@ -0,0 +1,114 @@
|
||||
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:
|
||||
- "登录"
|
||||
- "注册"
|
||||
- "微信登录"
|
||||
- "立即登录"
|
||||
exclude_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();
|
||||
222
.claude/skills/design-handoff/scripts/infer-interactions.mjs
Normal file
222
.claude/skills/design-handoff/scripts/infer-interactions.mjs
Normal file
@@ -0,0 +1,222 @@
|
||||
#!/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;
|
||||
|
||||
// 排除模式检查:如果任一 exclude_pattern 命中,该规则不匹配
|
||||
if (allMatched && Array.isArray(rule.exclude_patterns)) {
|
||||
for (const exclPattern of rule.exclude_patterns) {
|
||||
try {
|
||||
const exclRegex = new RegExp(exclPattern);
|
||||
if (exclRegex.test(source)) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedPatterns: [],
|
||||
locations: [],
|
||||
excluded_by: exclPattern,
|
||||
};
|
||||
}
|
||||
} catch (_e) { /* ignore regex errors */ }
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
706
.claude/skills/design-handoff/scripts/match-tokens.mjs
Normal file
706
.claude/skills/design-handoff/scripts/match-tokens.mjs
Normal file
@@ -0,0 +1,706 @@
|
||||
#!/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: 别名直查(支持值条件映射)
|
||||
*
|
||||
* alias 格式有两种:
|
||||
* 单条: { token: "...", status: "exact_match" }
|
||||
* 数组: [{ value: "#C4623A", token: "--tk-pri", variant: "patient" },
|
||||
* { value: "#3A6B8C", token: "--tk-pri.doctor", variant: "doctor" }]
|
||||
*
|
||||
* 数组格式按值匹配,单条格式直接匹配(兼容旧格式)。
|
||||
*/
|
||||
function matchByAlias(key, prototypeValue, aliases) {
|
||||
if (!aliases || !aliases.prototype_keys) return null;
|
||||
const alias = aliases.prototype_keys[key];
|
||||
if (!alias) return null;
|
||||
|
||||
// 数组格式:按值条件匹配
|
||||
if (Array.isArray(alias)) {
|
||||
const normalizedInput = normalizeColor(prototypeValue) || normalizeNumericValue(prototypeValue) || String(prototypeValue);
|
||||
for (const entry of alias) {
|
||||
const normalizedAlias = normalizeColor(entry.value) || normalizeNumericValue(entry.value) || String(entry.value);
|
||||
if (normalizedInput === normalizedAlias) {
|
||||
const result = {
|
||||
method: 'alias',
|
||||
confidence: entry.status === 'exact_match' ? 'confirmed' : 'pending',
|
||||
token: entry.token || null,
|
||||
scssVar: entry.scss_var || null,
|
||||
};
|
||||
if (entry.variant) result.variant = entry.variant;
|
||||
if (entry.note) result.note = entry.note;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
// 数组中无值匹配 → 降级到 Layer 2
|
||||
return null;
|
||||
}
|
||||
|
||||
// 单条格式(兼容旧格式)
|
||||
const result = { method: 'alias', confidence: null };
|
||||
|
||||
// 值校验:如果 alias 有 value 字段,比较值
|
||||
if (alias.value && prototypeValue != null) {
|
||||
const normAlias = normalizeColor(alias.value) || normalizeNumericValue(alias.value) || String(alias.value);
|
||||
const normProto = normalizeColor(prototypeValue) || normalizeNumericValue(prototypeValue) || String(prototypeValue);
|
||||
if (normAlias !== normProto) {
|
||||
// 值不匹配 → 降级到 Layer 2
|
||||
return 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, value, 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);
|
||||
|
||||
// 检测 variant
|
||||
const variantEntries = Object.values(matched).filter(e => e.variant);
|
||||
const variant = variantEntries.length > 0 ? variantEntries[0].variant : null;
|
||||
|
||||
// ---- 输出 ----
|
||||
const output = {
|
||||
source: parseResult.source || null,
|
||||
variant,
|
||||
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();
|
||||
384
.claude/skills/design-handoff/scripts/parse-prototype.mjs
Normal file
384
.claude/skills/design-handoff/scripts/parse-prototype.mjs
Normal file
@@ -0,0 +1,384 @@
|
||||
#!/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 = [];
|
||||
|
||||
// 策略 A: 找静态 screen-label 文本(外部 span/div 标签)
|
||||
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());
|
||||
}
|
||||
|
||||
// 策略 B: 找 IosFrame 的 label prop(JSX 属性模式)
|
||||
// 匹配: <IosFrame ... label="文本" ...>
|
||||
const iosFrameLabelPattern = /<IosFrame[^>]*\blabel=["']([^"']+)["'][^>]*>/g;
|
||||
const iosLabels = [];
|
||||
let iosLabelMatch;
|
||||
while ((iosLabelMatch = iosFrameLabelPattern.exec(source)) !== null) {
|
||||
iosLabels.push(iosLabelMatch[1].trim());
|
||||
}
|
||||
|
||||
// 提取 IosFrame children 组件
|
||||
const iosFramePattern = /<IosFrame[^>]*>\s*(?:<React\.Fragment[^>]*>)?\s*<(\w+)\s*\/?>/g;
|
||||
const components = [];
|
||||
let compMatch;
|
||||
while ((compMatch = iosFramePattern.exec(source)) !== null) {
|
||||
const name = compMatch[1];
|
||||
if (name !== 'div' && name !== 'span' && name[0] === name[0].toUpperCase()) {
|
||||
components.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
// 选择 label 来源:优先静态标签,如果数量不匹配则尝试 JSX prop
|
||||
const useIosLabels = labels.length === 0 || (iosLabels.length > 0 && iosLabels.length === components.length && labels.length !== components.length);
|
||||
const selectedLabels = useIosLabels ? iosLabels : labels;
|
||||
|
||||
// 配对
|
||||
const count = Math.max(selectedLabels.length, components.length);
|
||||
for (let i = 0; i < count; i++) {
|
||||
screens.push({
|
||||
label: selectedLabels[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. "需新建"的组件参考截图和布局描述从头实现
|
||||
423
.design/tokens.yml
Normal file
423
.design/tokens.yml
Normal file
@@ -0,0 +1,423 @@
|
||||
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:
|
||||
- value: "#C4623A"
|
||||
token: --tk-pri
|
||||
status: exact_match
|
||||
variant: patient
|
||||
- value: "#3A6B8C"
|
||||
token: --tk-pri.doctor
|
||||
status: exact_match
|
||||
variant: doctor
|
||||
|
||||
T.priL:
|
||||
- value: "#F0DDD4"
|
||||
token: --tk-pri-l
|
||||
status: exact_match
|
||||
variant: patient
|
||||
- value: "#D4E5F0"
|
||||
token: --tk-pri-l.doctor
|
||||
status: exact_match
|
||||
variant: doctor
|
||||
|
||||
T.priD:
|
||||
- value: "#8B3E1F"
|
||||
token: --tk-pri-d
|
||||
status: exact_match
|
||||
variant: patient
|
||||
- value: "#2A4F6A"
|
||||
token: --tk-pri-d.doctor
|
||||
status: exact_match
|
||||
variant: doctor
|
||||
|
||||
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 变量"
|
||||
@@ -67,3 +67,19 @@ jobs:
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: cd apps/web && corepack enable && pnpm install --frozen-lockfile && pnpm audit
|
||||
|
||||
miniprogram-test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/miniprogram
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: corepack enable && pnpm install --frozen-lockfile
|
||||
- name: TypeScript check
|
||||
run: npx tsc --noEmit
|
||||
- name: Run tests
|
||||
run: npx vitest run
|
||||
|
||||
33
.github/workflows/test.yml
vendored
33
.github/workflows/test.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: 123123
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
@@ -28,9 +28,9 @@ jobs:
|
||||
--health-retries 5
|
||||
|
||||
env:
|
||||
TEST_DB_URL: postgres://postgres:123123@localhost:5432/postgres
|
||||
TEST_DB_URL: postgres://postgres:postgres@localhost:5432/postgres
|
||||
JWT_SECRET: test-jwt-secret-for-ci
|
||||
DATABASE_URL: postgres://postgres:123123@localhost:5432/erp_ci
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432/erp_ci
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -81,4 +81,29 @@ jobs:
|
||||
run: pnpm build
|
||||
|
||||
- name: Security audit (npm)
|
||||
run: npx npm-audit --audit-level=high || true
|
||||
run: npx npm-audit --audit-level=high
|
||||
|
||||
miniprogram-test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/miniprogram
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
cache-dependency-path: apps/miniprogram/pnpm-lock.yaml
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: TypeScript check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Run tests
|
||||
run: npx vitest run
|
||||
|
||||
36
.gitignore
vendored
36
.gitignore
vendored
@@ -28,6 +28,16 @@ docker/redis_data/
|
||||
|
||||
# Test artifacts
|
||||
.test_token
|
||||
test-results/
|
||||
|
||||
# Build outputs
|
||||
apps/miniprogram/dist-h5/
|
||||
|
||||
# Runtime uploads
|
||||
uploads/
|
||||
|
||||
# Temp logs
|
||||
_server_out.txt
|
||||
*.heapsnapshot
|
||||
perf-trace-*.json
|
||||
docs/debug-*.png
|
||||
@@ -72,4 +82,28 @@ tmp/
|
||||
screenshots/
|
||||
server-log.txt
|
||||
snapshot_*.txt
|
||||
uploads/
|
||||
_*.txt
|
||||
_server_*.txt
|
||||
tmp_*.txt
|
||||
direct_*.txt
|
||||
server_*.txt
|
||||
server_combined.txt
|
||||
out.txt
|
||||
_wx_login.json
|
||||
.claude/settings.json
|
||||
|
||||
# Trace/debug JSON
|
||||
trace-*.json
|
||||
|
||||
# Graphify knowledge graph (regenerated locally)
|
||||
graphify-out/
|
||||
|
||||
# Native miniprogram (separate project)
|
||||
apps/mp-native/
|
||||
|
||||
# Misc untracked
|
||||
err.txt
|
||||
uploads/g:/hms/.superpowers/
|
||||
.claude/skills/design-handoff/node_modules/
|
||||
.design/config.yml
|
||||
.superpowers/
|
||||
|
||||
120
CLAUDE.md
120
CLAUDE.md
@@ -177,8 +177,13 @@
|
||||
|
||||
- [ ] 新增端点有权限声明(默认拒绝,不是默认放行)
|
||||
- [ ] 敏感数据有脱敏/加密处理(PII 字段走 AES-256-GCM)
|
||||
- [ ] 用户输入已验证和消毒(防 SQL 注入、XSS)
|
||||
- [ ] 无 CORS 通配符、无硬编码密钥
|
||||
- [ ] 用户输入已验证和消毒(防 SQL 注入、XSS、命令注入、路径穿越)
|
||||
- [ ] 无 CORS 通配符、无硬编码密钥、无 fallback 默认密钥
|
||||
- [ ] 日志中无敏感数据输出(密码、token、身份证号、手机号等)
|
||||
- [ ] 文件上传有 MIME 类型验证 + 大小限制 + 路径穿越防护
|
||||
- [ ] API 响应不暴露内部实现细节(数据库错误、堆栈跟踪、文件路径)
|
||||
- [ ] 速率限制已配置(认证端点更严格)
|
||||
- [ ] 密钥通过环境变量注入,`.env.example` 已同步更新
|
||||
|
||||
#### 文档一致性
|
||||
|
||||
@@ -224,7 +229,7 @@
|
||||
|
||||
#### 新增 API 端点安全检查(强制)
|
||||
|
||||
> 历史数据:25 次安全 fix 中 80% 源于默认放行模式。
|
||||
> 默认拒绝是安全基线 — 绝大多数安全修复源于默认放行模式。
|
||||
> 新增端点时**必须**逐项确认:
|
||||
|
||||
- [ ] 端点已添加 `require_permission` 权限守卫(非公开端点)
|
||||
@@ -237,7 +242,7 @@
|
||||
|
||||
#### 前后端接口同步检查(强制)
|
||||
|
||||
> 历史数据:35 次 fix 源于前后端接口不一致。
|
||||
> 前后端接口不一致是高频 bug 来源 — 任何 DTO 变更必须双向同步。
|
||||
> 后端 DTO 变更时**必须**同步检查前端:
|
||||
|
||||
- [ ] DTO 字段名变更 → 前端 TypeScript 接口同步更新
|
||||
@@ -247,6 +252,21 @@
|
||||
- [ ] 枚举值变更 → 前端类型定义和 UI 映射同步更新
|
||||
- [ ] 后端新增端点 → 前端 API 模块同步添加调用函数,不允许留空
|
||||
|
||||
#### DTO 输入校验检查(强制)
|
||||
|
||||
> DTO 输入校验是安全防线 — 缺失校验等于暴露攻击面,Update 和 Create 必须对称。
|
||||
> 新增/修改 DTO 时**必须**逐项确认:
|
||||
|
||||
- [ ] 所有请求结构体已 `derive(Validate)`(包括 Update\*Req、查询参数)
|
||||
- [ ] Update\*Req 与 Create\*Req 校验对称(不允许 Update 降级)
|
||||
- [ ] 字符串字段有 `#[validate(length(min, max))]`
|
||||
- [ ] 枚举/类型字段有 `#[validate(custom)]` 限制合法值
|
||||
- [ ] 集合字段有 `#[validate(length(min = 1))]` 非空检查
|
||||
- [ ] 数值范围字段有 `#[validate(range(min, max))]`
|
||||
- [ ] URL 字段有 SSRF 防护(禁止 localhost/内网地址,仅 http/https)
|
||||
- [ ] 密码字段有 `max = 128` 防止 DoS
|
||||
- [ ] handler 层已调用 `req.validate().map_err(|e| AppError::Validation(e.to_string()))?`
|
||||
|
||||
### 3.4 事件总线
|
||||
|
||||
- 模块间通信**只能**通过 `EventBus`
|
||||
@@ -276,6 +296,50 @@
|
||||
// 国际化文案使用 i18n key,不硬编码中文
|
||||
```
|
||||
|
||||
### 3.7 安全规范
|
||||
|
||||
#### 密钥与凭据管理
|
||||
|
||||
- 所有密钥、token、密码**必须**通过环境变量或密钥管理服务注入,**禁止**硬编码在源码中
|
||||
- 开发环境密钥**必须**与生产环境严格隔离(`cfg(debug_assertions)` 编译期防护)
|
||||
- 生产密钥**禁止**有 fallback 默认值,缺失时启动 panic
|
||||
- 新增密钥时必须同步更新 `.env.example` 和 `wiki/infrastructure.md`
|
||||
|
||||
#### 依赖安全
|
||||
|
||||
- 新增依赖前**必须**检查已知漏洞(`cargo audit` / `npm audit`)
|
||||
- 禁止引入有未修补高危漏洞的依赖版本
|
||||
- 定期更新依赖到最新安全补丁版本
|
||||
|
||||
#### 数据安全
|
||||
|
||||
- PII 数据(姓名、身份证号、手机号、地址等)**必须**加密存储(AES-256-GCM)
|
||||
- 日志中**禁止**输出 PII 数据和认证凭据(密码、token、session key)
|
||||
- 敏感操作(登录、权限变更、数据导出、批量删除)**必须**记录审计日志(操作者、时间、目标、结果)
|
||||
- 文件上传**必须**验证 MIME 类型 + 限制文件大小 + 防止路径穿越(文件名 sanitize)
|
||||
|
||||
#### 传输安全
|
||||
|
||||
- 生产环境**必须**强制 HTTPS,**禁止**降级到 HTTP
|
||||
- HTTP 响应**必须**包含安全头(HSTS、CSP、X-Frame-Options、X-Content-Type-Options、Permissions-Policy)
|
||||
- SSE/WebSocket 长连接认证 token 不通过 URL query 参数传递(使用 header 或 cookie)
|
||||
- API 响应**禁止**暴露内部实现细节(堆栈跟踪、数据库错误、文件路径、SQL 语句)
|
||||
|
||||
#### 认证与授权
|
||||
|
||||
- 密码**必须**使用单向哈希(bcrypt/argon2),**禁止**明文或可逆加密存储
|
||||
- JWT **必须**设置合理过期时间,支持 token 吊销机制
|
||||
- 敏感操作(删除数据、权限变更)需要二次确认
|
||||
- 权限检查在 handler 层执行,**禁止**仅依赖前端隐藏控制访问
|
||||
- `tenant_id` **必须**从 JWT 中间件注入,**禁止**信任客户端传递的值
|
||||
|
||||
#### 速率限制
|
||||
|
||||
- 所有 API 端点**必须**配置速率限制
|
||||
- 认证相关端点(登录、注册、密码重置)限制更严格
|
||||
- 批量操作和数据导出需要独立的速率限制策略
|
||||
- 速率限制超出时返回 429 状态码,响应包含 `Retry-After` header
|
||||
|
||||
---
|
||||
|
||||
## 4. 测试与验证
|
||||
@@ -394,14 +458,24 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
- ❌ **不要**跳过验证直接提交 — 编译/测试/功能验证必须全部通过
|
||||
- ❌ **不要**提交后忘记推送 — 不推送等于没完成,远程仓库必须同步。每次新会话开始先检查未推送提交
|
||||
- ❌ **不要**忘记更新文档 — 涉及架构、接口、模块变化时必须同步更新相关文档
|
||||
- ❌ **不要**连续 5 个代码提交不更新 wiki — wiki 是团队唯一真相源,过期数据比没有数据更有害(5 月实测:89 fix 仅 11 有 wiki 更新,关键数字迁移数差 8 个)
|
||||
- ❌ **不要**连续 5 个代码提交不更新 wiki — wiki 是团队唯一真相源,过期数据比没有数据更有害
|
||||
- ❌ **不要**修复 bug 后跳过症状导航更新 — 每个修复都应该帮助未来遇到同类问题的人快速定位根因
|
||||
- ❌ **不要**新增功能后不更新 wiki 关键数字 — 迁移数/路由数/实体数/测试数必须与代码同步,否则 wiki 指标表就是废数据
|
||||
- ❌ **不要**一次性输出长文档 — 超过 200 行的文档必须分步编写(先大纲 → 逐章 → 整合),否则会因上下文过长卡死
|
||||
- ❌ **不要**忽略讨论记录 — 每次发散式讨论结束后必须建立文档到 `docs/discussions/`,不要口头确认后就忘
|
||||
- ❌ **不要**跳过 Feature DoD — 每个功能标记"完成"前必须通过 §2.6 检查清单,不全面验证就提交 = 后续 5 次 fix(媒体库教训)
|
||||
- ❌ **不要**新增端点时默认放行 — 所有端点默认拒绝访问,必须显式声明权限(安全教训:25 次 fix 源于默认放行)
|
||||
- ❌ **不要**后端 DTO 变更不同步前端 — 字段名/路径/类型变更时必须同步更新前端 TypeScript 接口(35 次 fix 教训)
|
||||
- ❌ **不要**跳过 Feature DoD — 每个功能标记"完成"前必须通过 §2.6 检查清单,不全面验证就提交将导致后续反复修复
|
||||
- ❌ **不要**新增端点时默认放行 — 所有端点默认拒绝访问,必须显式声明权限
|
||||
- ❌ **不要**后端 DTO 变更不同步前端 — 字段名/路径/类型变更时必须同步更新前端 TypeScript 接口
|
||||
- ❌ **不要**写 Update DTO 时省略校验 — Update*Req 必须与 Create*Req 校验对称(Validate derive / 枚举 custom / Vec min=1 / 密码 max=128)
|
||||
- ❌ **不要**URL 字段不做 SSRF 防护 — 禁止 localhost/127.0.0.1/内网地址,仅允许 http/https 协议
|
||||
- ❌ **不要**handler 层跳过 `.validate()` — 所有 `Json<T>` handler 函数体第一行必须调 `req.validate().map_err(\|e\| AppError::Validation(e.to_string()))?`
|
||||
- ❌ **不要**在日志中输出敏感数据 — 密码、token、身份证号、手机号等 PII 信息禁止写入日志
|
||||
- ❌ **不要**信任客户端传递的 `tenant_id` — 必须从 JWT 中间件注入,客户端可伪造
|
||||
- ❌ **不要**在生产代码中使用 `unwrap()` — 必须处理所有错误,使用 `?` 或 `map_err`
|
||||
- ❌ **不要**将内部错误信息返回给客户端 — 数据库错误、堆栈跟踪、文件路径等必须转换为用户友好的错误消息
|
||||
- ❌ **不要**使用 HTTP 传输敏感数据 — 生产环境必须 HTTPS
|
||||
- ❌ **不要**跳过依赖安全检查 — 新增依赖前运行 `cargo audit` / `npm audit`,禁止引入有高危漏洞的版本
|
||||
- ❌ **不要**文件上传不做安全处理 — 必须验证 MIME 类型 + 限制大小 + sanitize 文件名防路径穿越
|
||||
|
||||
### 场景化指令
|
||||
|
||||
@@ -412,6 +486,7 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
- 当遇到**新增表** → 创建 SeaORM migration + Entity,包含所有标准字段
|
||||
- 当遇到**新增页面** → 使用 Ant Design 组件,i18n key 引用文案
|
||||
- 当遇到**新增业务模块插件** → 参考 `wiki/wasm-plugin.md` 的插件制作完整流程和 `.claude/skills/plugin-development/SKILL.md`,创建 cdylib crate + 实现 Guest trait + 编译为 WASM Component。**权限码必须与实体名一致(每个实体声明 `.list` + `.manage`)**
|
||||
- 当遇到**新增/修改 DTO** → 参考 `wiki/architecture.md` §4 DTO 输入校验规范:`derive(Validate)` + 字段级校验 + handler 层 `validate()` 调用 + 单元测试
|
||||
|
||||
---
|
||||
|
||||
@@ -429,3 +504,32 @@ chore(docker): 添加 PostgreSQL 健康检查
|
||||
| 设计文档索引 | `wiki/index.md` |
|
||||
| 开发进度、模块状态 | `wiki/index.md` 关键数字 |
|
||||
| 环境配置、连接信息、登录凭据 | `wiki/infrastructure.md` §2 |
|
||||
|
||||
## graphify — 代码知识图谱
|
||||
|
||||
> 项目知识图谱位于 `graphify-out/`,当前规模:18,517 节点 / 22,666 边 / 1,841 社区(纯 AST 解析,无 API 成本)。
|
||||
> 工具:`python -m graphify`(已安装 graphifyy 0.8.18)。
|
||||
|
||||
### 开发流程中的使用场景
|
||||
|
||||
| 时机 | 命令 | 目的 |
|
||||
|------|------|------|
|
||||
| **接手新任务,理解代码关系** | `graphify query "概念名"` | 搜索相关节点,比 Grep 更精准(按调用/引用/包含关系) |
|
||||
| **排查 bug,追踪调用链** | `graphify path "A" "B"` | 查找两个模块/函数间的最短路径 |
|
||||
| **理解某个模块的职责** | `graphify explain "模块名"` | 自然语言解释节点及其邻居 |
|
||||
| **代码改动后** | `graphify update .` | 增量更新图谱(AST-only,秒级完成) |
|
||||
| **宏观架构审查** | 读 `graphify-out/GRAPH_REPORT.md` | 全局社区结构、跨文件关系概览 |
|
||||
|
||||
### 使用优先级(融入 §2.5 闭环工作法)
|
||||
|
||||
在 §2.5 步骤 1「现状确认」中,**优先使用 graphify 替代盲目 Grep**:
|
||||
|
||||
1. **先 `graphify query`** — 精确定位相关节点和社区(比 Grep 返回更结构化的结果)
|
||||
2. **再 `graphify path`** — 确认模块间依赖路径(避免遗漏间接依赖)
|
||||
3. **最后 Grep/Glob/Read** — 确认 graphify 发现的具体文件内容
|
||||
|
||||
### 注意事项
|
||||
|
||||
- `graphify update .` 纯本地 AST 解析,不消耗 LLM token,每次代码改动后都可以运行
|
||||
- 查询结果比 GRAPH_REPORT.md 更精准,优先使用 query/path/explain,仅在需要全局视图时读报告
|
||||
- 首次生成需几分钟(1712 文件),后续增量更新秒级完成
|
||||
|
||||
9
Cargo.lock
generated
9
Cargo.lock
generated
@@ -1429,6 +1429,7 @@ dependencies = [
|
||||
"handlebars",
|
||||
"hex",
|
||||
"redis",
|
||||
"regex-lite",
|
||||
"reqwest",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
@@ -1453,6 +1454,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"cbc",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"erp-core",
|
||||
"hex",
|
||||
"jsonwebtoken",
|
||||
@@ -1613,6 +1615,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"utoipa",
|
||||
"uuid",
|
||||
"validator",
|
||||
"wasmtime",
|
||||
"wasmtime-wasi",
|
||||
]
|
||||
@@ -3978,6 +3981,12 @@ dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-lite"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
|
||||
@@ -33,7 +33,7 @@ tokio = { version = "1", features = ["full"] }
|
||||
# Web
|
||||
axum = { version = "0.8", features = ["multipart"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip", "fs"] }
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip", "fs", "set-header"] }
|
||||
|
||||
# Database
|
||||
sea-orm = { version = "1.1", features = [
|
||||
@@ -91,6 +91,7 @@ reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
hex = "0.4"
|
||||
regex-lite = "0.1"
|
||||
|
||||
# CSV and Excel export
|
||||
csv = "1"
|
||||
@@ -119,6 +120,9 @@ handlebars = "6"
|
||||
# HTML sanitization
|
||||
ammonia = "4"
|
||||
|
||||
# Document parsing
|
||||
pdf-extract = "0.7"
|
||||
|
||||
# Metrics
|
||||
metrics = "0.24"
|
||||
metrics-exporter-prometheus = "0.16"
|
||||
|
||||
5
apps/miniprogram/.env.production
Normal file
5
apps/miniprogram/.env.production
Normal file
@@ -0,0 +1,5 @@
|
||||
TARO_APP_API_URL=https://api.hms.example.com/api/v1
|
||||
TARO_APP_DEFAULT_TENANT_ID=
|
||||
# TARO_APP_ENCRYPTION_KEY 不在此文件设置
|
||||
# 生产密钥通过 CI/CD 环境变量注入(dotenv 不覆盖已有 env var)
|
||||
# 本地 build:weapp 测试时自动回退到 .env 中的开发密钥
|
||||
8
apps/miniprogram/.prettierrc
Normal file
8
apps/miniprogram/.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
@@ -11,6 +11,9 @@ vi.mock('@/services/request', () => ({
|
||||
clearRequestCache: vi.fn(),
|
||||
markLoggingOut: vi.fn(),
|
||||
clearLoggingOut: vi.fn(),
|
||||
getCachedPatientId: vi.fn(() => ''),
|
||||
setCachedPatientId: vi.fn(),
|
||||
resetForTesting: vi.fn(),
|
||||
}));
|
||||
|
||||
/** 创建一个成功的 API 响应 */
|
||||
|
||||
99
apps/miniprogram/__tests__/services/analytics-pii.test.ts
Normal file
99
apps/miniprogram/__tests__/services/analytics-pii.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('@tarojs/taro', () => ({
|
||||
default: {
|
||||
getStorageSync: vi.fn(() => []),
|
||||
setStorage: vi.fn(),
|
||||
removeStorageSync: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/services/request', () => ({
|
||||
api: { post: vi.fn().mockResolvedValue({ success: true }) },
|
||||
}));
|
||||
|
||||
describe('Analytics PII 清理', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('flushEvents 发送的 batch 不含 PII', async () => {
|
||||
const { trackEvent, flushEvents } = await import('@/services/analytics');
|
||||
const { api } = await import('@/services/request');
|
||||
|
||||
trackEvent('page_view', {
|
||||
page: 'health',
|
||||
userId: 'should-be-removed',
|
||||
patientId: 'should-be-removed',
|
||||
user_name: 'should-be-removed',
|
||||
phone: 'should-be-removed',
|
||||
id_card: 'should-be-removed',
|
||||
});
|
||||
|
||||
await flushEvents();
|
||||
|
||||
const postCall = vi.mocked(api.post).mock.calls[0];
|
||||
const body = postCall[1] as { events: Array<Record<string, unknown>> };
|
||||
const evt = body.events[0];
|
||||
|
||||
// 事件级别不应有 userId/patientId
|
||||
expect(evt).not.toHaveProperty('userId');
|
||||
expect(evt).not.toHaveProperty('patientId');
|
||||
|
||||
// properties 中不应有 PII 字段
|
||||
const props = evt.properties as Record<string, unknown>;
|
||||
expect(props).not.toHaveProperty('userId');
|
||||
expect(props).not.toHaveProperty('patientId');
|
||||
expect(props).not.toHaveProperty('user_name');
|
||||
expect(props).not.toHaveProperty('phone');
|
||||
expect(props).not.toHaveProperty('id_card');
|
||||
|
||||
// 正常字段保留
|
||||
expect(props.page).toBe('health');
|
||||
});
|
||||
|
||||
it('trackEvent 不在事件级别包含 userId/patientId', async () => {
|
||||
const { trackEvent, flushEvents } = await import('@/services/analytics');
|
||||
const { api } = await import('@/services/request');
|
||||
|
||||
trackEvent('test_event');
|
||||
await flushEvents();
|
||||
|
||||
const postCall = vi.mocked(api.post).mock.calls[0];
|
||||
const body = postCall[1] as { events: Array<Record<string, unknown>> };
|
||||
const evt = body.events[0];
|
||||
|
||||
expect(evt).not.toHaveProperty('userId');
|
||||
expect(evt).not.toHaveProperty('patientId');
|
||||
});
|
||||
|
||||
it('sanitizeProperties 过滤所有 PII 标识字段', async () => {
|
||||
const { trackEvent, flushEvents } = await import('@/services/analytics');
|
||||
const { api } = await import('@/services/request');
|
||||
|
||||
trackEvent('test', {
|
||||
openid: 'oXXX',
|
||||
access_token: 'tok',
|
||||
refresh_token: 'ref',
|
||||
email: 'test@test.com',
|
||||
address: '某地',
|
||||
mobile: '13800001111',
|
||||
page: 'settings', // 非 PII 字段
|
||||
});
|
||||
|
||||
await flushEvents();
|
||||
|
||||
const postCall = vi.mocked(api.post).mock.calls[0];
|
||||
const body = postCall[1] as { events: Array<Record<string, unknown>> };
|
||||
const props = body.events[0].properties as Record<string, unknown>;
|
||||
|
||||
// 全部 PII 被过滤,只剩 page
|
||||
expect(props).not.toHaveProperty('openid');
|
||||
expect(props).not.toHaveProperty('access_token');
|
||||
expect(props).not.toHaveProperty('refresh_token');
|
||||
expect(props).not.toHaveProperty('email');
|
||||
expect(props).not.toHaveProperty('address');
|
||||
expect(props).not.toHaveProperty('mobile');
|
||||
expect(props.page).toBe('settings');
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ vi.mock('@tarojs/taro', () => ({
|
||||
getStorageSync: vi.fn(() => ''),
|
||||
setStorageSync: vi.fn(),
|
||||
showToast: vi.fn(),
|
||||
reLaunch: vi.fn(),
|
||||
reLaunch: vi.fn(() => Promise.resolve()),
|
||||
getCurrentPages: vi.fn(() => []),
|
||||
},
|
||||
}));
|
||||
@@ -23,7 +23,7 @@ vi.mock('@/utils/secure-storage', () => ({
|
||||
}));
|
||||
|
||||
import Taro from '@tarojs/taro';
|
||||
import { api, clearRequestCache, resetForTesting } from '@/services/request';
|
||||
import { api, clearRequestCache, resetForTesting, setCachedPatientId } from '@/services/request';
|
||||
|
||||
describe('request module', () => {
|
||||
beforeEach(() => {
|
||||
@@ -148,4 +148,218 @@ describe('request module', () => {
|
||||
await expect(api.get('/bad-params')).rejects.toThrow('参数错误');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ResponseCache', () => {
|
||||
it('should cache GET responses and return cached on second call', async () => {
|
||||
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: { id: '1' } } } as any);
|
||||
|
||||
await api.get('/cached-test');
|
||||
await api.get('/cached-test');
|
||||
|
||||
expect(Taro.request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not cache POST requests', async () => {
|
||||
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: {} } } as any);
|
||||
|
||||
await api.post('/no-cache', { a: 1 });
|
||||
await api.post('/no-cache', { a: 1 });
|
||||
|
||||
expect(Taro.request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('clearRequestCache should clear cached entries', async () => {
|
||||
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: { v: 1 } } } as any);
|
||||
|
||||
await api.get('/clear-test');
|
||||
clearRequestCache();
|
||||
await api.get('/clear-test');
|
||||
|
||||
expect(Taro.request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setCachedPatientId', () => {
|
||||
it('should isolate cache entries by patient ID', async () => {
|
||||
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: { v: 1 } } } as any);
|
||||
setCachedPatientId('patient-A');
|
||||
await api.get('/health/data');
|
||||
|
||||
setCachedPatientId('patient-B');
|
||||
await api.get('/health/data');
|
||||
|
||||
// 不同 patient ID 应各自发请求(缓存隔离)
|
||||
expect(Taro.request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestUnlimited', () => {
|
||||
it('should bypass concurrency limiter', async () => {
|
||||
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 200, data: { success: true, data: 'ok' } } as any);
|
||||
|
||||
const { requestUnlimited } = await import('@/services/request');
|
||||
await requestUnlimited('GET', '/health/test');
|
||||
|
||||
expect(Taro.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConcurrencyLimiter', () => {
|
||||
it('should queue requests when at capacity', async () => {
|
||||
const { ConcurrencyLimiter } = await import('@/services/request/limiter');
|
||||
const limiter = new ConcurrencyLimiter(2);
|
||||
const order: number[] = [];
|
||||
|
||||
const acquire1 = limiter.acquire();
|
||||
const acquire2 = limiter.acquire();
|
||||
// Third acquire should queue
|
||||
const acquire3 = limiter.acquire().then(() => order.push(3));
|
||||
|
||||
order.push(1);
|
||||
order.push(2);
|
||||
|
||||
// Release one to unblock the third
|
||||
limiter.release();
|
||||
await acquire3;
|
||||
|
||||
expect(order).toContain(3);
|
||||
limiter.release();
|
||||
limiter.release();
|
||||
});
|
||||
|
||||
it('should release in FIFO order', async () => {
|
||||
const { ConcurrencyLimiter } = await import('@/services/request/limiter');
|
||||
const limiter = new ConcurrencyLimiter(1);
|
||||
const order: string[] = [];
|
||||
|
||||
await limiter.acquire(); // fills the slot
|
||||
|
||||
const p2 = limiter.acquire().then(() => order.push('second'));
|
||||
const p3 = limiter.acquire().then(() => order.push('third'));
|
||||
|
||||
limiter.release(); // releases second
|
||||
await p2;
|
||||
limiter.release(); // releases third
|
||||
await p3;
|
||||
|
||||
expect(order).toEqual(['second', 'third']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ResponseCache LRU', () => {
|
||||
it('should update insertion order on cache hit', async () => {
|
||||
const { ResponseCache } = await import('@/services/request/cache');
|
||||
const cache = new ResponseCache(3, 60_000);
|
||||
cache.setPatientId('p1');
|
||||
|
||||
cache.set('/a', 'data-a');
|
||||
cache.set('/b', 'data-b');
|
||||
cache.set('/c', 'data-c');
|
||||
|
||||
// Access /a to move it to the end (most recently used)
|
||||
cache.get('/a');
|
||||
|
||||
// Adding /d should evict /b (oldest after /a was accessed)
|
||||
cache.set('/d', 'data-d');
|
||||
|
||||
expect(cache.get('/b')).toBeNull();
|
||||
expect(cache.get('/a')).toBe('data-a');
|
||||
expect(cache.get('/d')).toBe('data-d');
|
||||
});
|
||||
|
||||
it('should expire entries based on TTL', async () => {
|
||||
const { ResponseCache } = await import('@/services/request/cache');
|
||||
vi.useFakeTimers();
|
||||
const cache = new ResponseCache(100, 1000);
|
||||
cache.setPatientId('p1');
|
||||
|
||||
cache.set('/expiring', 'data', 500);
|
||||
expect(cache.get('/expiring')).toBe('data');
|
||||
|
||||
vi.advanceTimersByTime(600);
|
||||
expect(cache.get('/expiring')).toBeNull();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('token refresh & 401 retry', () => {
|
||||
it('should throw immediately when isLoggingOut is true', async () => {
|
||||
const { markLoggingOut } = await import('@/services/request');
|
||||
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 401, data: {} } as any);
|
||||
|
||||
markLoggingOut();
|
||||
await expect(api.get('/protected')).rejects.toThrow('登录已过期');
|
||||
});
|
||||
|
||||
it('should attempt token refresh on 401', async () => {
|
||||
const mockStore: Record<string, string> = {
|
||||
access_token: 'expired-token',
|
||||
refresh_token: 'valid-refresh',
|
||||
tenant_id: 'test-tenant',
|
||||
};
|
||||
|
||||
// Override secureGet for this test
|
||||
const { secureGet } = await import('@/utils/secure-storage');
|
||||
vi.mocked(secureGet).mockImplementation((key: string) => mockStore[key] || '');
|
||||
|
||||
// First call: 401 → triggers refresh
|
||||
// Refresh call: success
|
||||
// Retry call: success
|
||||
vi.mocked(Taro.request)
|
||||
.mockResolvedValueOnce({ statusCode: 401, data: {} } as any)
|
||||
.mockResolvedValueOnce({ statusCode: 200, data: { success: true, data: { access_token: 'new-token', refresh_token: 'new-refresh', expires_in: 3600 } } } as any)
|
||||
.mockResolvedValueOnce({ statusCode: 200, data: { success: true, data: { result: 'ok' } } } as any);
|
||||
|
||||
const result = await api.get('/needs-auth');
|
||||
|
||||
expect(result).toEqual({ result: 'ok' });
|
||||
// 3 calls: initial 401 + refresh + retry
|
||||
expect(Taro.request).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should redirect to login when refresh fails', async () => {
|
||||
const mockStore: Record<string, string> = {
|
||||
access_token: 'expired-token',
|
||||
refresh_token: 'bad-refresh',
|
||||
tenant_id: 'test-tenant',
|
||||
};
|
||||
|
||||
const { secureGet } = await import('@/utils/secure-storage');
|
||||
vi.mocked(secureGet).mockImplementation((key: string) => mockStore[key] || '');
|
||||
vi.mocked(Taro.getCurrentPages).mockReturnValue([{ path: 'pages/home' } as any]);
|
||||
|
||||
vi.mocked(Taro.request)
|
||||
.mockResolvedValueOnce({ statusCode: 401, data: {} } as any)
|
||||
.mockResolvedValueOnce({ statusCode: 401, data: {} } as any); // refresh fails
|
||||
|
||||
await expect(api.get('/protected-resource')).rejects.toThrow('登录已过期');
|
||||
expect(Taro.reLaunch).toHaveBeenCalledWith({ url: '/pages/login/index' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeReLaunch dedup', () => {
|
||||
it('should only call reLaunch once for concurrent requests', async () => {
|
||||
const { markLoggingOut } = await import('@/services/request');
|
||||
vi.mocked(Taro.request).mockResolvedValue({ statusCode: 401, data: {} } as any);
|
||||
vi.mocked(Taro.getCurrentPages).mockReturnValue([{ path: 'pages/home' } as any]);
|
||||
|
||||
const mockStore: Record<string, string> = {
|
||||
access_token: 'expired',
|
||||
refresh_token: 'bad',
|
||||
tenant_id: 't1',
|
||||
};
|
||||
const { secureGet } = await import('@/utils/secure-storage');
|
||||
vi.mocked(secureGet).mockImplementation((key: string) => mockStore[key] || '');
|
||||
|
||||
// First call sets isLoggingOut, second call hits early exit
|
||||
await expect(api.get('/test1')).rejects.toThrow();
|
||||
await expect(api.get('/test2')).rejects.toThrow();
|
||||
|
||||
// reLaunch should be called at most once
|
||||
expect(vi.mocked(Taro.reLaunch).mock.calls.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
66
apps/miniprogram/__tests__/utils/request-signer.test.ts
Normal file
66
apps/miniprogram/__tests__/utils/request-signer.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { signRequest, generateNonce, hmacSha256Sync } from '@/utils/request-signer';
|
||||
|
||||
describe('generateNonce', () => {
|
||||
it('生成 16 字符十六进制字符串', () => {
|
||||
const nonce = generateNonce();
|
||||
expect(nonce).toHaveLength(16);
|
||||
expect(nonce).toMatch(/^[0-9a-f]{16}$/);
|
||||
});
|
||||
|
||||
it('连续调用生成不同值', () => {
|
||||
const n1 = generateNonce();
|
||||
const n2 = generateNonce();
|
||||
expect(n1).not.toBe(n2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hmacSha256Sync', () => {
|
||||
it('相同输入产生相同输出', () => {
|
||||
const key = 'test-key';
|
||||
const msg = 'hello world';
|
||||
const r1 = hmacSha256Sync(key, msg);
|
||||
const r2 = hmacSha256Sync(key, msg);
|
||||
expect(r1).toBe(r2);
|
||||
expect(r1).toHaveLength(64); // SHA-256 = 32 bytes = 64 hex chars
|
||||
});
|
||||
|
||||
it('不同输入产生不同输出', () => {
|
||||
const r1 = hmacSha256Sync('key', 'msg1');
|
||||
const r2 = hmacSha256Sync('key', 'msg2');
|
||||
expect(r1).not.toBe(r2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signRequest', () => {
|
||||
const signingKey = 'test-signing-key-256-bit!!!!!!!!!!!';
|
||||
|
||||
it('GET 请求生成正确的签名头', () => {
|
||||
const headers = signRequest('GET', '/health/patients', undefined, signingKey);
|
||||
|
||||
expect(headers).toHaveProperty('X-Signature');
|
||||
expect(headers).toHaveProperty('X-Timestamp');
|
||||
expect(headers).toHaveProperty('X-Nonce');
|
||||
expect(headers['X-Nonce']).toHaveLength(16);
|
||||
expect(headers['X-Signature']).toHaveLength(64);
|
||||
});
|
||||
|
||||
it('POST 请求包含 body hash', () => {
|
||||
const headers = signRequest('POST', '/health/vital-signs', { value: 120 }, signingKey);
|
||||
|
||||
expect(headers).toHaveProperty('X-Signature');
|
||||
expect(headers['X-Signature']).toHaveLength(64);
|
||||
});
|
||||
|
||||
it('无 body 时也能正常签名', () => {
|
||||
const headers = signRequest('GET', '/test', undefined, signingKey);
|
||||
expect(headers['X-Signature']).toBeTruthy();
|
||||
});
|
||||
|
||||
it('相同参数不同 nonce 产生不同签名', () => {
|
||||
const h1 = signRequest('GET', '/test', undefined, signingKey);
|
||||
// 由于 nonce 不同,签名也不同
|
||||
const h2 = signRequest('GET', '/test', undefined, signingKey);
|
||||
expect(h1['X-Signature']).not.toBe(h2['X-Signature']);
|
||||
});
|
||||
});
|
||||
324
apps/miniprogram/__tests__/utils/secure-storage.test.ts
Normal file
324
apps/miniprogram/__tests__/utils/secure-storage.test.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* secure-storage AES-256-GCM 测试
|
||||
* 覆盖:加解密对称性、空值删除、明文兼容、迁移、Base64 边界
|
||||
*/
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
// --- crypto.getRandomValues polyfill ---
|
||||
if (!globalThis.crypto?.getRandomValues) {
|
||||
globalThis.crypto = {
|
||||
getRandomValues: (arr: any) => {
|
||||
for (let i = 0; i < arr.length; i++) arr[i] = (Math.random() * 256) | 0;
|
||||
return arr;
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
// --- Mock @tarojs/taro (覆盖 setup.ts 中的默认 mock,添加 base64 方法) ---
|
||||
const storage = new Map<string, string>();
|
||||
|
||||
vi.mock('@tarojs/taro', () => ({
|
||||
default: {
|
||||
getStorageSync: vi.fn((key: string) => storage.get(key) ?? ''),
|
||||
setStorageSync: vi.fn((key: string, value: string) => { storage.set(key, value); }),
|
||||
removeStorageSync: vi.fn((key: string) => { storage.delete(key); }),
|
||||
arrayBufferToBase64: vi.fn((buf: ArrayBuffer) => {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary);
|
||||
}),
|
||||
base64ToArrayBuffer: vi.fn((b64: string) => {
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes.buffer;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// --- Mock 加密密钥 ---
|
||||
process.env.TARO_APP_ENCRYPTION_KEY =
|
||||
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
// --- 导入被测模块(在 mock 之后) ---
|
||||
import { secureSet, secureGet, secureRemove, migrateLegacyStorage } from '@/utils/secure-storage';
|
||||
|
||||
// --- 辅助:直接访问 mock 函数 ---
|
||||
import Taro from '@tarojs/taro';
|
||||
|
||||
const mockGet = Taro.getStorageSync as ReturnType<typeof vi.fn>;
|
||||
const mockSet = Taro.setStorageSync as ReturnType<typeof vi.fn>;
|
||||
const mockRemove = Taro.removeStorageSync as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe('secure-storage AES-256-GCM', () => {
|
||||
beforeEach(() => {
|
||||
storage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// 1. AES 加解密对称性
|
||||
// ================================================================
|
||||
describe('AES 加解密对称性', () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
['英文', 'hello world'],
|
||||
['中文', '你好世界'],
|
||||
['emoji', '\u{1F600}\u{1F680}\u{1F4A9}'],
|
||||
['特殊字符', '<script>alert("xss")</script>&"\''],
|
||||
['JSON', '{"name":"张三","age":30,"nested":{"key":"val"}}'],
|
||||
['超长字符串', 'A'.repeat(10000)],
|
||||
['混合内容', 'Hello 你好 \u{1F600} !@#$%^&*()'],
|
||||
['空格和换行', ' line1\nline2\ttabbed '],
|
||||
['Unicode 补充', '\u{1F1E8}\u{1F1F3}\u{1F1FA}\u{1F1F8}'],
|
||||
];
|
||||
|
||||
cases.forEach(([label, value]) => {
|
||||
it(`roundtrip: ${label}`, () => {
|
||||
secureSet('test_key', value);
|
||||
const result = secureGet('test_key');
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
it('多次写入同一 key 产生不同密文(nonce 随机)', () => {
|
||||
secureSet('dup', 'same-value');
|
||||
const first = storage.get('_es_dup')!;
|
||||
secureSet('dup', 'same-value');
|
||||
const second = storage.get('_es_dup')!;
|
||||
// 密文应该不同(nonce 不同),但都能解密
|
||||
expect(first).not.toBe(second);
|
||||
expect(secureGet('dup')).toBe('same-value');
|
||||
});
|
||||
|
||||
it('不同 key 互不干扰', () => {
|
||||
secureSet('key_a', 'value_a');
|
||||
secureSet('key_b', 'value_b');
|
||||
expect(secureGet('key_a')).toBe('value_a');
|
||||
expect(secureGet('key_b')).toBe('value_b');
|
||||
});
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// 2. 空 value 触发 remove
|
||||
// ================================================================
|
||||
describe('空 value 触发 remove', () => {
|
||||
it('空字符串触发 removeStorageSync', () => {
|
||||
secureSet('empty', '');
|
||||
expect(mockRemove).toHaveBeenCalledWith('_es_empty');
|
||||
expect(secureGet('empty')).toBe('');
|
||||
});
|
||||
|
||||
it('先写入再清空应删除存储', () => {
|
||||
secureSet('temp', 'some-data');
|
||||
expect(secureGet('temp')).toBe('some-data');
|
||||
secureSet('temp', '');
|
||||
expect(secureGet('temp')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// 3. 明文 fallback 读取兼容
|
||||
// ================================================================
|
||||
describe('明文 fallback 兼容', () => {
|
||||
it('直接读取无前缀的明文存储', () => {
|
||||
storage.set('access_token', 'plain-token-123');
|
||||
expect(secureGet('access_token')).toBe('plain-token-123');
|
||||
});
|
||||
|
||||
it('加密存储优先于明文存储', () => {
|
||||
storage.set('access_token', 'plain-token');
|
||||
secureSet('access_token', 'encrypted-token');
|
||||
expect(secureGet('access_token')).toBe('encrypted-token');
|
||||
});
|
||||
|
||||
it('存储值非字符串时回退到明文', () => {
|
||||
// getStorageSync mock 返回 ''(默认值),prefixed key 不存在
|
||||
// 明文 key 也返回 ''
|
||||
expect(secureGet('nonexistent')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// 4. migrateLegacyStorage 迁移逻辑
|
||||
// ================================================================
|
||||
describe('migrateLegacyStorage', () => {
|
||||
it('将明文数据迁移到加密存储并删除原始 key', () => {
|
||||
storage.set('access_token', 'legacy-token');
|
||||
storage.set('refresh_token', 'legacy-refresh');
|
||||
storage.set('user_data', '{"id":"123"}');
|
||||
|
||||
migrateLegacyStorage();
|
||||
|
||||
// 明文 key 应被删除
|
||||
expect(storage.has('access_token')).toBe(false);
|
||||
expect(storage.has('refresh_token')).toBe(false);
|
||||
expect(storage.has('user_data')).toBe(false);
|
||||
|
||||
// 加密 key 存在且可解密
|
||||
expect(secureGet('access_token')).toBe('legacy-token');
|
||||
expect(secureGet('refresh_token')).toBe('legacy-refresh');
|
||||
expect(secureGet('user_data')).toBe('{"id":"123"}');
|
||||
});
|
||||
|
||||
it('已加密的 key 不重复迁移', () => {
|
||||
secureSet('access_token', 'already-encrypted');
|
||||
const spy = vi.spyOn(storage, 'set');
|
||||
|
||||
migrateLegacyStorage();
|
||||
|
||||
// 不应产生新的 set 调用(除了可能内部的 secureSet 已有的)
|
||||
// 关键:值不变
|
||||
expect(secureGet('access_token')).toBe('already-encrypted');
|
||||
});
|
||||
|
||||
it('非字符串的明文数据不迁移', () => {
|
||||
// 我们的 mock getStorageSync 对不存在的 key 返回 ''
|
||||
// 模拟: 存一个空字符串的明文值(不迁移)
|
||||
storage.set('tenant_id', '');
|
||||
migrateLegacyStorage();
|
||||
// 空字符串不被视为有效数据,不做迁移
|
||||
expect(storage.has('_es_tenant_id')).toBe(false);
|
||||
});
|
||||
|
||||
it('MIGRATION_KEYS 中未列出的 key 不受影响', () => {
|
||||
storage.set('custom_key', 'custom-value');
|
||||
migrateLegacyStorage();
|
||||
expect(storage.get('custom_key')).toBe('custom-value');
|
||||
expect(storage.has('_es_custom_key')).toBe(false);
|
||||
});
|
||||
|
||||
it('prefixed key 存在但非 aes: 前缀时重新加密为 AES', () => {
|
||||
// 直接放一个非 aes: 前缀的值到 prefixed key
|
||||
// migrateLegacyStorage 发现 prefixed key 存在且不以 aes: 开头,
|
||||
// 会调用 secureGet → 明文 fallback 返回该值,然后 secureSet 重新加密
|
||||
storage.set('_es_access_token', 'legacy-ciphertext-or-plain');
|
||||
|
||||
migrateLegacyStorage();
|
||||
|
||||
// 应被重新加密为 AES 格式
|
||||
const stored = storage.get('_es_access_token')!;
|
||||
expect(stored.startsWith('aes:')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// 5. secureRemove
|
||||
// ================================================================
|
||||
describe('secureRemove', () => {
|
||||
it('删除加密存储', () => {
|
||||
secureSet('remove_test', 'to-be-removed');
|
||||
expect(secureGet('remove_test')).toBe('to-be-removed');
|
||||
secureRemove('remove_test');
|
||||
expect(secureGet('remove_test')).toBe('');
|
||||
});
|
||||
|
||||
it('删除不存在的 key 不报错', () => {
|
||||
expect(() => secureRemove('nonexistent')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// 6. Base64 边界
|
||||
// ================================================================
|
||||
describe('Base64 边界', () => {
|
||||
it('存储值可正确通过 base64 编解码', () => {
|
||||
const value = 'Test with special chars: | ||||