dsh-heatmap 0.1.0

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/lib/client.js ADDED
@@ -0,0 +1,897 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-heatmap",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react = require("react");
8
+ let react_jsx_runtime = require("react/jsx-runtime");
9
+ //#region src/client/storage.ts
10
+ const EVENTS_KEY = "dsh-heatmap.events.v1";
11
+ const SETTINGS_KEY = "dsh-heatmap.settings.v1";
12
+ const CONSENT_KEY = "dsh-heatmap.consent.v1";
13
+ const DEFAULT_SETTINGS = {
14
+ heatmapMode: false,
15
+ trackingEnabled: true,
16
+ trackMouse: false,
17
+ sampleRate: 1,
18
+ uploadEndpoint: "",
19
+ consentRequired: true,
20
+ maxEvents: 5e3
21
+ };
22
+ function readSettings() {
23
+ try {
24
+ const raw = localStorage.getItem(SETTINGS_KEY);
25
+ if (!raw) return { ...DEFAULT_SETTINGS };
26
+ return {
27
+ ...DEFAULT_SETTINGS,
28
+ ...JSON.parse(raw)
29
+ };
30
+ } catch {
31
+ return { ...DEFAULT_SETTINGS };
32
+ }
33
+ }
34
+ /** 当前生效设置(可变引用,供埋点引擎实时读取)。 */
35
+ let currentSettings = readSettings();
36
+ function getSettings() {
37
+ return currentSettings;
38
+ }
39
+ function saveSettings(next) {
40
+ currentSettings = { ...next };
41
+ try {
42
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(currentSettings));
43
+ } catch {}
44
+ }
45
+ let currentSessionId = "";
46
+ function getSessionId() {
47
+ return currentSessionId;
48
+ }
49
+ function setSessionId(id) {
50
+ currentSessionId = id;
51
+ }
52
+ /** 本地事件环形缓冲(内存 + localStorage 持久化)。 */
53
+ var ClientEventStore = class {
54
+ events = [];
55
+ loaded = false;
56
+ ensureLoaded() {
57
+ if (this.loaded) return;
58
+ this.loaded = true;
59
+ try {
60
+ const raw = localStorage.getItem(EVENTS_KEY);
61
+ this.events = raw ? JSON.parse(raw) : [];
62
+ } catch {
63
+ this.events = [];
64
+ }
65
+ }
66
+ push(event, maxEvents) {
67
+ this.ensureLoaded();
68
+ this.events.push(event);
69
+ if (this.events.length > maxEvents) this.events = this.events.slice(-maxEvents);
70
+ }
71
+ flush() {
72
+ try {
73
+ localStorage.setItem(EVENTS_KEY, JSON.stringify(this.events));
74
+ } catch {}
75
+ }
76
+ all() {
77
+ this.ensureLoaded();
78
+ return this.events;
79
+ }
80
+ clear() {
81
+ this.events = [];
82
+ try {
83
+ localStorage.removeItem(EVENTS_KEY);
84
+ } catch {}
85
+ }
86
+ buildBatch(sessionId) {
87
+ this.ensureLoaded();
88
+ return {
89
+ v: 1,
90
+ sessionId,
91
+ sentAt: Date.now(),
92
+ events: this.events
93
+ };
94
+ }
95
+ };
96
+ const clientStore = new ClientEventStore();
97
+ function hasConsented() {
98
+ try {
99
+ return localStorage.getItem(CONSENT_KEY) === "1";
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+ function setConsented(value) {
105
+ try {
106
+ localStorage.setItem(CONSENT_KEY, value ? "1" : "0");
107
+ } catch {}
108
+ }
109
+ //#endregion
110
+ //#region src/client/tracker.ts
111
+ function uuid() {
112
+ try {
113
+ return crypto.randomUUID();
114
+ } catch {
115
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
116
+ }
117
+ }
118
+ /** 参与「元素身份」识别的语义标签;点击落在子节点时向上寻找最近的可交互元素。 */
119
+ const INTERACTIVE_TAGS = /* @__PURE__ */ new Set([
120
+ "BUTTON",
121
+ "A",
122
+ "INPUT",
123
+ "TEXTAREA",
124
+ "SELECT",
125
+ "OPTION",
126
+ "LABEL",
127
+ "SUMMARY",
128
+ "DETAILS",
129
+ "LI",
130
+ "DIV",
131
+ "SPAN",
132
+ "P",
133
+ "H1",
134
+ "H2",
135
+ "H3",
136
+ "H4",
137
+ "H5",
138
+ "H6"
139
+ ]);
140
+ function nearestInteractive(el) {
141
+ let cur = el;
142
+ while (cur && cur !== document.body && cur !== document.documentElement) {
143
+ if (INTERACTIVE_TAGS.has(cur.tagName)) return cur;
144
+ cur = cur.parentElement;
145
+ }
146
+ return el;
147
+ }
148
+ function textOf(el) {
149
+ const t = (el.textContent ?? "").replace(/\s+/g, " ").trim();
150
+ if (t === "") return void 0;
151
+ return t.length > 40 ? `${t.slice(0, 40)}…` : t;
152
+ }
153
+ /**
154
+ * 生成稳定的元素身份(优先 data-testid > id > aria-label > role+tag > placeholder/name > tag+text)。
155
+ * 隐私保护:input/textarea/select 与 contenteditable 元素绝不采集其内容/文案。
156
+ */
157
+ function identify(el) {
158
+ if (!el || el === document.body || el === document.documentElement) return void 0;
159
+ const tag = el.tagName.toLowerCase();
160
+ const testId = el.getAttribute("data-testid") ?? el.getAttribute("data-test-id") ?? void 0;
161
+ const id = el.id || void 0;
162
+ const ariaLabel = el.getAttribute("aria-label") ?? void 0;
163
+ const role = el.getAttribute("role") ?? void 0;
164
+ const title = el.getAttribute("title") ?? void 0;
165
+ const isInputLike = tag === "input" || tag === "textarea" || tag === "select" || el.isContentEditable === true;
166
+ const name = isInputLike ? el.getAttribute("name") ?? void 0 : void 0;
167
+ const placeholder = isInputLike ? el.getAttribute("placeholder") ?? void 0 : void 0;
168
+ const text = isInputLike ? void 0 : textOf(el);
169
+ const elementId = testId ?? id ?? ariaLabel ?? (role ? `${role}#${tag}` : void 0) ?? (placeholder ? `${tag}[${placeholder}]` : void 0) ?? (name ? `${tag}#${name}` : void 0) ?? `${tag}${text ? `:${text}` : ""}`;
170
+ return {
171
+ tag,
172
+ ...id !== void 0 ? { id } : {},
173
+ ...role !== void 0 ? { role } : {},
174
+ ...ariaLabel !== void 0 ? { ariaLabel } : {},
175
+ ...title !== void 0 ? { title } : {},
176
+ ...testId !== void 0 ? { testId } : {},
177
+ ...name !== void 0 ? { name } : {},
178
+ ...placeholder !== void 0 ? { placeholder } : {},
179
+ ...text !== void 0 ? { text } : {},
180
+ elementId
181
+ };
182
+ }
183
+ function createTracker() {
184
+ const sessionId = uuid();
185
+ setSessionId(sessionId);
186
+ const page = () => ({
187
+ path: window.location.pathname,
188
+ hash: window.location.hash
189
+ });
190
+ const emit = (type, partial) => {
191
+ const s = getSettings();
192
+ if (!s.trackingEnabled) return;
193
+ if (s.sampleRate < 1 && Math.random() > s.sampleRate) return;
194
+ const event = {
195
+ v: 1,
196
+ id: uuid(),
197
+ ts: Date.now(),
198
+ sessionId,
199
+ type,
200
+ page: page(),
201
+ ...partial
202
+ };
203
+ clientStore.push(event, s.maxEvents);
204
+ };
205
+ emit("session_start", {});
206
+ emit("page_view", {});
207
+ const onClick = (e) => {
208
+ emit("click", {
209
+ target: identify(nearestInteractive(e.target)),
210
+ position: {
211
+ x: e.clientX,
212
+ y: e.clientY
213
+ }
214
+ });
215
+ };
216
+ const onFocusIn = (e) => {
217
+ emit("focus", { target: identify(e.target) });
218
+ };
219
+ const onFocusOut = (e) => {
220
+ emit("blur", { target: identify(e.target) });
221
+ };
222
+ let lastHover = 0;
223
+ const onMouseMove = (e) => {
224
+ if (!getSettings().trackMouse) return;
225
+ const now = Date.now();
226
+ if (now - lastHover < 250) return;
227
+ lastHover = now;
228
+ emit("hover", {
229
+ position: {
230
+ x: e.clientX,
231
+ y: e.clientY
232
+ },
233
+ target: identify(nearestInteractive(e.target))
234
+ });
235
+ };
236
+ let scrollTimer;
237
+ const onScroll = () => {
238
+ if (scrollTimer !== void 0) return;
239
+ scrollTimer = window.setTimeout(() => {
240
+ scrollTimer = void 0;
241
+ const doc = document.documentElement;
242
+ const max = doc.scrollHeight - doc.clientHeight;
243
+ const depth = max > 0 ? Math.min(1, Math.max(0, doc.scrollTop / max)) : 0;
244
+ emit("scroll_depth", { depth: Math.round(depth * 100) / 100 });
245
+ }, 500);
246
+ };
247
+ const onVisibility = () => {
248
+ const visible = document.visibilityState === "visible";
249
+ emit("visibility", { visible });
250
+ if (!visible) emit("session_end", {});
251
+ };
252
+ let lastInput = 0;
253
+ const onInput = (e) => {
254
+ const now = Date.now();
255
+ if (now - lastInput < 1e3) return;
256
+ const el = e.target;
257
+ if (!el || !("value" in el)) return;
258
+ lastInput = now;
259
+ emit("input", {
260
+ target: identify(el),
261
+ inputLength: el.value.length
262
+ });
263
+ };
264
+ const onHashChange = () => {
265
+ emit("page_view", {});
266
+ };
267
+ const onPageHide = () => {
268
+ emit("session_end", {});
269
+ clientStore.flush();
270
+ };
271
+ const impressed = /* @__PURE__ */ new WeakSet();
272
+ const IMPRESSION_SELECTOR = "[data-testid], [data-impression], [role=\"main\"], [role=\"region\"], [role=\"dialog\"], [role=\"navigation\"], [role=\"complementary\"]";
273
+ const impressionObserver = new IntersectionObserver((entries) => {
274
+ for (const entry of entries) if (entry.isIntersecting && entry.intersectionRatio >= .5 && !impressed.has(entry.target)) {
275
+ impressed.add(entry.target);
276
+ impressionObserver.unobserve(entry.target);
277
+ emit("impression", { target: identify(entry.target) });
278
+ }
279
+ }, { threshold: [.5] });
280
+ const scanImpressions = () => {
281
+ document.querySelectorAll(IMPRESSION_SELECTOR).forEach((el) => {
282
+ if (!impressed.has(el)) impressionObserver.observe(el);
283
+ });
284
+ };
285
+ scanImpressions();
286
+ const impressionTimer = window.setInterval(scanImpressions, 2500);
287
+ document.addEventListener("click", onClick, true);
288
+ document.addEventListener("focusin", onFocusIn, true);
289
+ document.addEventListener("focusout", onFocusOut, true);
290
+ document.addEventListener("mousemove", onMouseMove, true);
291
+ document.addEventListener("scroll", onScroll, true);
292
+ document.addEventListener("input", onInput, true);
293
+ document.addEventListener("visibilitychange", onVisibility);
294
+ window.addEventListener("hashchange", onHashChange);
295
+ window.addEventListener("pagehide", onPageHide);
296
+ const flushTimer = window.setInterval(() => clientStore.flush(), 3e3);
297
+ return { dispose() {
298
+ document.removeEventListener("click", onClick, true);
299
+ document.removeEventListener("focusin", onFocusIn, true);
300
+ document.removeEventListener("focusout", onFocusOut, true);
301
+ document.removeEventListener("mousemove", onMouseMove, true);
302
+ document.removeEventListener("scroll", onScroll, true);
303
+ document.removeEventListener("input", onInput, true);
304
+ document.removeEventListener("visibilitychange", onVisibility);
305
+ window.removeEventListener("hashchange", onHashChange);
306
+ window.removeEventListener("pagehide", onPageHide);
307
+ window.clearInterval(flushTimer);
308
+ window.clearInterval(impressionTimer);
309
+ impressionObserver.disconnect();
310
+ if (scrollTimer !== void 0) window.clearTimeout(scrollTimer);
311
+ clientStore.flush();
312
+ } };
313
+ }
314
+ //#endregion
315
+ //#region src/client/ConsentModal.tsx
316
+ const backdrop = {
317
+ position: "fixed",
318
+ inset: 0,
319
+ background: "rgba(0, 0, 0, 0.55)",
320
+ display: "flex",
321
+ alignItems: "center",
322
+ justifyContent: "center",
323
+ zIndex: 2e4,
324
+ pointerEvents: "auto"
325
+ };
326
+ const dialog = {
327
+ width: 420,
328
+ maxWidth: "calc(100vw - 32px)",
329
+ background: "#1e1f24",
330
+ border: "1px solid #3a3c44",
331
+ borderRadius: 10,
332
+ padding: 18,
333
+ color: "#e6e6e6",
334
+ boxShadow: "0 12px 40px rgba(0,0,0,0.5)",
335
+ fontSize: 13,
336
+ lineHeight: 1.6
337
+ };
338
+ function ConsentModal(props) {
339
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
340
+ style: backdrop,
341
+ onClick: props.onCancel,
342
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
343
+ style: dialog,
344
+ onClick: (e) => e.stopPropagation(),
345
+ children: [
346
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
347
+ style: {
348
+ fontSize: 15,
349
+ fontWeight: 700,
350
+ marginBottom: 10
351
+ },
352
+ children: "数据上传授权"
353
+ }),
354
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
355
+ style: { margin: "0 0 10px" },
356
+ children: [
357
+ "即将把 ",
358
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", { children: props.count }),
359
+ " 条使用埋点数据(点击、聚焦、停留、滚动、鼠标位置等)上传到:"
360
+ ]
361
+ }),
362
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
363
+ style: {
364
+ background: "#141519",
365
+ border: "1px solid #3a3c44",
366
+ borderRadius: 6,
367
+ padding: "8px 10px",
368
+ marginBottom: 10,
369
+ wordBreak: "break-all",
370
+ fontFamily: "monospace",
371
+ fontSize: 12
372
+ },
373
+ children: props.destination
374
+ }),
375
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
376
+ style: {
377
+ margin: "0 0 12px",
378
+ color: "#aab"
379
+ },
380
+ children: [
381
+ "隐私承诺:数据仅包含页面控件身份、坐标、时间与视口信息,",
382
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", { children: "不包含对话正文、输入内容或个人身份信息" }),
383
+ "。 只有在你点击「同意并上传」后,数据才会被发送。"
384
+ ]
385
+ }),
386
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
387
+ style: {
388
+ display: "flex",
389
+ justifyContent: "flex-end",
390
+ gap: 8
391
+ },
392
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
393
+ onClick: props.onCancel,
394
+ style: btn$1("#2a2b30", "#ccc"),
395
+ children: "取消"
396
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
397
+ onClick: props.onAgree,
398
+ style: btn$1("#3a82f7", "#fff"),
399
+ children: "同意并上传"
400
+ })]
401
+ })
402
+ ]
403
+ })
404
+ });
405
+ }
406
+ function btn$1(bg, color) {
407
+ return {
408
+ background: bg,
409
+ color,
410
+ border: "1px solid #4a4c55",
411
+ borderRadius: 6,
412
+ padding: "6px 14px",
413
+ fontSize: 13,
414
+ cursor: "pointer"
415
+ };
416
+ }
417
+ //#endregion
418
+ //#region src/client/HeatmapOverlay.tsx
419
+ /**
420
+ * 热力图覆盖层 + 统计面板 + 设置 + 上传(含授权弹窗)。
421
+ *
422
+ * 注册进 `shell.overlay`(additive list slot)。热力图数据只来自本地采集,
423
+ * 面板明确标注「本地 / 本人」数据。
424
+ */
425
+ function computeStats(events) {
426
+ let clicks = 0;
427
+ let focus = 0;
428
+ let impressions = 0;
429
+ const sessions = /* @__PURE__ */ new Set();
430
+ const top = /* @__PURE__ */ new Map();
431
+ let dwellMs = 0;
432
+ let visibleStart = 0;
433
+ let maxDepth = 0;
434
+ for (const e of events) {
435
+ sessions.add(e.sessionId);
436
+ if (e.type === "click") {
437
+ clicks += 1;
438
+ if (e.target?.elementId) top.set(e.target.elementId, (top.get(e.target.elementId) ?? 0) + 1);
439
+ }
440
+ if (e.type === "focus") focus += 1;
441
+ if (e.type === "impression") impressions += 1;
442
+ if (e.type === "visibility") {
443
+ if (e.visible) {
444
+ if (visibleStart === 0) visibleStart = e.ts;
445
+ } else if (visibleStart > 0) {
446
+ dwellMs += e.ts - visibleStart;
447
+ visibleStart = 0;
448
+ }
449
+ }
450
+ if (e.type === "scroll_depth" && e.depth !== void 0 && e.depth > maxDepth) maxDepth = e.depth;
451
+ }
452
+ if (visibleStart > 0) dwellMs += Date.now() - visibleStart;
453
+ const topList = [...top.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([elementId, count]) => ({
454
+ elementId,
455
+ count
456
+ }));
457
+ return {
458
+ total: events.length,
459
+ clicks,
460
+ focus,
461
+ impressions,
462
+ sessions: sessions.size,
463
+ dwellMs,
464
+ maxDepth,
465
+ top: topList
466
+ };
467
+ }
468
+ /** 热度色带:透明 → 蓝 → 青 → 绿 → 黄 → 红。 */
469
+ function heatColor(t) {
470
+ const stops = [
471
+ [0, [
472
+ 0,
473
+ 60,
474
+ 255
475
+ ]],
476
+ [.35, [
477
+ 0,
478
+ 220,
479
+ 255
480
+ ]],
481
+ [.6, [
482
+ 30,
483
+ 230,
484
+ 60
485
+ ]],
486
+ [.8, [
487
+ 255,
488
+ 220,
489
+ 0
490
+ ]],
491
+ [1, [
492
+ 255,
493
+ 30,
494
+ 0
495
+ ]]
496
+ ];
497
+ let a = stops[0];
498
+ let b = stops[stops.length - 1];
499
+ for (let i = 0; i < stops.length - 1; i += 1) if (t >= stops[i][0] && t <= stops[i + 1][0]) {
500
+ a = stops[i];
501
+ b = stops[i + 1];
502
+ break;
503
+ }
504
+ const span = b[0] - a[0] || 1;
505
+ const k = Math.min(1, Math.max(0, (t - a[0]) / span));
506
+ return `rgba(${Math.round(a[1][0] + (b[1][0] - a[1][0]) * k)}, ${Math.round(a[1][1] + (b[1][1] - a[1][1]) * k)}, ${Math.round(a[1][2] + (b[1][2] - a[1][2]) * k)}, ${.28 + .5 * t})`;
507
+ }
508
+ function drawHeatmap(canvas, events) {
509
+ const dpr = window.devicePixelRatio || 1;
510
+ const w = window.innerWidth;
511
+ const h = window.innerHeight;
512
+ canvas.width = Math.floor(w * dpr);
513
+ canvas.height = Math.floor(h * dpr);
514
+ canvas.style.width = `${w}px`;
515
+ canvas.style.height = `${h}px`;
516
+ const ctx = canvas.getContext("2d");
517
+ if (!ctx) return;
518
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
519
+ ctx.clearRect(0, 0, w, h);
520
+ const CELL = 22;
521
+ const cols = Math.ceil(w / CELL);
522
+ const rows = Math.ceil(h / CELL);
523
+ const grid = new Int32Array(rows * cols);
524
+ let max = 0;
525
+ for (const e of events) {
526
+ if (!e.position) continue;
527
+ const gx = Math.floor(e.position.x / CELL);
528
+ const gy = Math.floor(e.position.y / CELL);
529
+ if (gx < 0 || gy < 0 || gx >= cols || gy >= rows) continue;
530
+ const idx = gy * cols + gx;
531
+ grid[idx] += e.type === "click" ? 3 : 1;
532
+ if (grid[idx] > max) max = grid[idx];
533
+ }
534
+ if (max === 0) return;
535
+ for (let gy = 0; gy < rows; gy += 1) for (let gx = 0; gx < cols; gx += 1) {
536
+ const v = grid[gy * cols + gx];
537
+ if (v === 0) continue;
538
+ const t = v / max;
539
+ ctx.fillStyle = heatColor(t);
540
+ ctx.beginPath();
541
+ const radius = CELL * (.35 + .55 * t);
542
+ ctx.arc(gx * CELL + CELL / 2, gy * CELL + CELL / 2, radius, 0, Math.PI * 2);
543
+ ctx.fill();
544
+ }
545
+ }
546
+ function fmtDuration(ms) {
547
+ if (ms < 1e3) return `${ms}ms`;
548
+ const s = Math.floor(ms / 1e3);
549
+ if (s < 60) return `${s}s`;
550
+ return `${Math.floor(s / 60)}m ${s % 60}s`;
551
+ }
552
+ const panelStyle = {
553
+ position: "fixed",
554
+ right: 16,
555
+ bottom: 16,
556
+ width: 300,
557
+ background: "#1e1f24",
558
+ border: "1px solid #3a3c44",
559
+ borderRadius: 10,
560
+ padding: 14,
561
+ color: "#e6e6e6",
562
+ boxShadow: "0 8px 30px rgba(0,0,0,0.45)",
563
+ fontSize: 12,
564
+ zIndex: 1e4,
565
+ pointerEvents: "auto",
566
+ display: "flex",
567
+ flexDirection: "column",
568
+ gap: 8
569
+ };
570
+ function HeatmapOverlay() {
571
+ const [settings, setSettings] = (0, react.useState)(() => getSettings());
572
+ const [open, setOpen] = (0, react.useState)(false);
573
+ const [consentOpen, setConsentOpen] = (0, react.useState)(false);
574
+ const [uploadMsg, setUploadMsg] = (0, react.useState)(null);
575
+ const [tick, setTick] = (0, react.useState)(0);
576
+ const canvasRef = (0, react.useRef)(null);
577
+ (0, react.useEffect)(() => {
578
+ const timer = window.setInterval(() => setTick((v) => v + 1), 1e3);
579
+ return () => window.clearInterval(timer);
580
+ }, []);
581
+ (0, react.useEffect)(() => {
582
+ if (!settings.heatmapMode || !canvasRef.current) return;
583
+ const redraw = () => drawHeatmap(canvasRef.current, clientStore.all());
584
+ redraw();
585
+ const resize = () => redraw();
586
+ window.addEventListener("resize", resize);
587
+ const timer = window.setInterval(redraw, 1e3);
588
+ return () => {
589
+ window.removeEventListener("resize", resize);
590
+ window.clearInterval(timer);
591
+ };
592
+ }, [settings.heatmapMode, tick]);
593
+ const s = computeStats(clientStore.all());
594
+ const destination = settings.uploadEndpoint !== "" ? settings.uploadEndpoint : `${window.location.origin}/dsh-heatmap/ingest`;
595
+ const update = (patch) => {
596
+ const next = {
597
+ ...settings,
598
+ ...patch
599
+ };
600
+ setSettings(next);
601
+ saveSettings(next);
602
+ };
603
+ const doUpload = (0, react.useCallback)(async () => {
604
+ const batch = clientStore.buildBatch(getSessionId());
605
+ setConsentOpen(false);
606
+ setUploadMsg("上传中…");
607
+ try {
608
+ const res = await fetch(destination, {
609
+ method: "POST",
610
+ headers: { "content-type": "application/json" },
611
+ body: JSON.stringify(batch)
612
+ });
613
+ if (res.ok) setUploadMsg(`已上传 ${batch.events.length} 条埋点数据`);
614
+ else setUploadMsg(`上传失败(HTTP ${res.status})`);
615
+ } catch (err) {
616
+ setUploadMsg(`上传失败:${err instanceof Error ? err.message : String(err)}`);
617
+ }
618
+ }, [destination]);
619
+ const onUploadClick = () => {
620
+ setUploadMsg(null);
621
+ if (settings.consentRequired && !hasConsented()) {
622
+ setConsentOpen(true);
623
+ return;
624
+ }
625
+ doUpload();
626
+ };
627
+ const onAgree = () => {
628
+ setConsented(true);
629
+ doUpload();
630
+ };
631
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
632
+ settings.heatmapMode && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("canvas", {
633
+ ref: canvasRef,
634
+ style: {
635
+ position: "fixed",
636
+ inset: 0,
637
+ zIndex: 9e3,
638
+ pointerEvents: "none"
639
+ }
640
+ }),
641
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
642
+ onClick: () => setOpen((v) => !v),
643
+ style: {
644
+ position: "fixed",
645
+ right: 16,
646
+ bottom: 16,
647
+ zIndex: 10001,
648
+ width: 44,
649
+ height: 44,
650
+ borderRadius: 22,
651
+ cursor: "pointer",
652
+ background: settings.heatmapMode ? "#e0483e" : "#2a2b30",
653
+ color: "#fff",
654
+ border: "1px solid #4a4c55",
655
+ fontSize: 18,
656
+ boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
657
+ pointerEvents: "auto"
658
+ },
659
+ title: "dsh-heatmap 埋点与热力图",
660
+ children: "🔥"
661
+ }),
662
+ open && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
663
+ style: panelStyle,
664
+ children: [
665
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
666
+ style: {
667
+ display: "flex",
668
+ justifyContent: "space-between",
669
+ alignItems: "center"
670
+ },
671
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", {
672
+ style: { fontSize: 13 },
673
+ children: "dsh-heatmap"
674
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
675
+ style: {
676
+ fontSize: 10,
677
+ background: "#123",
678
+ color: "#9cf",
679
+ padding: "1px 6px",
680
+ borderRadius: 8
681
+ },
682
+ children: "本地 / 本人数据"
683
+ })]
684
+ }),
685
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
686
+ style: {
687
+ background: "#141519",
688
+ borderRadius: 6,
689
+ padding: "8px 10px",
690
+ display: "flex",
691
+ flexDirection: "column",
692
+ gap: 3
693
+ },
694
+ children: [
695
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Row, {
696
+ label: "事件总数",
697
+ value: String(s.total)
698
+ }),
699
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Row, {
700
+ label: "点击",
701
+ value: String(s.clicks)
702
+ }),
703
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Row, {
704
+ label: "聚焦",
705
+ value: String(s.focus)
706
+ }),
707
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Row, {
708
+ label: "曝光",
709
+ value: String(s.impressions)
710
+ }),
711
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Row, {
712
+ label: "会话数",
713
+ value: String(s.sessions)
714
+ }),
715
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Row, {
716
+ label: "停留时长",
717
+ value: fmtDuration(s.dwellMs)
718
+ }),
719
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Row, {
720
+ label: "最大滚动",
721
+ value: `${Math.round(s.maxDepth * 100)}%`
722
+ })
723
+ ]
724
+ }),
725
+ s.top.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
726
+ style: {
727
+ background: "#141519",
728
+ borderRadius: 6,
729
+ padding: "8px 10px"
730
+ },
731
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
732
+ style: {
733
+ marginBottom: 4,
734
+ fontWeight: 600
735
+ },
736
+ children: "点击最多的控件"
737
+ }), s.top.map((t) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
738
+ style: {
739
+ display: "flex",
740
+ justifyContent: "space-between",
741
+ fontSize: 11,
742
+ color: "#bbb"
743
+ },
744
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
745
+ style: {
746
+ overflow: "hidden",
747
+ textOverflow: "ellipsis",
748
+ whiteSpace: "nowrap",
749
+ maxWidth: 200
750
+ },
751
+ children: t.elementId
752
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t.count })]
753
+ }, t.elementId))]
754
+ }),
755
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Toggle, {
756
+ label: "热力图模式",
757
+ checked: settings.heatmapMode,
758
+ onChange: (v) => update({ heatmapMode: v })
759
+ }),
760
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Toggle, {
761
+ label: "采集埋点",
762
+ checked: settings.trackingEnabled,
763
+ onChange: (v) => update({ trackingEnabled: v })
764
+ }),
765
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Toggle, {
766
+ label: "鼠标热力图",
767
+ checked: settings.trackMouse,
768
+ onChange: (v) => update({ trackMouse: v })
769
+ }),
770
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Toggle, {
771
+ label: "上传需授权",
772
+ checked: settings.consentRequired,
773
+ onChange: (v) => update({ consentRequired: v })
774
+ }),
775
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
776
+ style: {
777
+ display: "flex",
778
+ flexDirection: "column",
779
+ gap: 3
780
+ },
781
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
782
+ style: {
783
+ fontSize: 11,
784
+ color: "#aaa"
785
+ },
786
+ children: "上传地址(留空 = 本地收集器)"
787
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
788
+ value: settings.uploadEndpoint,
789
+ onChange: (e) => update({ uploadEndpoint: e.target.value }),
790
+ placeholder: destination,
791
+ style: {
792
+ fontSize: 11,
793
+ padding: "4px 6px",
794
+ background: "#141519",
795
+ color: "#ddd",
796
+ border: "1px solid #3a3c44",
797
+ borderRadius: 5
798
+ }
799
+ })]
800
+ }),
801
+ uploadMsg && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
802
+ style: {
803
+ fontSize: 11,
804
+ color: uploadMsg.startsWith("已上传") ? "#8e8" : "#e88"
805
+ },
806
+ children: uploadMsg
807
+ }),
808
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
809
+ style: {
810
+ display: "flex",
811
+ gap: 6
812
+ },
813
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
814
+ onClick: onUploadClick,
815
+ style: btn("#3a82f7", "#fff", true),
816
+ children: "上传数据"
817
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
818
+ onClick: () => {
819
+ clientStore.clear();
820
+ setTick((v) => v + 1);
821
+ },
822
+ style: btn("#2a2b30", "#ccc"),
823
+ children: "清除本地"
824
+ })]
825
+ })
826
+ ]
827
+ }),
828
+ consentOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ConsentModal, {
829
+ destination,
830
+ count: clientStore.all().length,
831
+ onCancel: () => setConsentOpen(false),
832
+ onAgree
833
+ })
834
+ ] });
835
+ }
836
+ function Row({ label, value }) {
837
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
838
+ style: {
839
+ display: "flex",
840
+ justifyContent: "space-between"
841
+ },
842
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
843
+ style: { color: "#999" },
844
+ children: label
845
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
846
+ style: { fontWeight: 600 },
847
+ children: value
848
+ })]
849
+ });
850
+ }
851
+ function Toggle({ label, checked, onChange }) {
852
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
853
+ style: {
854
+ display: "flex",
855
+ justifyContent: "space-between",
856
+ alignItems: "center",
857
+ cursor: "pointer"
858
+ },
859
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: label }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
860
+ type: "checkbox",
861
+ checked,
862
+ onChange: (e) => onChange(e.target.checked)
863
+ })]
864
+ });
865
+ }
866
+ function btn(bg, color, primary = false) {
867
+ return {
868
+ flex: primary ? 1.4 : 1,
869
+ background: bg,
870
+ color,
871
+ border: "1px solid #4a4c55",
872
+ borderRadius: 6,
873
+ padding: "6px 10px",
874
+ fontSize: 12,
875
+ cursor: "pointer"
876
+ };
877
+ }
878
+ //#endregion
879
+ //#region src/client/index.ts
880
+ const inject = ["slots"];
881
+ function apply(ctx) {
882
+ const tracker = createTracker();
883
+ ctx.effect(() => () => tracker.dispose(), "dsh-heatmap: tracker");
884
+ ctx.slots.register({
885
+ name: "shell.overlay",
886
+ id: "dsh-heatmap-overlay",
887
+ order: 1e3
888
+ }, HeatmapOverlay);
889
+ }
890
+ //#endregion
891
+ exports.apply = apply;
892
+ exports.inject = inject;
893
+ return module.exports;
894
+ }
895
+ });
896
+
897
+ //# sourceMappingURL=client.js.map