@routerhub/agent-rules 1.5.147 → 1.5.148
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/rules/global.md +1 -1
- package/skills/create-doc/SKILL.md +2 -2
- package/skills/create-pr/SKILL.md +4 -4
- package/skills/pr-release-loop/SKILL.md +2 -2
- package/skills/screenshot-annotate/SKILL.md +7 -4
- package/skills/screenshot-annotate/annotate.js +448 -0
- package/skills/visual-report/SKILL.md +2 -2
package/package.json
CHANGED
package/rules/global.md
CHANGED
|
@@ -149,7 +149,7 @@ name: "通用规则"
|
|
|
149
149
|
- ⚠️ **创建完 PR 后自动走完整闭环流程,未走完不算完成**:创建 PR → 循环 review → 重新部署测试环境验证 → 确认没问题 → 发 PR 链接给用户 → 发新版本,六步缺一不可,全程自动执行:
|
|
150
150
|
1. **自动走循环 review**:创建完 PR 后自动触发 `/loop-review` skill,反复「拉取 AI review(Claude Opus + GPT 交叉验证)→ 逐条读真实代码判断哪些值得修 → 值得修的改、不值得修/误报的 Won't fix 切断 → push 触发新一轮 review」,直到某一轮不再冒出值得修的新问题才结束,禁止只跑一轮就收工。**循环 review 走完后,必须显式输出「✅ 循环 review 完成,进入发布收尾闭环」,并调用 `/pr-release-loop` skill 走完后续步骤。**
|
|
151
151
|
2. **重新部署到测试环境(循环 review 后最容易漏掉的一步)**:循环 review 走完后,自动触发 `/deploy-test` skill,把最新代码重新部署到测试环境,确保测试的是循环 review 之后的最终代码。⚠️ **循环 review 期间每次修复都会 push 新 commit;不重新部署 = 测试环境跑的还是 review 之前的旧代码 = 用旧代码验证新改动 = 结论无效。因此循环 review 结束后禁止直接发 PR 链接 / 发版,必须先 `/deploy-test` 重部署。**
|
|
152
|
-
3. **测试环境验证 + 全程截图标注**:在测试环境用真实数据、真实页面交互测试本次改动(遵循「⚠️ 页面功能验证铁律」「⚠️ 用户视角测试铁律」)。测试过程中每一步都截图保留(遵循「⚠️
|
|
152
|
+
3. **测试环境验证 + 全程截图标注**:在测试环境用真实数据、真实页面交互测试本次改动(遵循「⚠️ 页面功能验证铁律」「⚠️ 用户视角测试铁律」)。测试过程中每一步都截图保留(遵循「⚠️ 截图规范」:真实视口 + `fullPage` 全页、URL 可见、存盘到 `docs/` 对应子目录),并在每张截图上用**坐标换算后的箭头标注**(走 `/screenshot-annotate` skill)关键改动区域/验证点,让看的人一眼看懂这张图证明了什么。
|
|
153
153
|
4. **确认没问题才算完成**:测试通过、截图与箭头标注齐全、功能符合预期,才算真正完成。禁止测试没跑、截图没标注就宣称完成。
|
|
154
154
|
5. **把 PR 链接发给用户**:确认没问题后,先把 PR 链接发送给用户(`gh pr view <PR> --json url -q .url`),让用户能直接打开查看,再执行发版。
|
|
155
155
|
6. **发新版本**:确认没问题、PR 链接已发送后,按上面三步收尾完成冲突检查与静态编译检查,PR 合并后执行根目录 `./release.sh` 发新版本。
|
|
@@ -67,9 +67,9 @@ description: >-
|
|
|
67
67
|
|
|
68
68
|
### 截图规范
|
|
69
69
|
|
|
70
|
-
- 截取整页(full page
|
|
70
|
+
- 截取整页(full page),不是可视区域;用真实视口宽度(4K 屏自然宽 3840),禁止强制把视口拉宽
|
|
71
71
|
- 截图结果必须包含当前页面 URL
|
|
72
|
-
- ⚠️ **截图版 HTML
|
|
72
|
+
- ⚠️ **截图版 HTML 的每张截图都必须加箭头(或红框)标注**,指向该图要说明的关键操作点或关键数据,禁止放无标注的「裸截图」;标注放在页面空白区域,不遮挡关键内容。⚠️ **标注坐标必须精确**:用 `/screenshot-annotate` skill(浏览器 DOM 测得的坐标 → `annotate.js` 换算到截图像素),禁止肉眼估位
|
|
73
73
|
- 制作过程中产生的中间截图文件统一放到 `screenshots/` 目录
|
|
74
74
|
|
|
75
75
|
### 分步操作记录(每步必录)
|
|
@@ -58,7 +58,7 @@ Closes #issue编号
|
|
|
58
58
|
1. **截修复后**:浏览器打开改动页面 → 滚动到改动区域 → 全页截图
|
|
59
59
|
2. **截修复前**:临时注释/回退改动代码 → 等 hot reload → 同位置截图 → 恢复代码
|
|
60
60
|
- ⚠️ **若线上数据已变化,导致无法在真实页面复现"修复前"效果**:允许改用等价的纯逻辑对比代替(用新旧两版逻辑代码跑同样的输入数据,把输出结果差异渲染成对比图),但必须在 Description 里明确写清楚"为什么无法复现 + 用了什么替代方案",禁止因此省略截图或假装能复现。
|
|
61
|
-
3. **生成对比图**:用
|
|
61
|
+
3. **生成对比图**:用 `annotate.js`(截图标注统一脚本,见 `/screenshot-annotate` skill)拼接并标注 → 上方红/绿色标注条(❌修复前 / ✅修复后)→ 下方左右并排截图 → 关键改动区域用坐标精确换算后的箭头 + 红/绿框圈选
|
|
62
62
|
4. **上传 GitHub CDN**(在 PR Description 编辑区直接上传):
|
|
63
63
|
- 打开目标 PR 页面 → 在 **Description 编辑区**直接上传图片(拖拽/粘贴,或定位 Description 编辑区的隐藏 `input[type=file]` 上传),GitHub 会自动把图片转成 `https://github.com/user-attachments/assets/...` 的 CDN 地址并插入 Description 正文,`` 内嵌
|
|
64
64
|
- ⚠️ **禁止走评论区上传再搬运 CDN URL**:评论区上传需要多一步「提交评论 → 复制 URL → 粘贴到 Description」,产生的临时图片评论会留在 PR 对话里干扰 reviewer 阅读,且多一步手动搬运容易出错
|
|
@@ -66,10 +66,10 @@ Closes #issue编号
|
|
|
66
66
|
5. **保存 Description**:确认图片已内嵌进 Description 正文后,保存 PR Description 使图片持久化
|
|
67
67
|
|
|
68
68
|
**截图硬性规范(PR 效果截图必须逐条满足,缺一不可,禁止跳过)**:
|
|
69
|
-
- ⚠️
|
|
69
|
+
- ⚠️ **全页图用浏览器真实视口宽度(`window.innerWidth` 即截图宽,4K 屏自然宽 3840)**,用 agent-browser 时先 `set viewport <视口宽> <合适高度>` 固定视口,再 `screenshot --full` 截完整页面。⚠️ **禁止强制 `set viewport 3840 <h>` 把 CSS 视口拉宽到比屏幕还宽**(页面会缩小看不清、且 CSS 像素与截图像素缩放错位导致标注不准);需要更高清晰度时用 `set viewport <W> <H> 2`(DPR 倍率,CSS 宽不变、像素更密)。
|
|
70
70
|
- ⚠️ **必须 `fullPage: true` 截完整页面**,不要只截视口的一部分。每个改动点都要覆盖到,禁止只截视口内一屏。
|
|
71
71
|
- ⚠️ **截图必须全面(多图覆盖多个角度)**:一张全页图 + 每个关键改动区域的局部放大图。只截一处、截局部、漏掉改动点都不算全。整份 PR Description 的效果截图要使 reviewer 不看代码就能看全本次改动。
|
|
72
|
-
- ⚠️ **每张截图必须用醒目箭头 +
|
|
72
|
+
- ⚠️ **每张截图必须用醒目箭头 + 简短文字标签标注关键改动区域/验证点**,让看的人一眼看懂这张图证明了什么;修复前用红框/红箭头,修复后用绿框/绿箭头,标注放在不遮挡原内容的位置。禁止只贴裸图不标注。⚠️ **标注坐标必须精确**:统一用 `/screenshot-annotate` skill(`getBoundingClientRect()` + `scrollX/scrollY` 换算到截图像素,用 `annotate.js` 脚本画箭头),禁止肉眼看图估坐标。
|
|
73
73
|
- ⚠️ **截图中必须能看到当前页面 URL**(浏览器地址栏,或页面顶部叠加 URL 标注),确保证据可追溯。
|
|
74
74
|
- ⚠️ 截图保存到本地磁盘 `docs/` 对应子目录(文件名「编号 + 英文描述」,如 `01-before.png` / `02-after.png`),上传 CDN 后按规则清理临时文件,禁止提交到 Git 仓库。
|
|
75
75
|
- ⚠️ 必须展示前后对比(修复前红框,修复后绿框),标注不遮挡页面内容。
|
|
@@ -101,7 +101,7 @@ gh pr create \
|
|
|
101
101
|
1. **先做 CI 闸门检查**:创建 PR 后立即执行 `gh pr checks <PR>`(必要时轮询直到非 `pending`)。若存在任一 `failure`,必须先定位失败根因并修复,推送后复查到全部 `success`,再继续后续步骤;禁止带红 CI 进入下一步。
|
|
102
102
|
2. **自动走循环 review**:自动触发 `/loop-review` skill,反复「拉取 AI review(Claude Opus + GPT 交叉验证)→ 逐条读真实代码判断哪些值得修 → 值得修的改、不值得修/误报的 Won't fix 切断 → push 触发新一轮 review」,直到某一轮不再冒出值得修的新问题才结束。
|
|
103
103
|
3. **重新部署到测试环境**:循环 review 走完后,自动触发 `/deploy-test` skill,把最新代码重新部署到测试环境,确保测试的是循环 review 之后的最终代码。
|
|
104
|
-
4. **测试环境验证 + 全程截图标注**:在测试环境用真实数据、真实页面交互测试本次改动(遵循「⚠️ 页面功能验证铁律」「⚠️ 用户视角测试铁律」)。测试过程中每一步都截图保留(遵循「⚠️
|
|
104
|
+
4. **测试环境验证 + 全程截图标注**:在测试环境用真实数据、真实页面交互测试本次改动(遵循「⚠️ 页面功能验证铁律」「⚠️ 用户视角测试铁律」)。测试过程中每一步都截图保留(遵循「⚠️ 截图规范」:真实视口 + `fullPage` 全页、URL 可见、存盘到 `docs/` 对应子目录),并在每张截图上用**坐标换算后的箭头标注**(走 `/screenshot-annotate` skill)关键改动区域/验证点,让看的人一眼看懂这张图证明了什么。
|
|
105
105
|
5. **确认没问题才算完成**:测试通过、截图与箭头标注齐全、功能符合预期,才算真正完成。禁止测试没跑、截图没标注就宣称完成。
|
|
106
106
|
6. **发新版本**:确认没问题后,回到下方步骤 7 完成冲突检查与静态编译检查,PR 合并后执行根目录 `./release.sh` 发新版本。
|
|
107
107
|
|
|
@@ -23,7 +23,7 @@ description: >-
|
|
|
23
23
|
1. ✅ 循环 review 走完(`/loop-review`,直到某一轮不再冒出值得修的新问题)
|
|
24
24
|
2. ✅ 最新代码已提交并推送到 PR 分支
|
|
25
25
|
3. ✅ 已通过 `/deploy-test` 重新部署到测试环境(部署的是 review 之后的最终代码)
|
|
26
|
-
4. ✅ 测试环境用真实数据 +
|
|
26
|
+
4. ✅ 测试环境用真实数据 + 真实交互复测通过(每张截图真实视口 + `fullPage` 全页、坐标换算箭头标注、URL 可见)
|
|
27
27
|
5. ✅ 与主分支无冲突(`gh pr view <PR> --json mergeable -q .mergeable` = `MERGEABLE`)
|
|
28
28
|
6. ✅ GitHub 静态编译通过(`gh pr checks <PR>` 无 `failure`)
|
|
29
29
|
7. ✅ PR 链接已发送给用户
|
|
@@ -57,7 +57,7 @@ git status && git push origin "$(git branch --show-current)"
|
|
|
57
57
|
### 4. 测试环境复测 + 全程截图标注
|
|
58
58
|
|
|
59
59
|
- 在测试环境用**真实数据 + 真实交互**测试本次改动(遵循「⚠️ 页面功能验证铁律」「⚠️ 用户视角测试铁律」)。
|
|
60
|
-
- 每步截图保留(遵循「⚠️
|
|
60
|
+
- 每步截图保留(遵循「⚠️ 截图规范」:真实视口 + `fullPage` 全页、URL 可见、存盘到 `docs/` 对应子目录),并用**坐标换算后的箭头标注**(走 `/screenshot-annotate` skill)关键改动区域/验证点,让看的人一眼看懂这张图证明了什么。
|
|
61
61
|
- 若本次改动是纯后端/基础设施,按「非 UI / 后端 / 基础设施改动的效果截图获取方法」把 curl 响应渲染成暗色终端 HTML 截图。
|
|
62
62
|
|
|
63
63
|
### 5. 确认没问题才算完成
|
|
@@ -67,12 +67,13 @@ description: >-
|
|
|
67
67
|
|
|
68
68
|
## 统一标注脚本 annotate.js
|
|
69
69
|
|
|
70
|
-
标注由 `
|
|
70
|
+
标注由 `annotate.js`(本 skill 目录内的零依赖 Node 脚本)完成。它把截图 + 坐标转成自包含标注 HTML(截图 base64 内嵌、SVG 箭头/框/文字精确放在换算后的坐标上),再用无头浏览器渲染为 PNG。
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
脚本与 SKILL.md 位于同一 skill 目录(随 agent-rules 同步到各项目 `.claude/skills/screenshot-annotate/`)。调用时定位到该目录:
|
|
73
73
|
|
|
74
74
|
```bash
|
|
75
|
-
|
|
75
|
+
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # 或显式 .claude/skills/screenshot-annotate 目录
|
|
76
|
+
node "$SKILL_DIR/annotate.js" \
|
|
76
77
|
shot.png shot-annot.html \
|
|
77
78
|
--json '[{"x":412,"y":1240,"w":160,"h":48,"text":"新增的保存按钮","color":"#22c55e"}]' \
|
|
78
79
|
--label "✅ 修复后" --labelColor "#f43f5e" --url "https://api-test.xxx/gateway/dashboard"
|
|
@@ -84,10 +85,12 @@ node /绝对路径/agent-rules/scripts/annotate.js \
|
|
|
84
85
|
|
|
85
86
|
## 渲染标注 HTML 为 PNG(固定流程)
|
|
86
87
|
|
|
88
|
+
用 `SKILL_DIR/annotate.js` 生成标注 HTML 后,用无头浏览器渲染为 PNG。
|
|
89
|
+
|
|
87
90
|
```bash
|
|
88
91
|
NS="claude-截图标注"
|
|
89
92
|
# 1. 无头打开标注 HTML
|
|
90
|
-
agent-browser --cdp 9226 --namespace "$NS" tab new "file
|
|
93
|
+
agent-browser --cdp 9226 --namespace "$NS" tab new "file:///<上一步生成的绝对路径>/shot-annot.html"
|
|
91
94
|
# 2. 设视口为标注画布尺寸(annotate.js 会打印「画布尺寸 WxH」)
|
|
92
95
|
agent-browser --cdp 9226 --namespace "$NS" set viewport <W> <H>
|
|
93
96
|
# 3. 全页截图
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* annotate.js — 生成「标注 HTML」(零依赖,Node 内置模块实现)
|
|
4
|
+
*
|
|
5
|
+
* 作用:把一张截图 + 若干标注坐标,生成一个自包含的 HTML 文件——
|
|
6
|
+
* 截图 base64 内嵌为底图,SVG 在精确换算后的坐标上画
|
|
7
|
+
* 「箭头 + 文本框 + 圆角高亮框 + 顶部标题条」。
|
|
8
|
+
* 标注文字/箭头由浏览器引擎渲染(中文完美、抗锯齿),
|
|
9
|
+
* 再由 agent-browser 用无头 CDP 把 HTML 截成最终标注图。
|
|
10
|
+
*
|
|
11
|
+
* 为什么这样设计:
|
|
12
|
+
* 标注不准的根因是「坐标单位换算错位 + 人工肉眼看图估位」。
|
|
13
|
+
* 本脚本强制走「元素 CSS 坐标(getBoundingClientRect) + 滚动量 → 截图设备像素」的换算,
|
|
14
|
+
* 换算结果直接画在 SVG 上,全程不经过人手目测。
|
|
15
|
+
*
|
|
16
|
+
* 坐标换算规则:
|
|
17
|
+
* - 默认:--json 里的 x/y/w/h 视为「截图设备像素坐标」(截图像素)。
|
|
18
|
+
* 画布里实际位置 = margin + 给定值。
|
|
19
|
+
* - 传 --viewport "cssW,cssH":x/y/w/h 视为「CSS 视口坐标」,
|
|
20
|
+
* 脚本先按 (截图宽/cssW)、(截图高/cssH) 换算成设备像素,再画。
|
|
21
|
+
* - 全页拼接截图时,元素相对整页的左/顶 = rect.left + scrollX / rect.top + scrollY,
|
|
22
|
+
* 把这两个和作为 x/y 传入(这就是整页里的设备像素位置,无需 --viewport)。
|
|
23
|
+
*
|
|
24
|
+
* 用法:
|
|
25
|
+
* node annotate.js <image> <outHtml> \
|
|
26
|
+
* --json '[{"x":412,"y":1240,"w":160,"h":48,"text":"新增的保存按钮","color":"#22c55e"}]' \
|
|
27
|
+
* --label "✅ 修复后" [--labelColor "#f43f5e"] [--viewport "1440,900"] [--margin-frac 0.05]
|
|
28
|
+
*
|
|
29
|
+
* <image> 输入截图(PNG/JPG/WebP…,尺寸由 IHDR / JPEG SOF / sips 探测)
|
|
30
|
+
* <outHtml> 输出的标注 HTML 路径
|
|
31
|
+
* --json 标注数组 JSON,每项:{x,y[,w,h][,text][,color][,stroke-width]}
|
|
32
|
+
* - x/y:目标点(默认设备像素坐标;传 --viewport 时为 CSS 坐标)
|
|
33
|
+
* - w/h:目标区域宽高(有则画高亮圆角框,箭头指向其中心)
|
|
34
|
+
* - text:文本框文字(浏览器渲染中文,自动排布在目标旁空闲侧)
|
|
35
|
+
* - color:主色(箭头/框/文本描边),缺省 #f43f5e
|
|
36
|
+
* --label 顶部标题条文字(适合「修复前/修复后」对比图),可省略
|
|
37
|
+
* --labelColor 标题条主题色,缺省 #f43f5e
|
|
38
|
+
* --viewport "cssW,cssH" 指定后坐标视为 CSS 视口坐标,自动换算
|
|
39
|
+
* --margin-frac 画布四周留白相对图片长边的比例(给箭头腾空间),缺省 0.05
|
|
40
|
+
* --url 可选:在标题条右侧显示页面 URL(用于「URL 可见」证据要求)
|
|
41
|
+
*
|
|
42
|
+
* 渲染为 PNG 的固定流程(配合 screenshot-annotate skill):
|
|
43
|
+
* 1. 生成标注 HTML: node annotate.js shot.png shot-annot.html --json '...' --label '...'
|
|
44
|
+
* 2. 无头打开并设视口为画布尺寸(画布比图片多四周留白):
|
|
45
|
+
* AGENT_BROWSER --cdp 9226 --namespace <ns> tab new "file://<abs>/shot-annot.html"
|
|
46
|
+
* AGENT_BROWSER --cdp 9226 --namespace <ns> set viewport <W> <H>
|
|
47
|
+
* 3. 截图: AGENT_BROWSER --cdp 9226 --namespace <ns> screenshot --full shot-annot.png
|
|
48
|
+
* 4. 关闭标签页: AGENT_BROWSER --cdp 9226 --namespace <ns> tab close <tabId>
|
|
49
|
+
* (W/H = annotate.js 打印的「画布尺寸」,即图片宽/高 + 2×margin)
|
|
50
|
+
* 本脚本与 SKILL.md 同目录(随 agent-rules 同步到各项目 .claude/skills/screenshot-annotate/)。
|
|
51
|
+
*/
|
|
52
|
+
"use strict";
|
|
53
|
+
|
|
54
|
+
const fs = require("fs");
|
|
55
|
+
const path = require("path");
|
|
56
|
+
const { execFileSync } = require("child_process");
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------- 参数解析
|
|
59
|
+
function parseArgs(argv) {
|
|
60
|
+
const positionals = [];
|
|
61
|
+
const keyed = {};
|
|
62
|
+
for (let i = 0; i < argv.length; i++) {
|
|
63
|
+
const a = argv[i];
|
|
64
|
+
if (a.startsWith("--")) {
|
|
65
|
+
const key = a.slice(2);
|
|
66
|
+
const next = argv[i + 1];
|
|
67
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
68
|
+
keyed[key] = next;
|
|
69
|
+
i++;
|
|
70
|
+
} else {
|
|
71
|
+
keyed[key] = true;
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
positionals.push(a);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { positionals, keyed };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function num(v, dflt) {
|
|
81
|
+
if (v === undefined || v === true || v === null || v === "") return dflt;
|
|
82
|
+
const n = Number(v);
|
|
83
|
+
return Number.isFinite(n) ? n : dflt;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------- 图片尺寸探测
|
|
87
|
+
function pngSize(buf) {
|
|
88
|
+
// 8 字节签名 + IHDR chunk(长度4 + 'IHDR' 4 + data 13)
|
|
89
|
+
if (
|
|
90
|
+
buf.length >= 24 &&
|
|
91
|
+
buf.readUInt32BE(0) === 0x89504e47 &&
|
|
92
|
+
buf.toString("ascii", 12, 16) === "IHDR"
|
|
93
|
+
) {
|
|
94
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function jpegSize(buf) {
|
|
100
|
+
if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null;
|
|
101
|
+
let pos = 2;
|
|
102
|
+
while (pos + 9 < buf.length) {
|
|
103
|
+
if (buf[pos] !== 0xff) {
|
|
104
|
+
pos++;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const marker = buf[pos + 1];
|
|
108
|
+
// SOF0..15(除 DHT C4、JPG C8、DAC CC、RST0-7 D0-D7、DNL DC、SOS DA、DRI DD、APP0-EF E0-EF、COM FE)
|
|
109
|
+
if (
|
|
110
|
+
marker >= 0xc0 &&
|
|
111
|
+
marker <= 0xcf &&
|
|
112
|
+
marker !== 0xc4 &&
|
|
113
|
+
marker !== 0xc8 &&
|
|
114
|
+
marker !== 0xcc &&
|
|
115
|
+
(marker < 0xd0 || marker > 0xd7) &&
|
|
116
|
+
marker !== 0xda &&
|
|
117
|
+
marker !== 0xdb &&
|
|
118
|
+
marker !== 0xdc
|
|
119
|
+
) {
|
|
120
|
+
const segLen = buf.readUInt16BE(pos + 2);
|
|
121
|
+
const height = buf.readUInt16BE(pos + 5);
|
|
122
|
+
const width = buf.readUInt16BE(pos + 7);
|
|
123
|
+
return { width, height };
|
|
124
|
+
}
|
|
125
|
+
const segLen = buf.readUInt16BE(pos + 2);
|
|
126
|
+
if (segLen < 2) return null;
|
|
127
|
+
pos += 2 + segLen;
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function sipsSize(filePath) {
|
|
133
|
+
try {
|
|
134
|
+
const out = execFileSync("sips", ["-g", "pixelWidth", "-g", "pixelHeight", filePath], {
|
|
135
|
+
encoding: "utf-8",
|
|
136
|
+
});
|
|
137
|
+
const w = Number((out.match(/pixelWidth:\s*(\d+)/) || [])[1]);
|
|
138
|
+
const h = Number((out.match(/pixelHeight:\s*(\d+)/) || [])[1]);
|
|
139
|
+
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
|
|
140
|
+
return { width: w, height: h };
|
|
141
|
+
}
|
|
142
|
+
} catch (e) {
|
|
143
|
+
/* fallthrough */
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function detectSize(filePath, buf) {
|
|
149
|
+
return pngSize(buf) || jpegSize(buf) || sipsSize(filePath);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------- HTML 安全
|
|
153
|
+
function escapeHtml(s) {
|
|
154
|
+
return String(s)
|
|
155
|
+
.replace(/&/g, "&")
|
|
156
|
+
.replace(/</g, "<")
|
|
157
|
+
.replace(/>/g, ">")
|
|
158
|
+
.replace(/"/g, """)
|
|
159
|
+
.replace(/'/g, "'");
|
|
160
|
+
}
|
|
161
|
+
function extMime(filePath) {
|
|
162
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
163
|
+
const map = {
|
|
164
|
+
".png": "image/png",
|
|
165
|
+
".jpg": "image/jpeg",
|
|
166
|
+
".jpeg": "image/jpeg",
|
|
167
|
+
".gif": "image/gif",
|
|
168
|
+
".webp": "image/webp",
|
|
169
|
+
".svg": "image/svg+xml",
|
|
170
|
+
".bmp": "image/bmp",
|
|
171
|
+
};
|
|
172
|
+
return map[ext] || "image/png";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------- 布局工具(生成 SVG 常量)
|
|
176
|
+
// 文字排版:根据中文字符数近似估算宽度(一个汉字≈1.0em,拉丁≈0.55em)
|
|
177
|
+
function estimateTextMetrics(text, fontSize) {
|
|
178
|
+
const chars = [...text];
|
|
179
|
+
let w = 0;
|
|
180
|
+
for (const ch of chars) {
|
|
181
|
+
const code = ch.codePointAt(0);
|
|
182
|
+
const width = code > 0x2e80 ? fontSize : fontSize * 0.55;
|
|
183
|
+
w += width;
|
|
184
|
+
}
|
|
185
|
+
return { width: Math.ceil(w) + 2 * fontSize, height: Math.ceil(fontSize * 1.7) };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------- 生成标注 HTML
|
|
189
|
+
function buildHtml({
|
|
190
|
+
imagePath,
|
|
191
|
+
imageMime,
|
|
192
|
+
imageW,
|
|
193
|
+
imageH,
|
|
194
|
+
annotations,
|
|
195
|
+
label,
|
|
196
|
+
labelColor,
|
|
197
|
+
url,
|
|
198
|
+
marginFrac,
|
|
199
|
+
}) {
|
|
200
|
+
const margin = Math.max(24, Math.round(Math.max(imageW, imageH) * marginFrac));
|
|
201
|
+
const W = imageW + margin * 2;
|
|
202
|
+
const H = imageH + margin * 2;
|
|
203
|
+
const imgData = fs.readFileSync(imagePath).toString("base64");
|
|
204
|
+
const dataUri = `data:${imageMime};base64,${imgData}`;
|
|
205
|
+
|
|
206
|
+
// 预处理标注:换算坐标(CSS→设备像素若给 viewport)、排版文本
|
|
207
|
+
const placed = [];
|
|
208
|
+
for (const a of annotations) {
|
|
209
|
+
const color = a.color || "#f43f5e";
|
|
210
|
+
const lw = num(a["stroke-width"], Math.max(3, Math.round(W / 900)));
|
|
211
|
+
// 换算已在 main() 中完成(CSS 坐标 → 设备像素)
|
|
212
|
+
const x = num(a.x, 0);
|
|
213
|
+
const y = num(a.y, 0);
|
|
214
|
+
const w = num(a.w, 0);
|
|
215
|
+
const h = num(a.h, 0);
|
|
216
|
+
placed.push({ ...a, x, y, w, h, color, lw });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 文本距离目标框
|
|
220
|
+
const gap = Math.max(30, Math.round(Math.min(imageW, imageH) * 0.03));
|
|
221
|
+
|
|
222
|
+
// 生成每条标注的 SVG 片段
|
|
223
|
+
const parts = [];
|
|
224
|
+
for (const a of placed) {
|
|
225
|
+
// 目标中心
|
|
226
|
+
const tx = a.x + a.w / 2;
|
|
227
|
+
const ty = a.y + a.h / 2;
|
|
228
|
+
const fs = Math.max(16, Math.round(imageW / 90));
|
|
229
|
+
const metrics = a.text ? estimateTextMetrics(a.text, fs) : { width: 0, height: 0 };
|
|
230
|
+
const boxW = Math.ceil((metrics.width || 0) + fs * 1.2);
|
|
231
|
+
const boxH = Math.ceil(metrics.height + fs * 0.6);
|
|
232
|
+
const pad = 8;
|
|
233
|
+
const bw = a.text ? boxW : 0;
|
|
234
|
+
const bh = a.text ? boxH : 0;
|
|
235
|
+
|
|
236
|
+
// 文本候选方位(上、下、左、右),取第一个不越界且不遮目标的
|
|
237
|
+
const cands = [
|
|
238
|
+
{ bx: tx - bw / 2, by: ty - gap - bh, name: "up" },
|
|
239
|
+
{ bx: tx - bw / 2, by: ty + gap, name: "down" },
|
|
240
|
+
{ bx: tx - gap - bw, by: ty - bh / 2, name: "left" },
|
|
241
|
+
{ bx: tx + gap, by: ty - bh / 2, name: "right" },
|
|
242
|
+
];
|
|
243
|
+
let slot = null;
|
|
244
|
+
for (const c of cands) {
|
|
245
|
+
const inX = c.bx - pad > margin && c.bx + bw + pad < margin + imageW;
|
|
246
|
+
const inY = c.by - pad > margin && c.by + bh + pad < margin + imageH;
|
|
247
|
+
let overlap = false;
|
|
248
|
+
if (a.w > 0 && a.h > 0) {
|
|
249
|
+
overlap = rectsOverlap(c.bx, c.by, bw, bh, a.x, a.y, a.w, a.h);
|
|
250
|
+
}
|
|
251
|
+
if (inX && inY && !overlap) {
|
|
252
|
+
slot = c;
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (!slot) slot = cands[0];
|
|
257
|
+
|
|
258
|
+
const hasTarget = a.w > 0 && a.h > 0;
|
|
259
|
+
const hasText = Boolean(a.text);
|
|
260
|
+
|
|
261
|
+
// 高亮目标框
|
|
262
|
+
if (hasTarget) {
|
|
263
|
+
const rx = Math.max(8, a.lw * 1.8);
|
|
264
|
+
parts.push(`
|
|
265
|
+
<rect x="${a.x}" y="${a.y}" width="${a.w}" height="${a.h}" rx="${rx}" ry="${rx}"
|
|
266
|
+
fill="${a.color}" fill-opacity="0.14" stroke="${a.color}" stroke-width="${a.lw}"
|
|
267
|
+
vector-effect="non-scaling-stroke" />`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 文本框
|
|
271
|
+
if (hasText) {
|
|
272
|
+
const rx = Math.max(6, a.lw * 1.5);
|
|
273
|
+
parts.push(`
|
|
274
|
+
<rect x="${slot.bx}" y="${slot.by}" width="${bw}" height="${bh}" rx="${rx}" ry="${rx}"
|
|
275
|
+
fill="#ffffff" fill-opacity="0.92" stroke="${a.color}" stroke-width="${Math.max(
|
|
276
|
+
1,
|
|
277
|
+
Math.round(a.lw * 0.8),
|
|
278
|
+
)}"
|
|
279
|
+
vector-effect="non-scaling-stroke" />`);
|
|
280
|
+
parts.push(`
|
|
281
|
+
<text x="${slot.bx + bw / 2}" y="${slot.by + bh / 2}" font-family="system-ui, -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif"
|
|
282
|
+
font-size="${fs}" font-weight="600" text-anchor="middle" dominant-baseline="central"
|
|
283
|
+
fill="${textContrast(a.color)}">${escapeHtml(a.text)}</text>`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 箭头:文本框中心 → 目标中心
|
|
287
|
+
const sx = slot.bx + bw / 2;
|
|
288
|
+
const sy = slot.by + bh / 2;
|
|
289
|
+
const dx = tx - sx;
|
|
290
|
+
const dy = ty - sy;
|
|
291
|
+
const len = Math.sqrt(dx * dx + dy * dy);
|
|
292
|
+
if (len > 1) {
|
|
293
|
+
// 起点避开文本框(沿方向外移半框)
|
|
294
|
+
const star = Math.min(1, Math.max(0, (bw * 0.5 + a.lw * 2) / len));
|
|
295
|
+
const ex = (star * dx) / len;
|
|
296
|
+
const ey = (star * dy) / len;
|
|
297
|
+
const sx2 = sx + ex * len;
|
|
298
|
+
const sy2 = sy + ey * len;
|
|
299
|
+
const arrowLen = Math.max(12, a.lw * 3.2);
|
|
300
|
+
const ang = Math.atan2(ty - sy, tx - sx);
|
|
301
|
+
const a1 = ang + Math.PI / 6;
|
|
302
|
+
const a2 = ang - Math.PI / 6;
|
|
303
|
+
parts.push(`
|
|
304
|
+
<line x1="${sx2}" y1="${sy2}" x2="${tx}" y2="${ty}" stroke="${a.color}"
|
|
305
|
+
stroke-width="${a.lw}" stroke-linecap="round" vector-effect="non-scaling-stroke" />`);
|
|
306
|
+
parts.push(`
|
|
307
|
+
<path d="M ${tx} ${ty} L ${tx - arrowLen * Math.cos(a1)} ${ty - arrowLen * Math.sin(a1)} L ${
|
|
308
|
+
tx - arrowLen * Math.cos(a2)
|
|
309
|
+
} ${ty - arrowLen * Math.sin(a2)} Z"
|
|
310
|
+
fill="${a.color}" />`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// 标题条
|
|
315
|
+
let bar = "";
|
|
316
|
+
if (label) {
|
|
317
|
+
const barH = Math.max(40, Math.round(imageH * 0.05));
|
|
318
|
+
const fs = Math.max(20, Math.round(imageW / 55));
|
|
319
|
+
const lc = /^#[0-9a-fA-F]{6}$/.test(labelColor) ? labelColor : "#f43f5e";
|
|
320
|
+
const urlText = url ? escapeHtml(url) : "";
|
|
321
|
+
bar = `
|
|
322
|
+
<rect x="${margin}" y="${margin}" width="${imageW}" height="${barH}" rx="10" ry="10"
|
|
323
|
+
fill="${lc}" fill-opacity="0.9" />
|
|
324
|
+
<text x="${margin + imageW / 2}" y="${margin + barH / 2}" font-family="system-ui, sans-serif"
|
|
325
|
+
font-size="${fs}" font-weight="700" text-anchor="middle" dominant-baseline="central"
|
|
326
|
+
fill="#ffffff">${escapeHtml(label)}</text>
|
|
327
|
+
${
|
|
328
|
+
urlText
|
|
329
|
+
? `<text x="${margin + imageW - 14}" y="${margin + barH / 2}" font-family="ui-monospace, Menlo, monospace"
|
|
330
|
+
font-size="${Math.round(fs * 0.55)}" text-anchor="end" dominant-baseline="central"
|
|
331
|
+
fill="#ffffff" fill-opacity="0.92">${urlText}</text>`
|
|
332
|
+
: ""
|
|
333
|
+
}`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return `<!doctype html>
|
|
337
|
+
<html>
|
|
338
|
+
<head>
|
|
339
|
+
<meta charset="utf-8" />
|
|
340
|
+
<title>标注</title>
|
|
341
|
+
<style>
|
|
342
|
+
html, body { margin:0; padding:0; overflow:hidden; background:#000; }
|
|
343
|
+
.stage { position:relative; width:${W}px; height:${H}px; }
|
|
344
|
+
.stage img { position:absolute; left:${margin}px; top:${margin}px; width:${imageW}px; height:${imageH}px; }
|
|
345
|
+
.stage svg { position:absolute; left:0; top:0; width:${W}px; height:${H}px; }
|
|
346
|
+
</style>
|
|
347
|
+
</head>
|
|
348
|
+
<body>
|
|
349
|
+
<div class="stage">
|
|
350
|
+
<img src="${dataUri}" />
|
|
351
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">
|
|
352
|
+
${bar}
|
|
353
|
+
${parts.join("\n")}
|
|
354
|
+
</svg>
|
|
355
|
+
</div>
|
|
356
|
+
</body>
|
|
357
|
+
</html>`;
|
|
358
|
+
|
|
359
|
+
function rectsOverlap(x1, y1, w1, h1, x2, y2, w2, h2) {
|
|
360
|
+
return !(x1 + w1 < x2 || x2 + w2 < x1 || y1 + h1 < y2 || y2 + h2 < y1);
|
|
361
|
+
}
|
|
362
|
+
function textContrast(colorHex) {
|
|
363
|
+
const c = colorHex.replace("#", "");
|
|
364
|
+
const r = parseInt(c.slice(0, 2), 16) || 0;
|
|
365
|
+
const g = parseInt(c.slice(2, 4), 16) || 0;
|
|
366
|
+
const b = parseInt(c.slice(4, 6), 16) || 0;
|
|
367
|
+
return (0.2126 * r + 0.7152 * g + 0.0722 * b) > 140 ? "#1a1a1a" : "#ffffff";
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ---------------------------------------------------------------- 主流程
|
|
372
|
+
function main() {
|
|
373
|
+
const { positionals, keyed } = parseArgs(process.argv.slice(2));
|
|
374
|
+
const [imagePath, outHtml] = positionals;
|
|
375
|
+
if (!imagePath || !outHtml) {
|
|
376
|
+
console.error(
|
|
377
|
+
"用法: node annotate.js <image> <outHtml> [--json \"...\"] [--label \"...\"] [--labelColor \"#fff\"] [--viewport \"cssW,cssH\"] [--margin-frac N] [--url \"...\"]",
|
|
378
|
+
);
|
|
379
|
+
process.exit(1);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (!fs.existsSync(imagePath)) {
|
|
383
|
+
console.error(`❌ 图片不存在: ${imagePath}`);
|
|
384
|
+
process.exit(1);
|
|
385
|
+
}
|
|
386
|
+
const buf = fs.readFileSync(imagePath);
|
|
387
|
+
const size = detectSize(imagePath, buf);
|
|
388
|
+
if (!size) {
|
|
389
|
+
console.error(`❌ 无法探测图片尺寸(需 IHDR / JPEG SOF / sips): ${imagePath}`);
|
|
390
|
+
process.exit(1);
|
|
391
|
+
}
|
|
392
|
+
const imageW = size.width;
|
|
393
|
+
const imageH = size.height;
|
|
394
|
+
|
|
395
|
+
let annotations = [];
|
|
396
|
+
if (keyed.json) {
|
|
397
|
+
try {
|
|
398
|
+
annotations = JSON.parse(keyed.json);
|
|
399
|
+
} catch (e) {
|
|
400
|
+
console.error("❌ --json 解析失败:", e.message);
|
|
401
|
+
process.exit(1);
|
|
402
|
+
}
|
|
403
|
+
if (!Array.isArray(annotations)) {
|
|
404
|
+
console.error("❌ --json 必须是标注数组");
|
|
405
|
+
process.exit(1);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// 坐标换算:CSS 坐标 → 设备像素
|
|
410
|
+
let viewport = null;
|
|
411
|
+
if (keyed.viewport && keyed.viewport !== true) {
|
|
412
|
+
const [vw, vh] = String(keyed.viewport).split(",").map(Number);
|
|
413
|
+
if (Number.isFinite(vw) && Number.isFinite(vh) && vw > 0 && vh > 0) {
|
|
414
|
+
viewport = { w: vw, h: vh };
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const scaleX = viewport ? imageW / viewport.w : 1;
|
|
418
|
+
const scaleY = viewport ? imageH / viewport.h : 1;
|
|
419
|
+
annotations = annotations.map((a) => ({
|
|
420
|
+
...a,
|
|
421
|
+
x: num(a.x, 0) * scaleX,
|
|
422
|
+
y: num(a.y, 0) * scaleY,
|
|
423
|
+
w: num(a.w, 0) * scaleX,
|
|
424
|
+
h: num(a.h, 0) * scaleY,
|
|
425
|
+
}));
|
|
426
|
+
|
|
427
|
+
const marginFrac = num(keyed["margin-frac"], 0.05);
|
|
428
|
+
const html = buildHtml({
|
|
429
|
+
imagePath,
|
|
430
|
+
imageMime: extMime(imagePath),
|
|
431
|
+
imageW,
|
|
432
|
+
imageH,
|
|
433
|
+
annotations,
|
|
434
|
+
label: keyed.label || "",
|
|
435
|
+
labelColor: keyed.labelColor || "#f43f5e",
|
|
436
|
+
url: keyed.url || "",
|
|
437
|
+
marginFrac,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
fs.writeFileSync(outHtml, html);
|
|
441
|
+
const margin = Math.max(24, Math.round(Math.max(imageW, imageH) * marginFrac));
|
|
442
|
+
console.log(
|
|
443
|
+
`✅ 已生成标注 HTML → ${outHtml}`,
|
|
444
|
+
);
|
|
445
|
+
console.log(` 画布尺寸 ${imageW + margin * 2}×${imageH + margin * 2}(视口截图时用)`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
main();
|
|
@@ -32,7 +32,7 @@ description: >-
|
|
|
32
32
|
|
|
33
33
|
| 改动类型 | 证据来源 | 具体动作 |
|
|
34
34
|
|------|------|------|
|
|
35
|
-
| 页面 / 界面改动 | 真实页面 | 打开真实页面,用真实数据走真实交互(点击、填写、提交、等待渲染),全页截图 +
|
|
35
|
+
| 页面 / 界面改动 | 真实页面 | 打开真实页面,用真实数据走真实交互(点击、填写、提交、等待渲染),全页截图 + 坐标换算后的箭头标注关键改动区域(/screenshot-annotate) |
|
|
36
36
|
| Redis 相关 | Redis for VS Code 插件 | 查看运行时 Redis 的 key/value,A/B 对比改动前后的值(SET 已知值 → 触发业务动作 → 截图看值是否如预期) |
|
|
37
37
|
| 数据库相关 | SQLTools 插件 | 查看运行时数据库的 row 数据,A/B 对比改动前后的值(插入/更新 → 触发业务动作 → 截图看落库是否如预期) |
|
|
38
38
|
| 纯后端接口 / 定时任务无页面 | 运行时真实返回 | curl / 日志 / 查库 / 查 Redis,把结果渲染成可视化证据(暗色终端风格的请求/响应对比 HTML 截图,或 Redis/SQLTools 截图) |
|
|
@@ -53,7 +53,7 @@ description: >-
|
|
|
53
53
|
2. **「为什么这样验证成立」原理说明卡**:先讲清楚 bug/功能差异的本质是哪一个动作,再论证「手动模拟该动作 = 真实场景」
|
|
54
54
|
3. **A/B 对照表**:修复前 vs 修复后(或改动前 vs 改动后),代码行为 / 等价操作 / 观测结果三列并排
|
|
55
55
|
4. **每步截图**:必须带箭头标注关键区域/验证点,图注写清「这张图证明了什么」,标注关键行/关键数据
|
|
56
|
-
- 截图本身遵循「⚠️
|
|
56
|
+
- 截图本身遵循「⚠️ 截图规范」:真实视口 + `fullPage` 全页、URL 可见、存到 `docs/` 对应子目录、文件名「编号 + 英文描述」。截图上的箭头/框标注统一走 `/screenshot-annotate` skill(坐标精确换算,禁止肉眼估位)。
|
|
57
57
|
|
|
58
58
|
## 不写测试 ≠ 不查错
|
|
59
59
|
|