comate-ui-inspector 0.1.5-alpha.5 → 0.1.5

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.
@@ -1,8 +1,10 @@
1
1
  /*
2
- * MAIN world 录制脚本(增强包):录制期间抓取并**逐条上报**
3
- * - 点击的元素(含 Vue 组件源码链,和选择模式同源)
4
- * - console.log/info/warn/error
5
- * - 同域名下的 fetch / XMLHttpRequest(方法/URL/状态/发起与完成时间)
2
+ * MAIN world 录制脚本(增强包):录制期间抓取并**逐条上报**常见用户事件
3
+ * - 点击 click(含 Vue 组件源码链,和选择模式同源)
4
+ * - 滚动 scroll(trailing 防抖,记一段滚动的落点)
5
+ * - 表单变更 change(密码脱敏)、表单提交 submit、操作键 keydown(仅 Enter/Escape)
6
+ * - console.log/info/warn/error(可选)
7
+ * - 同域名下的 fetch / XMLHttpRequest(可选,含发起与完成时间)
6
8
  * 每个事件即时 postMessage 出去,经 bridge→background→host/bridge 追加进 recording.json。
7
9
  * 这样页面刷新/跳转也不丢数据:缓冲在后端,不在页面内存,录制可跨页面连续进行。
8
10
  * 本脚本自身无状态缓冲;未开始录制时完全惰性,不打补丁、无开销。
@@ -92,6 +94,79 @@
92
94
  });
93
95
  }
94
96
 
97
+ // 滚动:高频事件,trailing 防抖 300ms,只记一段滚动结束时的落点。
98
+ var scrollTimer = null;
99
+ var lastScroll = null;
100
+ function onScroll(e) {
101
+ if (!on) {
102
+ return;
103
+ }
104
+ var t = e.target;
105
+ var winLevel = t === document || t === window || t === document.documentElement || t === document.body;
106
+ var x = winLevel ? window.scrollX || window.pageXOffset || 0 : t.scrollLeft;
107
+ var y = winLevel ? window.scrollY || window.pageYOffset || 0 : t.scrollTop;
108
+ lastScroll = {
109
+ selector: winLevel ? '(window)' : cssPath(t),
110
+ x: Math.round(x),
111
+ y: Math.round(y),
112
+ ts: Date.now(),
113
+ };
114
+ if (scrollTimer) {
115
+ clearTimeout(scrollTimer);
116
+ }
117
+ scrollTimer = setTimeout(function () {
118
+ scrollTimer = null;
119
+ if (on && lastScroll) {
120
+ emit('scroll', lastScroll);
121
+ lastScroll = null;
122
+ }
123
+ }, 300);
124
+ }
125
+
126
+ // 表单值变更(提交态:blur / 回车 / 选择后触发,天然去抖);密码脱敏,其余截断。
127
+ function onChange(e) {
128
+ if (!on) {
129
+ return;
130
+ }
131
+ var el = e.target;
132
+ if (!el || el.nodeType !== 1) {
133
+ return;
134
+ }
135
+ var type = (el.getAttribute && (el.getAttribute('type') || '')).toLowerCase();
136
+ var val;
137
+ if (type === 'password') {
138
+ val = '••••••';
139
+ } else if (type === 'checkbox' || type === 'radio') {
140
+ val = el.checked ? 'checked' : 'unchecked';
141
+ } else {
142
+ val = String(el.value == null ? '' : el.value).slice(0, 100);
143
+ }
144
+ emit('change', {
145
+ selector: cssPath(el),
146
+ tag: el.nodeName.toLowerCase(),
147
+ type: type || undefined,
148
+ value: val,
149
+ ts: Date.now(),
150
+ });
151
+ }
152
+
153
+ function onSubmit(e) {
154
+ if (!on) {
155
+ return;
156
+ }
157
+ var el = e.target;
158
+ emit('submit', {selector: el && el.nodeType === 1 ? cssPath(el) : '(form)', ts: Date.now()});
159
+ }
160
+
161
+ // 只记有意义的操作键(Enter / Escape),避免逐字记录输入内容(噪声 + 隐私)。
162
+ function onKeydown(e) {
163
+ if (!on || (e.key !== 'Enter' && e.key !== 'Escape')) {
164
+ return;
165
+ }
166
+ var el = e.target;
167
+ emit('keydown', {key: e.key, selector: el && el.nodeType === 1 ? cssPath(el) : '', ts: Date.now()});
168
+ }
169
+
95
170
  function startConsole() {
96
171
  ['log', 'info', 'warn', 'error'].forEach(function (level) {
97
172
  saved['c_' + level] = console[level];
@@ -208,6 +283,10 @@
208
283
  }
209
284
  on = {console: !!opts.console, network: !!opts.network};
210
285
  document.addEventListener('click', onClick, true);
286
+ document.addEventListener('scroll', onScroll, true);
287
+ document.addEventListener('change', onChange, true);
288
+ document.addEventListener('submit', onSubmit, true);
289
+ document.addEventListener('keydown', onKeydown, true);
211
290
  if (on.console) {
212
291
  startConsole();
213
292
  }
@@ -221,6 +300,14 @@
221
300
  return;
222
301
  }
223
302
  document.removeEventListener('click', onClick, true);
303
+ document.removeEventListener('scroll', onScroll, true);
304
+ document.removeEventListener('change', onChange, true);
305
+ document.removeEventListener('submit', onSubmit, true);
306
+ document.removeEventListener('keydown', onKeydown, true);
307
+ if (scrollTimer) {
308
+ clearTimeout(scrollTimer);
309
+ scrollTimer = null;
310
+ }
224
311
  stopConsole();
225
312
  stopNetwork();
226
313
  on = null;
@@ -90,11 +90,14 @@
90
90
  panel.style.display = 'block';
91
91
  panelCount.textContent = '已选中 ' + selected.length + ' 个';
92
92
  panelList.textContent = '';
93
+ renumber();
93
94
  selected.forEach(function (item, i) {
94
95
  var info = item.info || {};
95
- var files = (info.componentChain || []).map(function (c) {
96
- return c && c.file;
97
- }).filter(Boolean);
96
+ var files = (info.componentChain || [])
97
+ .map(function (c) {
98
+ return c && c.file;
99
+ })
100
+ .filter(Boolean);
98
101
  var row = document.createElement('div');
99
102
  row.style.cssText =
100
103
  'display:flex;align-items:flex-start;gap:6px;padding:6px 0;border-top:1px solid #2a2a2a;';
@@ -102,7 +105,8 @@
102
105
  meta.style.cssText = 'flex:1;min-width:0;';
103
106
  var title = document.createElement('div');
104
107
  title.style.cssText = 'font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
105
- title.textContent = i + 1 + '. <' + (info.tag || '?') + '>' + (info.text ? ' ' + info.text.slice(0, 20) : '');
108
+ title.textContent =
109
+ i + 1 + '. <' + (info.tag || '?') + '>' + (info.text ? ' ' + info.text.slice(0, 20) : '');
106
110
  var sub = document.createElement('div');
107
111
  sub.style.cssText = 'color:#9aa0a6;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
108
112
  sub.textContent = files.length ? files[0].split('/').pop() : info.selector || '';
@@ -307,10 +311,26 @@
307
311
  box.style.cssText =
308
312
  'position:fixed;z-index:2147483645;pointer-events:none;border:2px solid #16a34a;' +
309
313
  'background:rgba(22,163,74,0.10);border-radius:4px;';
314
+ var badge = document.createElement('div');
315
+ badge.className = 'oreate-sel-badge';
316
+ badge.style.cssText =
317
+ 'position:absolute;left:-2px;top:-2px;transform:translateY(-100%);background:#16a34a;color:#fff;' +
318
+ 'font:11px/1.5 sans-serif;padding:0 6px;border-radius:4px 4px 4px 0;white-space:nowrap;';
319
+ box.appendChild(badge);
310
320
  document.body.appendChild(box);
311
321
  return box;
312
322
  }
313
323
 
324
+ // 按当前选中顺序刷新每个高亮框上的数字角标(删除中间项后序号会重排)。
325
+ function renumber() {
326
+ for (var i = 0; i < selected.length; i++) {
327
+ var b = selected[i].box.querySelector('.oreate-sel-badge');
328
+ if (b) {
329
+ b.textContent = String(i + 1);
330
+ }
331
+ }
332
+ }
333
+
314
334
  function placeBox(box, el) {
315
335
  var rect = el.getBoundingClientRect();
316
336
  box.style.left = rect.left + 'px';
@@ -33,16 +33,14 @@ function readJson(file, fallback) {
33
33
  }
34
34
  }
35
35
 
36
- // 读 append-only 事件流,按类型归并
36
+ // 读 append-only 事件流,返回有序的 {kind, item} 列表(各类用户事件混在一起,按写入顺序)。
37
37
  function readEvents() {
38
- const clicks = [];
39
- const con = [];
40
- const network = [];
38
+ const out = [];
41
39
  let text = '';
42
40
  try {
43
41
  text = readFileSync(eventsFile, 'utf8');
44
42
  } catch {
45
- return {clicks, console: con, network};
43
+ return out;
46
44
  }
47
45
  for (const line of text.split('\n')) {
48
46
  if (!line.trim()) {
@@ -54,18 +52,11 @@ function readEvents() {
54
52
  } catch {
55
53
  continue;
56
54
  }
57
- if (!ev || !ev.item) {
58
- continue;
59
- }
60
- if (ev.kind === 'click') {
61
- clicks.push(ev.item);
62
- } else if (ev.kind === 'console') {
63
- con.push(ev.item);
64
- } else if (ev.kind === 'network') {
65
- network.push(ev.item);
55
+ if (ev && ev.kind && ev.item) {
56
+ out.push(ev);
66
57
  }
67
58
  }
68
- return {clicks, console: con, network};
59
+ return out;
69
60
  }
70
61
 
71
62
  const parsed = readJson(storeFile, []);
@@ -74,9 +65,8 @@ const meta = readJson(recordingFile, null);
74
65
  // 只注入**已停止**的录制(active === false)。录制进行中(跨刷新/跨页面累积)时不消费、不清空,
75
66
  // 以免中途给 Comate 发消息把整段会话吞掉。
76
67
  const recDone = meta && meta.active === false;
77
- const events = recDone ? readEvents() : {clicks: [], console: [], network: []};
78
- const recording = recDone ? {...meta, ...events} : null;
79
- const hasRec = recDone && (events.clicks.length || events.console.length || events.network.length);
68
+ const recEvents = recDone ? readEvents() : [];
69
+ const hasRec = recDone && recEvents.length > 0;
80
70
 
81
71
  if (!list.length && !hasRec) {
82
72
  emit({decision: 'allow'});
@@ -95,38 +85,52 @@ function describe(target, index, total) {
95
85
  .join('\n');
96
86
  }
97
87
 
98
- function describeRecording(r) {
99
- const base = r.startedAt || 0;
100
- const secs = Math.round(((r.endedAt || 0) - base || 0) / 1000);
88
+ // 把各类用户事件(点击/滚动/输入/提交/按键/控制台/网络)归并成一条按时间排序的时间线。
89
+ function describeRecording(meta, evs) {
90
+ const base = meta.startedAt || 0;
91
+ const secs = Math.round(((meta.endedAt || 0) - base || 0) / 1000);
101
92
  const rel = t => (typeof t === 'number' ? `+${((t - base) / 1000).toFixed(2)}s` : '+?');
102
93
 
103
- // 把点击 / 控制台 / 网络归并成一条按时间排序的时间线;网络拆成「发起」「完成」两个事件。
104
- const events = [];
105
- (r.clicks || []).forEach(c => {
106
- const files = (c.componentChain || []).map(x => x && x.file).filter(Boolean);
107
- events.push({
108
- ts: c.ts,
109
- text:
110
- `点击 <${c.tag}> ${JSON.stringify((c.text || '').slice(0, 40))} @ ${c.selector}` +
111
- (files.length ? ` | 源码 ${files.join(' <- ')}` : ''),
112
- });
113
- });
114
- (r.console || []).forEach(l => {
115
- events.push({ts: l.ts, text: `控制台[${l.level}] ${String(l.text).slice(0, 300)}`});
116
- });
117
- (r.network || []).forEach(n => {
118
- events.push({ts: n.startTs, text: `网络→发起 ${n.method} ${n.url}`});
119
- events.push({ts: n.endTs, text: `网络←完成 ${n.method} ${n.url} → ${n.status} (${n.ms}ms)`});
120
- });
121
- // V8 的 sort 稳定:同一时刻保持插入序(发起在完成之前)。
122
- events.sort((a, b) => (a.ts || 0) - (b.ts || 0));
94
+ const lines = [];
95
+ for (const ev of evs) {
96
+ const it = ev.item || {};
97
+ if (ev.kind === 'click') {
98
+ const files = (it.componentChain || []).map(x => x && x.file).filter(Boolean);
99
+ lines.push({
100
+ ts: it.ts,
101
+ text:
102
+ `点击 <${it.tag}> ${JSON.stringify((it.text || '').slice(0, 40))} @ ${it.selector}` +
103
+ (files.length ? ` | 源码 ${files.join(' <- ')}` : ''),
104
+ });
105
+ } else if (ev.kind === 'scroll') {
106
+ lines.push({ts: it.ts, text: `滚动 ${it.selector} → (${it.x}, ${it.y})`});
107
+ } else if (ev.kind === 'change') {
108
+ lines.push({
109
+ ts: it.ts,
110
+ text: `输入/变更 <${it.tag}> @ ${it.selector} = ${JSON.stringify(String(it.value ?? '').slice(0, 60))}`,
111
+ });
112
+ } else if (ev.kind === 'submit') {
113
+ lines.push({ts: it.ts, text: `提交表单 @ ${it.selector}`});
114
+ } else if (ev.kind === 'keydown') {
115
+ lines.push({ts: it.ts, text: `按键 ${it.key}${it.selector ? ` @ ${it.selector}` : ''}`});
116
+ } else if (ev.kind === 'console') {
117
+ lines.push({ts: it.ts, text: `控制台[${it.level}] ${String(it.text).slice(0, 300)}`});
118
+ } else if (ev.kind === 'network') {
119
+ lines.push({ts: it.startTs, text: `网络→发起 ${it.method} ${it.url}`});
120
+ lines.push({ts: it.endTs, text: `网络←完成 ${it.method} ${it.url} → ${it.status} (${it.ms}ms)`});
121
+ } else {
122
+ lines.push({ts: it.ts, text: `${ev.kind} ${JSON.stringify(it).slice(0, 120)}`});
123
+ }
124
+ }
125
+ // V8 的 sort 稳定:同一时刻保持插入序(如网络发起在完成之前)。
126
+ lines.sort((a, b) => (a.ts || 0) - (b.ts || 0));
123
127
 
124
128
  const parts = [
125
- `【ui-inspector 录制】用户录制了一段操作(页面 ${r.pageUrl || ''},约 ${secs}s,共 ${events.length} 个事件,时间为相对录制起点的偏移):`,
129
+ `【ui-inspector 录制】用户录制了一段操作(页面 ${meta.pageUrl || ''},约 ${secs}s,共 ${lines.length} 个事件,时间为相对录制起点的偏移):`,
126
130
  ];
127
- events.slice(0, 120).forEach(e => parts.push(` ${rel(e.ts)} ${e.text}`));
128
- if (events.length > 120) {
129
- parts.push(` …(其余 ${events.length - 120} 个事件省略)`);
131
+ lines.slice(0, 160).forEach(e => parts.push(` ${rel(e.ts)} ${e.text}`));
132
+ if (lines.length > 160) {
133
+ parts.push(` …(其余 ${lines.length - 160} 个事件省略)`);
130
134
  }
131
135
  return parts.join('\n');
132
136
  }
@@ -142,7 +146,7 @@ if (list.length) {
142
146
  );
143
147
  }
144
148
  if (hasRec) {
145
- sections.push(describeRecording(recording));
149
+ sections.push(describeRecording(meta, recEvents));
146
150
  }
147
151
  sections.push(
148
152
  '请据此定位源码并按用户意见修改。页面选中态已自动取消;改动过程中可调用 MCP 工具 notify_browser 向页面推送状态(如「修改中…」「已完成」),会在浏览器弹 toast。'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comate-ui-inspector",
3
- "version": "0.1.5-alpha.5",
3
+ "version": "0.1.5",
4
4
  "description": "按住 Alt 点选页面元素,把「元素 + Vue 组件源码路径」交给 Comate 定位源码修改。浏览器扩展 + Native Messaging host + MCP + hook。",
5
5
  "type": "module",
6
6
  "license": "MIT",