Files
workbuddy_skills/product-planning/assets/element-picker/ui-picker-server.js
T
admin 8160f91559 技能库同步:另一台机的 2026-10-16 包 + element-picker 入包 + 工具归拢到技能级 assets/
product-planning(同步 26 改 65 新)
- 目录约定:docs/pm/<项目>/ ⇒ <工作区>/项目/<产品slug>/ 为产品根,下分 产品规划/ 与 产品原型/{designs,pages}
- ④段骨架六节 ⇒ 七节(新增「原型地址」;后两节改「页面导航」「原型状态取值」)
- ③段新增真机组件库;结案「源站只有暗色主题」,tokens.css(亮色)标为不可用于生产
- ③段改版规则反转为「一个产品只用一套原型、只留当前态」;②段新增 产品规划模板.md(表头唯一定义处)
- scripts:产品根定位重写(含 .registry/current-project 指针),新增 check_terms.py 与 selftest_product_root.py

session-mechanism(同步 3 改 2 新)
- references/06-技能优化规程.md 新增 §5「要求落在生成时看得见的面上」,总表 16 ⇒ 17 条;SKILL.md 同步
- scripts/collabctl.py 取回 _find_board() 候选定位(旧版写死 scripts/board.py,而看板已搬进 boards/会话看板/ ⇒ 旧版本就是坏的)

element-picker(新装)
- 按用户口径作「独立功能加入 product-planning」,非独立全局技能
- 修掉它自身三处路径漂移(技能根写法 ⇒ 同目录;README 两处旧落点;剪贴板示例文案与代码对齐)

工具归拢到技能级 assets/
- diagram-design / design-capture / element-picker / video-capture 从各段 assets/ 移到技能包 assets/
- 新增红线:本包有两处 assets/(技能级工具箱 vs 段内资产),引工具一律写「技能级 assets/<工具名>/」
- 路径口径由两类改三类(技能级工具箱 / 本段自带资产 / 同根技能)

不入库(磁盘保留)
- design-system-tiaoyue 整个目录(用户 2026-10-16 令):git rm -r --cached + .gitignore,理由=14 MB 且可 CDP 重采;
  已在 ③段 SKILL.md 注明「取仓后这一层是空的属正常,用自带套需另取或重采」
- element-picker 的运行期存档 marks/(服务自己会建目录)
2026-10-16 23:13:33 +08:00

126 lines
4.4 KiB
JavaScript

#!/usr/bin/env node
/**
* ui-picker-server.js —— 极简「页面元素拾取」服务(零依赖)
*
* 干什么:给浏览器里的拾取器提供一个落脚点 ——
* 页面点选元素后,把「选择器 / 网页 / 文本 / class / 关键样式 / HTML 片段」
* 追加写进本技能目录下的 marks/_ui-marks.json,供 AI 读取后精确改代码。
*
* 起法: node ui-picker-server.js [端口] (在本目录下执行,默认 8890)
* 页面: <script src="http://127.0.0.1:8890/picker.js"></script>
* URL 带 ?pick 才激活;按 Z 切拾取模式;点元素 → 确认窗 → 「确认」
*
* 端点:
* GET /picker.js 注入脚本
* POST /mark 存一条标记(追加写盘)
* GET /marks 读回全部标记(JSON)
* POST /clear 清空标记
* GET /health 存活检查
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = parseInt(process.argv[2], 10) || 8890;
// 存档跟技能一起放,搬到哪都自洽
const OUT_DIR = path.join(__dirname, 'marks');
const OUT_FILE = path.join(OUT_DIR, '_ui-marks.json');
function readMarks() {
try {
const raw = fs.readFileSync(OUT_FILE, 'utf8');
const obj = JSON.parse(raw);
return Array.isArray(obj.marks) ? obj : { updated: '', marks: [] };
} catch {
return { updated: '', marks: [] };
}
}
function writeMarks(obj) {
fs.mkdirSync(OUT_DIR, { recursive: true });
obj.updated = new Date().toISOString();
fs.writeFileSync(OUT_FILE, JSON.stringify(obj, null, 2), 'utf8');
}
function send(res, code, body, type) {
res.writeHead(code, {
'Content-Type': type || 'application/json; charset=utf-8',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
'Cache-Control': 'no-store',
});
res.end(body);
}
function readBody(req) {
return new Promise((resolve, reject) => {
let buf = '';
req.on('data', (c) => {
buf += c;
if (buf.length > 8 * 1024 * 1024) reject(new Error('body too large'));
});
req.on('end', () => resolve(buf));
req.on('error', reject);
});
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
if (req.method === 'OPTIONS') return send(res, 204, '');
if (req.method === 'GET' && url.pathname === '/health') {
return send(res, 200, JSON.stringify({ ok: true, skill: __dirname, file: OUT_FILE, count: readMarks().marks.length }));
}
if (req.method === 'GET' && url.pathname === '/picker.js') {
return send(res, 200, fs.readFileSync(path.join(__dirname, 'picker.js'), 'utf8'), 'application/javascript; charset=utf-8');
}
if (req.method === 'GET' && url.pathname === '/marks') {
return send(res, 200, fs.readFileSync(OUT_FILE, 'utf8').toString());
}
if (req.method === 'POST' && url.pathname === '/mark') {
try {
const body = JSON.parse(await readBody(req));
const store = readMarks();
const item = {
id: store.marks.length + 1,
at: new Date().toISOString(),
page: body.page || '',
note: body.note || '',
selector: body.selector || '',
breadcrumb: body.breadcrumb || '',
tag: body.tag || '',
classes: body.classes || [],
text: body.text || '',
attrs: body.attrs || {},
rect: body.rect || null,
styles: body.styles || {},
html: body.html || '',
};
store.marks.push(item);
writeMarks(store);
console.log(`[mark #${item.id}] ${item.selector} ← ${item.note}`);
return send(res, 200, JSON.stringify({ ok: true, id: item.id, total: store.marks.length }));
} catch (e) {
return send(res, 400, JSON.stringify({ ok: false, error: String(e.message || e) }));
}
}
if (req.method === 'POST' && url.pathname === '/clear') {
writeMarks({ updated: '', marks: [] });
return send(res, 200, JSON.stringify({ ok: true, total: 0 }));
}
return send(res, 404, JSON.stringify({ ok: false, error: 'not found' }));
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`元素拾取服务已启动: http://127.0.0.1:${PORT}`);
console.log(`页面引入: <script src="http://127.0.0.1:${PORT}/picker.js"></script>`);
console.log(`标记落盘: ${OUT_FILE}`);
});