@jx3box/jx3box-ui 2.3.30 → 2.4.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.
@@ -0,0 +1,13 @@
1
+ # PC 公共头客户端统计
2
+
3
+ `CommonHeader` 为引用公共头的普通 PC 项目接入新版客户端实例 heartbeat。
4
+
5
+ - 接口:`POST /api/cms/system/stat/heartbeat`。
6
+ - SDK 版本:当前固定为 `v0.0.1`,只在统计组件协议升级时调整。
7
+ - 实例 ID:复用本地 `jx3box:device_id` 随机 UUID;不使用硬件指纹。
8
+ - 普通桌面浏览器为 `client=pc_web`;手机浏览器访问 PC 页面为 `client=mobile_web`。
9
+ - 小程序和 App 容器按统一客户端枚举识别;游戏内页面的 `pc_game/mobile_game` 由对应页面以后主动指定,不在公共头推断。
10
+ - UID 不进入 payload,由 service-cms 从可选 JWT 中读取;IP 同样由服务端反代请求头读取。
11
+ - 生产环境启动后随机延迟 3~15 秒,同一北京时间日期、UID 和 Web 版本只成功上报一次。
12
+ - 页面恢复可见时补检;请求失败不写成功标记,也不影响公共头和宿主页面。
13
+ - localhost 每次公共头重新创建都强制上报,方便联调。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jx3box/jx3box-ui",
3
- "version": "2.3.30",
3
+ "version": "2.4.0",
4
4
  "description": "JX3BOX Vue3 UI",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -85,6 +85,7 @@ import miniprogram from "@jx3box/jx3box-common/data/miniprogram.json";
85
85
  import { getGlobalConfig } from "../service/header";
86
86
  import { getConfig, getMyAccountStatus } from "../service/cms";
87
87
  import { clearActiveAuthToken, refreshTokenIfNeeded } from "./utils/auth-token-refresh";
88
+ import { checkClientStatOnVisible, installClientStatReporting } from "./utils/client-stat";
88
89
  import User from "@jx3box/jx3box-common/js/user.js";
89
90
  import JX3BOX from "@jx3box/jx3box-common/data/jx3box.json";
90
91
 
@@ -262,6 +263,7 @@ export default {
262
263
  handleVisibilityChange: function () {
263
264
  if (document.visibilityState === "visible") {
264
265
  this.refreshAuthToken();
266
+ checkClientStatOnVisible();
265
267
  }
266
268
  },
267
269
 
@@ -446,6 +448,7 @@ export default {
446
448
  },
447
449
  created: function () {
448
450
  this.init();
451
+ installClientStatReporting();
449
452
  window.addEventListener("resize", this.updateScreen, { passive: true });
450
453
  document.addEventListener("visibilitychange", this.handleVisibilityChange);
451
454
 
@@ -0,0 +1,261 @@
1
+ import { $cms } from "@jx3box/jx3box-common/js/api";
2
+ import { isMiniProgram, isApp } from "@jx3box/jx3box-common/js/utils";
3
+ import User from "@jx3box/jx3box-common/js/user";
4
+
5
+ const INSTANCE_ID_KEY = "jx3box:device_id";
6
+ const REPORT_DATE_KEY = "jx3box:client-stat-report-date";
7
+ const REPORT_SIGNATURE_KEY = "jx3box:client-stat-report-signature";
8
+ const STAT_SDK_VERSION = "v0.0.1";
9
+ const STARTUP_DELAY_MIN = 3 * 1000;
10
+ const STARTUP_DELAY_MAX = 15 * 1000;
11
+
12
+ let reportTask = null;
13
+ let scheduledTimer = null;
14
+
15
+ function compactPayload(payload) {
16
+ return Object.fromEntries(
17
+ Object.entries(payload).filter(([, value]) => value !== undefined && value !== null && value !== "")
18
+ );
19
+ }
20
+
21
+ function rangedNumber(value, min, max, { integer = false } = {}) {
22
+ if (value === undefined || value === null || value === "") return null;
23
+ const result = Number(value);
24
+ if (!Number.isFinite(result) || result < min || result > max) return null;
25
+ if (integer && !Number.isInteger(result)) return null;
26
+ return result;
27
+ }
28
+
29
+ function createInstanceId() {
30
+ const browserCrypto = window.crypto;
31
+ if (typeof browserCrypto?.randomUUID === "function") return browserCrypto.randomUUID();
32
+
33
+ const bytes = new Uint8Array(16);
34
+ browserCrypto.getRandomValues(bytes);
35
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
36
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
37
+ const value = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
38
+ return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}`;
39
+ }
40
+
41
+ export function ensureClientInstanceId() {
42
+ const current = localStorage.getItem(INSTANCE_ID_KEY);
43
+ if (current) return current;
44
+
45
+ const instanceId = createInstanceId();
46
+ localStorage.setItem(INSTANCE_ID_KEY, instanceId);
47
+ return instanceId;
48
+ }
49
+
50
+ function chinaDate(date = new Date()) {
51
+ const parts = new Intl.DateTimeFormat("en-US", {
52
+ timeZone: "Asia/Shanghai",
53
+ year: "numeric",
54
+ month: "2-digit",
55
+ day: "2-digit",
56
+ }).formatToParts(date);
57
+ const values = Object.fromEntries(parts.map((item) => [item.type, item.value]));
58
+ return `${values.year}-${values.month}-${values.day}`;
59
+ }
60
+
61
+ function resolveChannel() {
62
+ const sources = [window.location.search, String(window.location.hash || "").split("?")[1]];
63
+ for (const source of sources) {
64
+ if (!source) continue;
65
+ const value = new URLSearchParams(String(source).replace(/^\?/, "")).get("__from");
66
+ if (value?.trim()) return value.trim().slice(0, 64);
67
+ }
68
+ return null;
69
+ }
70
+
71
+ function resolvePlatform(userAgent, navigatorPlatform) {
72
+ if (/iPhone|iPad|iPod/i.test(userAgent)) return "ios";
73
+ if (/Android/i.test(userAgent)) return "android";
74
+ if (/Windows/i.test(userAgent)) return "windows";
75
+ if (/Macintosh|MacIntel/i.test(userAgent) || navigatorPlatform === "MacIntel") return "macos";
76
+ if (/Linux/i.test(userAgent)) return "linux";
77
+ return "unknown";
78
+ }
79
+
80
+ function resolveClient(platform, userAgent) {
81
+ if (isApp()) return "app";
82
+ if (isMiniProgram() || /miniprogram/i.test(userAgent)) return "miniprogram";
83
+ if (["ios", "android"].includes(platform) || /Mobile|Tablet/i.test(userAgent)) return "mobile_web";
84
+ if (["windows", "macos", "linux"].includes(platform)) return "pc_web";
85
+ return "unknown";
86
+ }
87
+
88
+ function parseBrowser(userAgent) {
89
+ const candidates = [
90
+ ["Edge", /EdgA?[\s/]([\d.]+)/i],
91
+ ["Chrome", /(?:Chrome|CriOS)[\s/]([\d.]+)/i],
92
+ ["Firefox", /(?:Firefox|FxiOS)[\s/]([\d.]+)/i],
93
+ ["Safari", /Version[\s/]([\d.]+).*Safari/i],
94
+ ];
95
+ const matched = candidates.find(([, pattern]) => pattern.test(userAgent));
96
+ const browserMatch = matched?.[1]?.exec(userAgent);
97
+ const webkit = /AppleWebKit[\s/]([\d.]+)/i.exec(userAgent);
98
+ const chromium = /(?:Chrome|Chromium|CriOS)[\s/]([\d.]+)/i.exec(userAgent);
99
+
100
+ return {
101
+ browser_name: matched?.[0] || null,
102
+ browser_version: browserMatch?.[1] || null,
103
+ engine_name: chromium ? "Blink" : webkit ? "WebKit" : null,
104
+ webview_version: /;\s*wv\)|Version\/4\.0/i.test(userAgent)
105
+ ? browserMatch?.[1] || chromium?.[1] || webkit?.[1] || null
106
+ : null,
107
+ };
108
+ }
109
+
110
+ function supportsWebGl() {
111
+ try {
112
+ const canvas = document.createElement("canvas");
113
+ return !!(canvas.getContext("webgl") || canvas.getContext("experimental-webgl"));
114
+ } catch (e) {
115
+ return false;
116
+ }
117
+ }
118
+
119
+ function resolveOrientation() {
120
+ const type = String(window.screen?.orientation?.type || "");
121
+ if (type.startsWith("portrait")) return "portrait";
122
+ if (type.startsWith("landscape")) return "landscape";
123
+ return window.innerWidth > window.innerHeight ? "landscape" : "portrait";
124
+ }
125
+
126
+ function resolveDisplayMode() {
127
+ if (document.fullscreenElement) return "fullscreen";
128
+ if (window.matchMedia?.("(display-mode: standalone)")?.matches) return "standalone";
129
+ return "browser";
130
+ }
131
+
132
+ function resolveWebVersion() {
133
+ return (
134
+ window.__JX3BOX_VERSION__ ||
135
+ window.__APP_VERSION__ ||
136
+ process.env.VUE_APP_VERSION ||
137
+ process.env.VUE_APP_BUILD_VERSION ||
138
+ null
139
+ );
140
+ }
141
+
142
+ export function buildClientStatPayload(instanceId) {
143
+ const nav = window.navigator || {};
144
+ const screen = window.screen || {};
145
+ const connection = nav.connection || nav.mozConnection || nav.webkitConnection || {};
146
+ const userAgent = String(nav.userAgent || "");
147
+ const platform = resolvePlatform(userAgent, nav.platform);
148
+ const client = resolveClient(platform, userAgent);
149
+ const browser = parseBrowser(userAgent);
150
+
151
+ return compactPayload({
152
+ instance_id: instanceId,
153
+ product: "jx3box",
154
+ client,
155
+ platform,
156
+ domain: window.location.hostname,
157
+ channel: resolveChannel(),
158
+ web_version: resolveWebVersion(),
159
+ sdk_version: STAT_SDK_VERSION,
160
+
161
+ os_name: platform,
162
+ device_type: ["ios", "android"].includes(platform) ? (/iPad|Tablet/i.test(userAgent) ? "tablet" : "phone") : "desktop",
163
+ architecture: /arm64|aarch64/i.test(userAgent)
164
+ ? "arm64"
165
+ : /x86_64|x64|Win64|x86-64/i.test(userAgent)
166
+ ? "x64"
167
+ : null,
168
+ hardware_concurrency: rangedNumber(nav.hardwareConcurrency, 1, 1024, { integer: true }),
169
+ device_memory_gb: rangedNumber(nav.deviceMemory, 0.1, 655.35),
170
+
171
+ screen_width_css: rangedNumber(screen.width, 1, 100000, { integer: true }),
172
+ screen_height_css: rangedNumber(screen.height, 1, 100000, { integer: true }),
173
+ screen_avail_width_css: rangedNumber(screen.availWidth, 1, 100000, { integer: true }),
174
+ screen_avail_height_css: rangedNumber(screen.availHeight, 1, 100000, { integer: true }),
175
+ viewport_width_css: rangedNumber(window.innerWidth, 1, 100000, { integer: true }),
176
+ viewport_height_css: rangedNumber(window.innerHeight, 1, 100000, { integer: true }),
177
+ device_pixel_ratio: rangedNumber(window.devicePixelRatio, 0.1, 20),
178
+ color_depth: rangedNumber(screen.colorDepth, 1, 128, { integer: true }),
179
+ orientation: resolveOrientation(),
180
+ display_mode: resolveDisplayMode(),
181
+ prefers_dark: !!window.matchMedia?.("(prefers-color-scheme: dark)")?.matches,
182
+ prefers_reduced_motion: !!window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches,
183
+
184
+ ...browser,
185
+ language: nav.languages?.find(Boolean) || nav.language || null,
186
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || null,
187
+ touch_points: rangedNumber(nav.maxTouchPoints, 0, 100, { integer: true }),
188
+ capabilities: {
189
+ wasm: typeof WebAssembly !== "undefined",
190
+ service_worker: "serviceWorker" in nav,
191
+ webgl: supportsWebGl(),
192
+ },
193
+ network_type: String(connection.type || "unknown").toLowerCase(),
194
+ network_effective_type: String(connection.effectiveType || "unknown").toLowerCase(),
195
+ });
196
+ }
197
+
198
+ function getReportSignature() {
199
+ const uid = User.isLogin() ? Number(User.getInfo()?.uid || localStorage.getItem("uid") || 0) : 0;
200
+ return `${uid}:${resolveWebVersion() || "unknown"}`;
201
+ }
202
+
203
+ export async function reportClientStat({ force = false } = {}) {
204
+ if (reportTask) return reportTask;
205
+
206
+ reportTask = (async () => {
207
+ const today = chinaDate();
208
+ const signature = getReportSignature();
209
+ if (
210
+ !force &&
211
+ localStorage.getItem(REPORT_DATE_KEY) === today &&
212
+ localStorage.getItem(REPORT_SIGNATURE_KEY) === signature
213
+ ) {
214
+ return { reported: false, reason: "already_reported" };
215
+ }
216
+
217
+ const payload = buildClientStatPayload(ensureClientInstanceId());
218
+ const response = await $cms({ mute: true }).post("/api/cms/system/stat/heartbeat", payload, {
219
+ timeout: 10000,
220
+ skipForbiddenSessionClear: true,
221
+ });
222
+ localStorage.setItem(REPORT_DATE_KEY, today);
223
+ localStorage.setItem(REPORT_SIGNATURE_KEY, signature);
224
+ return { reported: true, data: response?.data?.data || null, payload };
225
+ })().finally(() => {
226
+ reportTask = null;
227
+ });
228
+
229
+ return reportTask;
230
+ }
231
+
232
+ function isLocalhost() {
233
+ return ["localhost", "127.0.0.1", "::1"].includes(window.location.hostname);
234
+ }
235
+
236
+ function startupDelay() {
237
+ if (process.env.NODE_ENV !== "production" || isLocalhost()) return 0;
238
+ return Math.floor(STARTUP_DELAY_MIN + Math.random() * (STARTUP_DELAY_MAX - STARTUP_DELAY_MIN));
239
+ }
240
+
241
+ export function scheduleClientStatReport({ force = false, delay = startupDelay() } = {}) {
242
+ if (scheduledTimer) {
243
+ if (!force) return;
244
+ window.clearTimeout(scheduledTimer);
245
+ }
246
+
247
+ scheduledTimer = window.setTimeout(() => {
248
+ scheduledTimer = null;
249
+ reportClientStat({ force }).catch(() => {
250
+ // 统计失败不影响公共头和宿主页面;未写成功标记时,下次恢复可见会继续尝试。
251
+ });
252
+ }, delay);
253
+ }
254
+
255
+ export function installClientStatReporting() {
256
+ scheduleClientStatReport({ force: isLocalhost() });
257
+ }
258
+
259
+ export function checkClientStatOnVisible() {
260
+ scheduleClientStatReport();
261
+ }
@@ -0,0 +1,31 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+
4
+ const root = path.resolve(__dirname, "..");
5
+ const commonHeader = fs.readFileSync(path.join(root, "src/CommonHeader.vue"), "utf8");
6
+ const clientStat = fs.readFileSync(path.join(root, "src/utils/client-stat.js"), "utf8");
7
+
8
+ function assert(condition, message) {
9
+ if (!condition) throw new Error(message);
10
+ }
11
+
12
+ assert(commonHeader.includes("installClientStatReporting()"), "CommonHeader should install client statistics");
13
+ assert(
14
+ commonHeader.includes("checkClientStatOnVisible()") && commonHeader.includes("visibilitychange"),
15
+ "CommonHeader should retry statistics when the page becomes visible"
16
+ );
17
+ assert(clientStat.includes('const INSTANCE_ID_KEY = "jx3box:device_id"'), "PC should reuse the client instance id key");
18
+ assert(clientStat.includes('product: "jx3box"'), "heartbeat should identify the JX3BOX product");
19
+ assert(clientStat.includes('const STAT_SDK_VERSION = "v0.0.1"'), "statistics SDK version should remain explicit");
20
+ assert(clientStat.includes('return "pc_web"'), "desktop browser traffic should report pc_web");
21
+ assert(clientStat.includes('return "mobile_web"'), "mobile browsers visiting PC pages should report mobile_web");
22
+ assert(clientStat.includes("/api/cms/system/stat/heartbeat"), "heartbeat should use the new statistics endpoint");
23
+ assert(clientStat.includes("$cms({ mute: true })"), "heartbeat should reuse the authenticated CMS client");
24
+ assert(
25
+ clientStat.indexOf("localStorage.setItem(REPORT_DATE_KEY") > clientStat.indexOf("await $cms"),
26
+ "successful report markers must only be stored after the request resolves"
27
+ );
28
+ assert(!clientStat.includes("current_uid:"), "heartbeat payload must not report uid");
29
+ assert(!clientStat.includes("ip:"), "heartbeat payload must not report ip");
30
+
31
+ console.log("client statistics checks passed");