@jx3box/jx3box-ui 2.4.3 → 2.4.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.
@@ -71,7 +71,6 @@
71
71
  .db;
72
72
  .auto(x);
73
73
  fill: @color;
74
- .tm(0.7);
75
74
  }
76
75
  .u-txt {
77
76
  .fz(12px, 2);
@@ -134,11 +133,17 @@
134
133
  }
135
134
 
136
135
  @media screen and (max-width: @phone) {
136
+ .c-header-jx3box {
137
+ padding-top: 16px;
138
+ background: #1d2128;
139
+ box-shadow: inset 0 1px rgba(255, 255, 255, 0.04), 0 8px 24px rgba(0, 0, 0, 0.28);
140
+ }
141
+
137
142
  .c-jx3box {
138
143
  height: calc(100vh - @header-height);
139
144
  .u-list {
140
145
  border-top: none;
141
- padding: 0 20px;
146
+ padding: 0 16px 18px;
142
147
  display: grid;
143
148
  grid-template-columns: repeat(4, 1fr);
144
149
  justify-content: center;
@@ -150,7 +155,7 @@
150
155
  flex: 1;
151
156
  }
152
157
  li {
153
- padding: 5px;
158
+ padding: 4px;
154
159
  .mr(0);
155
160
  .mb(0);
156
161
  }
@@ -158,9 +163,34 @@
158
163
  .u-item {
159
164
  .size(68px);
160
165
  .auto(x);
166
+ padding: 7px;
167
+ border-radius: 10px;
168
+ border-color: rgba(255, 255, 255, 0.08);
169
+ background: #292e37;
170
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.16);
171
+ transition: border-color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
172
+
161
173
  .u-pic {
162
174
  .size(28px);
163
175
  }
176
+
177
+ .u-txt {
178
+ color: #d4d7de;
179
+ }
180
+ }
181
+ .u-item:not(.u-doing):hover,
182
+ .u-item:not(.u-doing):active {
183
+ border-color: fade(@v4primary500, 55%);
184
+ background: fade(@v4primary500, 16%);
185
+ transform: translateY(-1px);
186
+ }
187
+ .u-close {
188
+ margin: 6px 16px 16px 0;
189
+ padding: 7px 12px;
190
+ border: 1px solid rgba(255, 255, 255, 0.1);
191
+ border-radius: 6px;
192
+ background: rgba(255, 255, 255, 0.06);
193
+ color: #d4d7de;
164
194
  }
165
195
  .u-app-start {
166
196
  clear: none;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jx3box/jx3box-ui",
3
- "version": "2.4.3",
3
+ "version": "2.4.5",
4
4
  "description": "JX3BOX Vue3 UI",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@element-plus/icons-vue": "^2.3.2",
37
- "@jx3box/jx3box-common": "9.5.2",
37
+ "@jx3box/jx3box-common": "^9.5.5",
38
38
  "@jx3box/jx3box-data": "^3.9.6",
39
39
  "@jx3box/jx3box-editor": "^3.2.13",
40
40
  "@jx3box/jx3box-emotion": "^1.3.2",
@@ -85,7 +85,14 @@ 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
+ import {
89
+ checkClientStatOnVisible,
90
+ ensureClientInstanceId,
91
+ installClientStatReporting,
92
+ resolveCurrentClientSurface,
93
+ resolveClientTrafficPermission,
94
+ } from "./utils/client-stat";
95
+ import { installCommonHeaderTrafficAnalytics } from "./utils/traffic-analytics";
89
96
  import User from "@jx3box/jx3box-common/js/user.js";
90
97
  import JX3BOX from "@jx3box/jx3box-common/data/jx3box.json";
91
98
 
@@ -105,7 +112,10 @@ export default {
105
112
  "header-box": box,
106
113
  "header-box2": box2,
107
114
  },
108
- props: ["overlayEnable"],
115
+ props: {
116
+ overlayEnable: Boolean,
117
+ trafficEnabled: { type: Boolean, default: true },
118
+ },
109
119
  data: function () {
110
120
  return {
111
121
  isOverlay: false,
@@ -449,6 +459,22 @@ export default {
449
459
  created: function () {
450
460
  this.init();
451
461
  installClientStatReporting();
462
+ if (this.trafficEnabled) {
463
+ try {
464
+ installCommonHeaderTrafficAnalytics({
465
+ router: this.$router,
466
+ runtime: window,
467
+ surface: resolveCurrentClientSurface(),
468
+ instanceId: ensureClientInstanceId(),
469
+ resolveTrafficPermission: resolveClientTrafficPermission,
470
+ webVersion: window.__APP_VERSION__ || process.env.VUE_APP_VERSION || process.env.VITE_APP_VERSION || null,
471
+ }).catch(function () {
472
+ // Traffic is fail-closed and must never affect the shared header or host page.
473
+ });
474
+ } catch (error) {
475
+ // Storage/privacy failures must not affect the shared header or host page.
476
+ }
477
+ }
452
478
  window.addEventListener("resize", this.updateScreen, { passive: true });
453
479
  document.addEventListener("visibilitychange", this.handleVisibilityChange);
454
480
 
@@ -0,0 +1,42 @@
1
+ import axios from "axios";
2
+ import { flatten } from "lodash";
3
+ import JX3BOX from "@jx3box/jx3box-common/data/jx3box.json";
4
+
5
+ const BOX_MENU_CACHE_KEY = "jx3box:box-menu:app-v1";
6
+
7
+ function readCache() {
8
+ try {
9
+ const data = JSON.parse(sessionStorage.getItem(BOX_MENU_CACHE_KEY));
10
+ return Array.isArray(data) && data.length ? data : null;
11
+ } catch (e) {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ function writeCache(data) {
17
+ try {
18
+ sessionStorage.setItem(BOX_MENU_CACHE_KEY, JSON.stringify(data));
19
+ } catch (e) {}
20
+ }
21
+
22
+ export function loadBoxMenu(fallback = []) {
23
+ const cached = readCache();
24
+ if (cached) return Promise.resolve(cached);
25
+
26
+ return axios
27
+ .get(JX3BOX.__imgPath + "logo/app.json")
28
+ .then((res) => {
29
+ const source = flatten(Object.values(res.data || {}));
30
+ const data = source.map((item) => ({
31
+ img: item.key + ".svg",
32
+ uuid: item.key,
33
+ abbr: item.label,
34
+ href: item.link,
35
+ client: item.client || ["std", "origin"],
36
+ }));
37
+ const result = data.length ? data : fallback;
38
+ writeCache(result);
39
+ return result;
40
+ })
41
+ .catch(() => fallback);
42
+ }
@@ -15,7 +15,7 @@
15
15
  <li v-for="(item, i) in list" :key="i" :class="{ 'u-app-start': item.lf }">
16
16
  <a class="u-item" :href="item.href" :target="getTarget(item.href)">
17
17
  <img class="u-pic" :src="getBoxIcon(item.img)" />
18
- <span class="u-txt">{{ item.abbr }}</span>
18
+ <span class="u-txt" :title="item.abbr">{{ getDisplayName(item.abbr) }}</span>
19
19
  </a>
20
20
  </li>
21
21
  </ul>
@@ -33,8 +33,8 @@ import Bus from "../../utils/bus";
33
33
  import JX3BOX from "@jx3box/jx3box-common/data/jx3box.json";
34
34
  import i18nMixin from "../../i18n/mixin";
35
35
  import box from "../../assets/data/box.json";
36
- import { getMenu } from "../../service/header.js";
37
36
  import { trimSlash } from "./utils";
37
+ import { loadBoxMenu } from "./box-menu";
38
38
 
39
39
  const { __imgPath, __cdn } = JX3BOX;
40
40
  export default {
@@ -56,7 +56,7 @@ export default {
56
56
  },
57
57
  computed: {
58
58
  homeicon: function () {
59
- return __imgPath + "image/box/home.svg";
59
+ return __cdn + "logo/logo-dark/home.svg";
60
60
  },
61
61
  homeicon_hover: function () {
62
62
  return __imgPath + "image/box/home_on.svg";
@@ -66,7 +66,8 @@ export default {
66
66
  },
67
67
  list: function () {
68
68
  return this.data.filter((item) => {
69
- return item.status && (item.client == this.client || item.client == "all");
69
+ const visible = item.status == null || !!item.status;
70
+ return visible && this.matchedClient(item.client);
70
71
  });
71
72
  },
72
73
  prefix: function () {
@@ -78,13 +79,25 @@ export default {
78
79
  Bus.emit("toggleBox", false);
79
80
  },
80
81
  matchedClient: function (client) {
81
- return client == "all" ? true : client == this.client;
82
+ const clients = Array.isArray(client)
83
+ ? client
84
+ : String(client || "")
85
+ .split(",")
86
+ .map((item) => item.trim())
87
+ .filter(Boolean);
88
+ return !clients.length || clients.includes("all") || clients.includes(this.client);
82
89
  },
83
90
  getBoxIcon: function (val) {
84
91
  val = val && val?.replace(".png", ".svg");
85
- let web_url = __cdn + "logo/logo-light/" + val;
92
+ let web_url = __cdn + "logo/logo-dark/" + val;
86
93
  return web_url;
87
94
  },
95
+ getDisplayName: function (name) {
96
+ const chars = Array.from(String(name || "").trim());
97
+ if (chars.length === 5) return chars.slice(0, 3).join("");
98
+ if (chars.length === 4) return chars.slice(0, 2).join("");
99
+ return chars.join("");
100
+ },
88
101
  getTarget: function (val) {
89
102
  if (window.innerWidth < 768 || val?.startsWith("/")) {
90
103
  return "_self";
@@ -93,20 +106,9 @@ export default {
93
106
  }
94
107
  },
95
108
  loadMenu() {
96
- try {
97
- const _box = JSON.parse(sessionStorage.getItem("box"));
98
- if (_box) {
99
- this.data = _box;
100
- } else {
101
- getMenu("box").then((res) => {
102
- this.data = res.data?.data?.val;
103
- sessionStorage.setItem("box", JSON.stringify(this.data));
104
- });
105
- }
106
- } catch (e) {
107
- this.data = box;
108
- console.log("loadBox error", e);
109
- }
109
+ loadBoxMenu(box).then((data) => {
110
+ this.data = data;
111
+ });
110
112
  },
111
113
  trimSlash(link) {
112
114
  return trimSlash(`${this.prefix}:${link}`);
@@ -1,36 +1,10 @@
1
1
  <template>
2
2
  <transition name="box2-fade">
3
- <div v-if="status" class="c-jx3box2-mask" @click="closeBox">
3
+ <div v-if="status" class="c-jx3box2-mask">
4
4
  <section class="c-jx3box2" @click.stop>
5
- <div class="u-topline"></div>
6
-
7
- <div class="u-head">
8
- <div class="u-title-wrap">
9
- <div class="u-logo">
10
- <img svg-inline src="../../assets/img/common/logo.svg" alt="JX3BOX" />
11
- </div>
12
- <div>
13
- <h2 class="u-title">{{ $jx3boxT("jx3boxUi.box2.title", "魔盒矩阵") }}</h2>
14
- <p class="u-subtitle">THE BOX MATRIX NAVIGATION</p>
15
- </div>
16
- </div>
17
- <div class="u-search">
18
- <el-icon class="u-search-icon"><Search></Search></el-icon>
19
- <input
20
- v-model.trim="searchQuery"
21
- class="u-search-input"
22
- type="text"
23
- :placeholder="$jx3boxT('jx3boxUi.box2.searchPlaceholder', '搜索应用')"
24
- />
25
- <button v-if="searchQuery" class="u-search-clear" type="button" @click="searchQuery = ''">
26
- <el-icon><Close /></el-icon>
27
- </button>
28
- </div>
29
- </div>
30
-
31
5
  <div class="u-body">
32
6
  <ul class="u-list">
33
- <li v-if="homeMatched">
7
+ <li>
34
8
  <a class="u-item" href="/index">
35
9
  <div class="u-icon-wrap">
36
10
  <img class="u-pic" svg-inline :src="homeicon" />
@@ -38,7 +12,7 @@
38
12
  <span class="u-txt">{{ $jx3boxT("jx3boxUi.commonHeader.home", "首页") }}</span>
39
13
  </a>
40
14
  </li>
41
- <li v-for="(item, i) in filteredList" :key="i">
15
+ <li v-for="(item, i) in list" :key="i">
42
16
  <a class="u-item" :href="item.href" :target="getTarget(item.href)">
43
17
  <div class="u-icon-wrap">
44
18
  <img class="u-pic" :src="getBoxIcon(item.img)" />
@@ -46,7 +20,7 @@
46
20
  <span class="u-txt">{{ item.abbr }}</span>
47
21
  </a>
48
22
  </li>
49
- <li v-if="allMatched && filteredList.length > 0">
23
+ <li v-if="list.length > 0">
50
24
  <a class="u-item" href="/app">
51
25
  <div class="u-icon-wrap">
52
26
  <img class="u-pic" svg-inline :src="allicon" />
@@ -55,9 +29,12 @@
55
29
  </a>
56
30
  </li>
57
31
  </ul>
58
- <div v-if="searchQuery && !homeMatched && filteredList.length === 0" class="u-empty">
59
- {{ $jx3boxT("jx3boxUi.box2.empty", "未找到匹配应用") }}
60
- </div>
32
+ </div>
33
+ <div class="u-actions">
34
+ <button class="u-collapse" type="button" @click="closeBox">
35
+ <el-icon><Upload /></el-icon>
36
+ <span>{{ $jx3boxT("jx3boxUi.commonHeader.collapse", "收起") }}</span>
37
+ </button>
61
38
  </div>
62
39
  </section>
63
40
  </div>
@@ -69,11 +46,9 @@ import Bus from "../../utils/bus";
69
46
  import JX3BOX from "@jx3box/jx3box-common/data/jx3box.json";
70
47
  import i18nMixin from "../../i18n/mixin";
71
48
  import box from "../../assets/data/box.json";
72
- import { getMenu } from "../../service/header.js";
73
- import axios from "axios";
74
- import { flatten } from "lodash";
49
+ import { loadBoxMenu } from "./box-menu";
75
50
 
76
- const { __cdn, __imgPath } = JX3BOX;
51
+ const { __cdn } = JX3BOX;
77
52
 
78
53
  export default {
79
54
  name: "Box2",
@@ -81,7 +56,6 @@ export default {
81
56
  data: function () {
82
57
  return {
83
58
  status: false,
84
- searchQuery: "",
85
59
  data: [],
86
60
  client: location.href.includes("origin") ? "origin" : "std",
87
61
  };
@@ -110,50 +84,8 @@ export default {
110
84
  return matchedClient;
111
85
  });
112
86
  },
113
- filteredList: function () {
114
- const query = (this.searchQuery || "").toLowerCase();
115
- if (!query) return this.list;
116
-
117
- const compactQuery = query.replace(/\s+/g, "");
118
- return this.list.filter((item) => {
119
- const abbr = String(item.abbr || "").toLowerCase();
120
- const abbrCompact = abbr.replace(/\s+/g, "");
121
- const uuid = String(item.uuid || "").toLowerCase();
122
- return abbr.includes(query) || abbrCompact.includes(compactQuery) || uuid.includes(query);
123
- });
124
- },
125
- homeMatched: function () {
126
- const query = (this.searchQuery || "").toLowerCase().trim();
127
- if (!query) return true;
128
- return this.$jx3boxT("jx3boxUi.commonHeader.home", "首页").includes(query);
129
- },
130
- allMatched: function () {
131
- const query = (this.searchQuery || "").toLowerCase().trim();
132
- if (!query) return true;
133
- return this.$jx3boxT("jx3boxUi.commonHeader.all", "全部").includes(query);
134
- },
135
87
  },
136
88
  methods: {
137
- setPageScrollLock(locked) {
138
- const docEl = document.documentElement;
139
- const body = document.body;
140
-
141
- if (locked) {
142
- if (this.__prevHtmlOverflow === undefined) {
143
- this.__prevHtmlOverflow = docEl.style.overflow;
144
- }
145
- if (this.__prevBodyOverflow === undefined) {
146
- this.__prevBodyOverflow = body.style.overflow;
147
- }
148
- docEl.style.overflow = "hidden";
149
- body.style.overflow = "hidden";
150
- } else {
151
- docEl.style.overflow = this.__prevHtmlOverflow ?? "";
152
- body.style.overflow = this.__prevBodyOverflow ?? "";
153
- this.__prevHtmlOverflow = undefined;
154
- this.__prevBodyOverflow = undefined;
155
- }
156
- },
157
89
  closeBox() {
158
90
  this.status = false;
159
91
  },
@@ -169,28 +101,9 @@ export default {
169
101
  }
170
102
  },
171
103
  loadMenu() {
172
- try {
173
- const data = JSON.parse(sessionStorage.getItem("box"));
174
- if (data) {
175
- this.data = data;
176
- } else {
177
- axios.get(__imgPath + "logo/app.json").then((res) => {
178
- const _data = flatten(Object.values(res.data || {}));
179
- this.data = _data.map((item) => ({
180
- img: item.key + ".svg",
181
- uuid: item.key,
182
- abbr: item.label,
183
- href: item.link,
184
- client: item.client || ["std", "origin"],
185
- }));
186
-
187
- if (!this.data.length) this.data = box;
188
- sessionStorage.setItem("box", JSON.stringify(this.data));
189
- });
190
- }
191
- } catch (e) {
192
- this.data = box;
193
- }
104
+ loadBoxMenu(box).then((data) => {
105
+ this.data = data;
106
+ });
194
107
  },
195
108
  onEsc(e) {
196
109
  if (e.key === "Escape") {
@@ -210,6 +123,8 @@ export default {
210
123
  }
211
124
  };
212
125
  Bus.on("toggleBox", this.__toggleHandler);
126
+ this.__docClickHandler = () => this.closeBox();
127
+ document.addEventListener("click", this.__docClickHandler);
213
128
  window.addEventListener("keydown", this.onEsc);
214
129
  },
215
130
  beforeUnmount() {
@@ -217,13 +132,11 @@ export default {
217
132
  Bus.off("toggleBox", this.__toggleHandler);
218
133
  this.__toggleHandler = null;
219
134
  }
135
+ if (this.__docClickHandler) {
136
+ document.removeEventListener("click", this.__docClickHandler);
137
+ this.__docClickHandler = null;
138
+ }
220
139
  window.removeEventListener("keydown", this.onEsc);
221
- this.setPageScrollLock(false);
222
- },
223
- watch: {
224
- status(val) {
225
- this.setPageScrollLock(!!val);
226
- },
227
140
  },
228
141
  };
229
142
  </script>
@@ -233,17 +146,13 @@ export default {
233
146
  .c-jx3box2-mask {
234
147
  position: fixed;
235
148
  left: 0;
236
- top: 0;
237
- z-index: 1200;
238
- width: 100vw;
239
- height: 100vh;
240
- display: flex;
241
- justify-content: center;
242
- align-items: center;
243
- padding: 20px;
244
- background: rgba(0, 0, 0, 0.72);
245
- backdrop-filter: blur(12px);
246
- -webkit-backdrop-filter: blur(12px);
149
+ top: @header-height;
150
+ z-index: 800;
151
+ width: 100%;
152
+ max-height: calc(100vh - @header-height);
153
+ overflow: hidden;
154
+ background: rgba(21, 25, 35, 0.98);
155
+ box-shadow: 0 18px 42px rgba(0, 0, 0, 0.36);
247
156
  }
248
157
  @media screen and (max-width: @phone) {
249
158
  .c-jx3box2-mask {
@@ -253,147 +162,33 @@ export default {
253
162
 
254
163
  .box2-fade-enter-active,
255
164
  .box2-fade-leave-active {
256
- transition: opacity 0.25s ease;
257
- }
258
-
259
- .box2-fade-enter-active .c-jx3box2,
260
- .box2-fade-leave-active .c-jx3box2 {
261
- transition: transform 0.25s ease, opacity 0.25s ease;
165
+ transition: opacity 0.2s ease, transform 0.2s ease;
262
166
  }
263
167
 
264
168
  .box2-fade-enter-from,
265
169
  .box2-fade-leave-to {
266
170
  opacity: 0;
267
- }
268
-
269
- .box2-fade-enter-from .c-jx3box2,
270
- .box2-fade-leave-to .c-jx3box2 {
271
- transform: translateY(16px) scale(0.96);
272
- opacity: 0;
171
+ transform: translateY(-12px);
273
172
  }
274
173
 
275
174
  .c-jx3box2 {
276
175
  position: relative;
277
- width: min(1120px, calc(100vw - 40px));
278
- max-height: min(860px, calc(100vh - 40px));
176
+ width: 100%;
177
+ max-height: calc(100vh - @header-height);
279
178
  display: flex;
280
179
  flex-direction: column;
281
180
  overflow: hidden;
282
- border-radius: 36px;
283
- border: 1px solid rgba(255, 255, 255, 0.1);
284
- background: rgba(21, 25, 35, 0.9);
285
- box-shadow: 0 30px 100px rgba(0, 0, 0, 0.45);
181
+ background: transparent;
286
182
  }
287
183
 
288
184
  .c-jx3box2 {
289
- .u-topline {
290
- height: 6px;
185
+ .u-body {
291
186
  width: 100%;
292
- background: linear-gradient(90deg, transparent, rgba(59, 130, 246, 0.6), transparent);
293
- }
294
-
295
- .u-head {
296
- padding: 28px 32px 24px;
297
- display: flex;
298
- align-items: center;
299
- gap: 20px;
300
- justify-content: space-between;
301
- flex-wrap: wrap;
302
- }
303
-
304
- .u-title-wrap {
305
- display: flex;
306
- align-items: center;
307
- gap: 14px;
308
- }
309
-
310
- .u-logo {
311
- width: 46px;
312
- height: 46px;
313
- border-radius: 14px;
314
- background: #fff;
315
- color: #fff;
316
- display: inline-flex;
317
- align-items: center;
318
- justify-content: center;
319
- box-shadow: 0 8px 24px rgba(37, 99, 235, 0.38);
320
- padding:5px;
321
- }
322
-
323
- .u-title {
324
187
  margin: 0;
325
- color: #fff;
326
- font-size: 28px;
327
- line-height: 1.1;
328
- font-weight: 800;
329
- }
330
-
331
- .u-subtitle {
332
- margin: 6px 0 0;
333
- color: #60a5fa;
334
- font-size: 10px;
335
- letter-spacing: 0.26em;
336
- font-weight: 700;
337
- }
338
-
339
- .u-search {
340
- width: min(460px, 100%);
341
- margin-left: auto;
342
- position: relative;
343
- .flex;
344
- align-items: center;
345
- }
346
-
347
- .u-search-input {
348
- width: 100%;
349
- height: 48px;
350
- border-radius: 16px;
351
- border: 1px solid rgba(255, 255, 255, 0.12);
352
- background: rgba(0, 0, 0, 0.3);
353
- color: #fff;
354
- outline: none;
355
- padding: 0 40px 0 40px;
356
188
  box-sizing: border-box;
357
- transition: all 0.2s ease;
358
- }
359
-
360
- .u-search-input::placeholder {
361
- color: #6b7280;
362
- }
363
-
364
- .u-search-input:focus {
365
- border-color: rgba(59, 130, 246, 0.5);
366
- box-shadow: 0 0 20px rgba(59, 130, 246, 0.2);
367
- }
368
-
369
- .u-search-icon {
370
- position: absolute;
371
- left: 14px;
372
- top: 50%;
373
- transform: translateY(-50%);
374
- color: #6b7280;
375
- font-size: 16px;
376
- }
377
-
378
- .u-search-clear {
379
- position: absolute;
380
- right: 10px;
381
- top: 50%;
382
- transform: translateY(-50%);
383
- border: 0;
384
- background: transparent;
385
- color: #798192;
386
- cursor: pointer;
387
- }
388
-
389
- .u-search-clear:hover {
390
- color: #fff;
391
- }
392
-
393
- .u-body {
394
- padding: 4px 32px 24px;
189
+ padding: 16px 24px 22px;
395
190
  flex: 1;
396
- min-height: 360px;
191
+ min-height: 0;
397
192
  display: flex;
398
193
  flex-direction: column;
399
194
  overflow-y: auto;
@@ -404,18 +199,18 @@ export default {
404
199
  padding: 0;
405
200
  list-style: none;
406
201
  display: grid;
407
- grid-template-columns: repeat(8, minmax(0, 1fr));
408
- gap: 14px;
202
+ grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
203
+ gap: 10px;
409
204
  align-content: start;
410
205
  }
411
206
 
412
207
  .u-item {
413
- height: 116px;
208
+ height: 88px;
414
209
  display: flex;
415
210
  flex-direction: column;
416
211
  align-items: center;
417
212
  justify-content: center;
418
- border-radius: 22px;
213
+ border-radius: 12px;
419
214
  background: rgba(255, 255, 255, 0.03);
420
215
  border: 1px solid rgba(255, 255, 255, 0.07);
421
216
  text-decoration: none;
@@ -423,16 +218,16 @@ export default {
423
218
  }
424
219
 
425
220
  .u-item:hover {
426
- transform: translateY(-4px);
427
- border-color: rgba(59, 130, 246, 0.5);
428
- background: rgba(59, 130, 246, 0.12);
429
- box-shadow: 0 14px 26px -12px rgba(0, 0, 0, 0.55), 0 0 16px rgba(59, 130, 246, 0.2);
221
+ transform: translateY(-2px);
222
+ border-color: fade(@v4primary500, 50%);
223
+ background: fade(@v4primary500, 12%);
224
+ box-shadow: 0 14px 26px -12px rgba(0, 0, 0, 0.55), 0 0 16px fade(@v4primary500, 20%);
430
225
  }
431
226
 
432
227
  .u-icon-wrap {
433
- width: 54px;
434
- height: 54px;
435
- border-radius: 14px;
228
+ width: 40px;
229
+ height: 40px;
230
+ border-radius: 10px;
436
231
  background: rgba(255, 255, 255, 0.05);
437
232
  display: flex;
438
233
  align-items: center;
@@ -441,17 +236,17 @@ export default {
441
236
  }
442
237
 
443
238
  .u-item:hover .u-icon-wrap {
444
- background: rgba(37, 99, 235, 0.18);
239
+ background: fade(@v4primary, 18%);
445
240
  }
446
241
 
447
242
  .u-pic {
448
- width: 30px;
449
- height: 30px;
243
+ width: 26px;
244
+ height: 26px;
450
245
  object-fit: contain;
451
246
  }
452
247
 
453
248
  .u-txt {
454
- margin-top: 10px;
249
+ margin-top: 7px;
455
250
  font-size: 12px;
456
251
  color: #a8b0be;
457
252
  line-height: 1.2;
@@ -461,13 +256,34 @@ export default {
461
256
  color: #fff;
462
257
  }
463
258
 
464
- .u-empty {
465
- flex: 1;
259
+ .u-actions {
260
+ width: 100%;
261
+ margin: 0;
262
+ padding: 0 24px 14px;
263
+ box-sizing: border-box;
466
264
  display: flex;
265
+ justify-content: flex-end;
266
+ }
267
+
268
+ .u-collapse {
269
+ display: inline-flex;
467
270
  align-items: center;
468
- justify-content: center;
469
- color: #8b95a7;
470
- font-size: 13px;
271
+ gap: 5px;
272
+ padding: 7px 12px;
273
+ border: 1px solid rgba(255, 255, 255, 0.1);
274
+ border-radius: 6px;
275
+ background: rgba(255, 255, 255, 0.06);
276
+ color: #a8b0be;
277
+ font-size: 12px;
278
+ line-height: 1;
279
+ cursor: pointer;
280
+ transition: all 0.2s ease;
281
+ }
282
+
283
+ .u-collapse:hover {
284
+ border-color: fade(@v4primary500, 45%);
285
+ background: fade(@v4primary500, 14%);
286
+ color: #fff;
471
287
  }
472
288
 
473
289
  .u-foot {
@@ -493,7 +309,7 @@ export default {
493
309
  width: 6px;
494
310
  height: 6px;
495
311
  border-radius: 50%;
496
- background: #3b82f6;
312
+ background: @v4primary500;
497
313
  }
498
314
 
499
315
  .u-close {
@@ -511,13 +327,13 @@ export default {
511
327
 
512
328
  @media screen and (max-width: 1200px) {
513
329
  .u-list {
514
- grid-template-columns: repeat(6, minmax(0, 1fr));
330
+ grid-template-columns: repeat(auto-fill, minmax(86px, 1fr));
515
331
  }
516
332
  }
517
333
 
518
334
  @media screen and (max-width: 992px) {
519
335
  .u-list {
520
- grid-template-columns: repeat(5, minmax(0, 1fr));
336
+ grid-template-columns: repeat(auto-fill, minmax(82px, 1fr));
521
337
  }
522
338
  }
523
339
  }
@@ -2,16 +2,22 @@ import { $cms } from "@jx3box/jx3box-common/js/api";
2
2
  import { isMiniProgram, isApp } from "@jx3box/jx3box-common/js/utils";
3
3
  import User from "@jx3box/jx3box-common/js/user";
4
4
  import { applyJx3boxClientStatRules } from "./client-stat-business";
5
+ import packageInfo from "../../package.json";
5
6
 
6
7
  const INSTANCE_ID_KEY = "jx3box:device_id";
7
8
  const REPORT_DATE_KEY = "jx3box:client-stat-report-date";
8
9
  const REPORT_SIGNATURE_KEY = "jx3box:client-stat-report-signature";
9
10
  const STAT_SDK_VERSION = "v0.0.2";
11
+ const PACKAGE_VERSION = packageInfo.version;
12
+ const WEB_VERSION = `jx3box-ui@${PACKAGE_VERSION}`;
10
13
  const STARTUP_DELAY_MIN = 3 * 1000;
11
14
  const STARTUP_DELAY_MAX = 15 * 1000;
12
15
 
13
16
  let reportTask = null;
14
17
  let scheduledTimer = null;
18
+ let trafficPermission = null;
19
+ let trafficPermissionCheckedAt = 0;
20
+ const TRAFFIC_PERMISSION_MAX_AGE = 5 * 60 * 1000;
15
21
 
16
22
  function compactPayload(payload) {
17
23
  return Object.fromEntries(
@@ -97,6 +103,12 @@ function resolveClient(platform, userAgent) {
97
103
  return "unknown";
98
104
  }
99
105
 
106
+ export function resolveCurrentClientSurface() {
107
+ const nav = window.navigator || {};
108
+ const userAgent = String(nav.userAgent || "");
109
+ return resolveClient(resolvePlatform(userAgent, nav.platform), userAgent);
110
+ }
111
+
100
112
  function parseBrowser(userAgent) {
101
113
  const candidates = [
102
114
  ["Edge", /EdgA?[\s/]([\d.]+)/i],
@@ -141,10 +153,6 @@ function resolveDisplayMode() {
141
153
  return "browser";
142
154
  }
143
155
 
144
- function resolveWebVersion() {
145
- return window.__APP_VERSION__ || process.env.VUE_APP_VERSION || process.env.VITE_APP_VERSION || null;
146
- }
147
-
148
156
  export function buildClientStatPayload(instanceId) {
149
157
  const nav = window.navigator || {};
150
158
  const screen = window.screen || {};
@@ -165,7 +173,9 @@ export function buildClientStatPayload(instanceId) {
165
173
  platform: context.platform,
166
174
  domain: window.location.hostname,
167
175
  channel: resolveChannel(),
168
- web_version: resolveWebVersion(),
176
+ app_version: "jx3box-ui",
177
+ app_build: PACKAGE_VERSION,
178
+ web_version: WEB_VERSION,
169
179
  sdk_version: STAT_SDK_VERSION,
170
180
 
171
181
  os_name: system.os_name,
@@ -208,7 +218,7 @@ export function buildClientStatPayload(instanceId) {
208
218
 
209
219
  function getReportSignature() {
210
220
  const uid = User.isLogin() ? Number(User.getInfo()?.uid || localStorage.getItem("uid") || 0) : 0;
211
- return `${uid}:${resolveWebVersion() || "unknown"}`;
221
+ return `${uid}:${WEB_VERSION}`;
212
222
  }
213
223
 
214
224
  export async function reportClientStat({ force = false } = {}) {
@@ -240,6 +250,24 @@ export async function reportClientStat({ force = false } = {}) {
240
250
  return reportTask;
241
251
  }
242
252
 
253
+ export async function resolveClientTrafficPermission({ force = false } = {}) {
254
+ if (!force && trafficPermission && Date.now() - trafficPermissionCheckedAt < TRAFFIC_PERMISSION_MAX_AGE) {
255
+ return trafficPermission;
256
+ }
257
+ try {
258
+ const result = await reportClientStat({ force: true });
259
+ const data = result?.data;
260
+ if (data && typeof data.traffic_allowed === "boolean") {
261
+ trafficPermission = data;
262
+ trafficPermissionCheckedAt = Date.now();
263
+ return data;
264
+ }
265
+ } catch (error) {
266
+ // Traffic remains fail-closed when heartbeat cannot confirm permission.
267
+ }
268
+ return null;
269
+ }
270
+
243
271
  function isLocalhost() {
244
272
  return ["localhost", "127.0.0.1", "::1"].includes(window.location.hostname);
245
273
  }
@@ -15,6 +15,127 @@ const DEFAULT_BATCH_ENDPOINT = "/api/cms/system/traffic/visits/batch";
15
15
  const DEFAULT_QUEUE_STORAGE_KEY = "jx3box:analytics:traffic:queue:v1";
16
16
  const DEFAULT_BLOCK_STORAGE_KEY = "jx3box:analytics:traffic:block:v1";
17
17
  const EMBEDDED_SURFACES = new Set(["app", "miniprogram", "pc_game", "mobile_game"]);
18
+ const COMMON_HEADER_INSTALLATIONS = new WeakMap();
19
+ const COMMON_HEADER_TRAFFIC_PROJECT = "jx3box-ui";
20
+
21
+ function normalizeRoutePath(value) {
22
+ let path = String(value || "")
23
+ .split(/[?#]/, 1)[0]
24
+ .trim();
25
+ if (!path) return "";
26
+ if (!path.startsWith("/")) path = `/${path}`;
27
+ path = path.replace(/\/{2,}/g, "/");
28
+ return path.length > 1 ? path.replace(/\/+$/, "") : path;
29
+ }
30
+
31
+ function normalizeVueRouteTemplate(value) {
32
+ const path = normalizeRoutePath(value);
33
+ if (!path || path === "/") return path;
34
+ return `/${path
35
+ .slice(1)
36
+ .split("/")
37
+ .map(function (segment) {
38
+ if (!segment.startsWith(":")) return segment;
39
+ const name = segment.slice(1).match(/^[A-Za-z_][A-Za-z0-9_]*/)?.[0];
40
+ if (!name) return segment;
41
+ // Backend rules own parameter validation. Vue Router custom regexes
42
+ // such as :id(\d+) are local matching details and must normalize to
43
+ // the registered cross-client template :id.
44
+ return `:${name}${segment.endsWith("?") ? "?" : ""}`;
45
+ })
46
+ .join("/")}`;
47
+ }
48
+
49
+ function routeTemplate(route) {
50
+ const source = route || {};
51
+ const meta = source.meta && typeof source.meta === "object" ? source.meta : {};
52
+ const analytics = meta.analytics && typeof meta.analytics === "object" ? meta.analytics : {};
53
+ const matched = Array.isArray(source.matched) ? source.matched : [];
54
+ const record = matched.length ? matched[matched.length - 1] : null;
55
+ return normalizeVueRouteTemplate(
56
+ analytics.route_pattern || analytics.routePattern || (record && record.path) || source.path || ""
57
+ );
58
+ }
59
+
60
+ function routerBase(router) {
61
+ return normalizeRoutePath(router?.options?.history?.base || router?.history?.base || "/") || "/";
62
+ }
63
+
64
+ function resolveAbsoluteRoutePattern(router, route, runtime) {
65
+ const pattern = routeTemplate(route);
66
+ if (!pattern) return "";
67
+ const base = routerBase(router);
68
+ if (base === "/" || pattern === base || pattern.startsWith(`${base}/`)) return pattern;
69
+
70
+ // Multi-entry PC projects mount their Router below paths such as /macro or /pet.
71
+ // Vue Router exposes only / and /:id inside that base, so retain the route
72
+ // template while restoring its public prefix. location.pathname is used only
73
+ // to verify that this Router currently owns the browser path; real IDs never
74
+ // replace the template or enter the long-term page key.
75
+ const pathname = normalizeRoutePath(runtime?.location?.pathname || "");
76
+ if (pathname && pathname !== base && !pathname.startsWith(`${base}/`)) return pattern;
77
+ return pattern === "/" ? base : `${base}${pattern}`;
78
+ }
79
+
80
+ function createAbsoluteRouteRouter(router, runtime) {
81
+ if (!router || typeof router.afterEach !== "function") return router;
82
+ const adaptRoute = function (route) {
83
+ if (!route) return route;
84
+ const routePattern = resolveAbsoluteRoutePattern(router, route, runtime);
85
+ if (!routePattern) return route;
86
+ const meta = route.meta && typeof route.meta === "object" ? route.meta : {};
87
+ const analytics = meta.analytics && typeof meta.analytics === "object" ? meta.analytics : {};
88
+ return Object.assign({}, route, {
89
+ meta: Object.assign({}, meta, {
90
+ analytics: Object.assign({}, analytics, { route_pattern: routePattern }),
91
+ }),
92
+ });
93
+ };
94
+ const adapter = Object.create(router);
95
+ adapter.afterEach = function (handler) {
96
+ return router.afterEach(function (to, from, failure) {
97
+ return handler(adaptRoute(to), adaptRoute(from), failure);
98
+ });
99
+ };
100
+ if (typeof router.isReady === "function") adapter.isReady = router.isReady.bind(router);
101
+ const currentRoute = router.currentRoute;
102
+ if (currentRoute && typeof currentRoute === "object" && "value" in currentRoute) {
103
+ adapter.currentRoute = {};
104
+ Object.defineProperty(adapter.currentRoute, "value", {
105
+ enumerable: true,
106
+ get: function () {
107
+ return adaptRoute(currentRoute.value);
108
+ },
109
+ });
110
+ } else {
111
+ Object.defineProperty(adapter, "currentRoute", {
112
+ enumerable: true,
113
+ get: function () {
114
+ return adaptRoute(router.currentRoute);
115
+ },
116
+ });
117
+ }
118
+ return adapter;
119
+ }
120
+
121
+ function queryValue(runtime, key) {
122
+ const location = (runtime && runtime.location) || {};
123
+ const sources = [location.search, String(location.hash || "").split("?")[1]];
124
+ for (const source of sources) {
125
+ if (!source) continue;
126
+ const value = new URLSearchParams(String(source).replace(/^\?/, "")).get(key);
127
+ if (value !== null) return value;
128
+ }
129
+ return "";
130
+ }
131
+
132
+ function resolvePcTrafficGameClient(runtime) {
133
+ const explicitClient = String(queryValue(runtime, "client") || "").toLowerCase();
134
+ if (explicitClient === "std" || explicitClient === "origin") return explicitClient;
135
+ const hostname = String(runtime?.location?.hostname || "").toLowerCase();
136
+ if (hostname === "origin.jx3box.com") return "origin";
137
+ return "std";
138
+ }
18
139
 
19
140
  function safeReason(value, fallback) {
20
141
  const reason = String(value || fallback || "traffic_disabled")
@@ -432,12 +553,55 @@ function createJx3boxTrafficAnalytics(options) {
432
553
  return api;
433
554
  }
434
555
 
556
+ function installCommonHeaderTrafficAnalytics(options) {
557
+ const settings = options || {};
558
+ const router = settings.router;
559
+ if (!router || typeof router.afterEach !== "function") return Promise.resolve(null);
560
+ if (router.__jx3boxAnalyticsRouterOwner__) return Promise.resolve(null);
561
+ const existing = COMMON_HEADER_INSTALLATIONS.get(router);
562
+ if (existing) return existing;
563
+
564
+ const runtime = settings.runtime || (typeof window !== "undefined" ? window : {});
565
+ const surface = settings.surface || "pc_web";
566
+ if (surface !== "pc_web" && surface !== "mobile_web") return Promise.resolve(null);
567
+ const project = COMMON_HEADER_TRAFFIC_PROJECT;
568
+ const analyticsRouter = createAbsoluteRouteRouter(router, runtime);
569
+
570
+ const task = Promise.resolve(typeof router.isReady === "function" ? router.isReady() : undefined)
571
+ .then(function () {
572
+ const collector = createJx3boxTrafficAnalytics({
573
+ runtime,
574
+ router: analyticsRouter,
575
+ project,
576
+ product: "jx3box",
577
+ client: surface,
578
+ surface,
579
+ gameClient: function () { return resolvePcTrafficGameClient(runtime); },
580
+ platform: settings.platform || "web",
581
+ webVersion: settings.webVersion,
582
+ instanceId: settings.instanceId,
583
+ resolveTrafficPermission: settings.resolveTrafficPermission,
584
+ });
585
+ return collector.init().then(function () { return collector; });
586
+ })
587
+ .catch(function (error) {
588
+ COMMON_HEADER_INSTALLATIONS.delete(router);
589
+ throw error;
590
+ });
591
+ COMMON_HEADER_INSTALLATIONS.set(router, task);
592
+ return task;
593
+ }
594
+
435
595
  export {
436
596
  DEFAULT_BATCH_ENDPOINT,
437
597
  DEFAULT_BLOCK_STORAGE_KEY,
438
598
  DEFAULT_CONFIG_ENDPOINT,
439
599
  DEFAULT_QUEUE_STORAGE_KEY,
440
600
  createJx3boxTrafficAnalytics,
601
+ createAbsoluteRouteRouter,
602
+ installCommonHeaderTrafficAnalytics,
441
603
  normalizeRecipientDomain,
442
604
  normalizeTrafficPermission,
605
+ resolvePcTrafficGameClient,
606
+ resolveAbsoluteRoutePattern,
443
607
  };
@@ -11,12 +11,23 @@ function assert(condition, message) {
11
11
  }
12
12
 
13
13
  assert(commonHeader.includes("installClientStatReporting()"), "CommonHeader should install client statistics");
14
+ assert(
15
+ commonHeader.includes("installCommonHeaderTrafficAnalytics"),
16
+ "CommonHeader should automatically install the shared Traffic collector"
17
+ );
14
18
  assert(
15
19
  commonHeader.includes("checkClientStatOnVisible()") && commonHeader.includes("visibilitychange"),
16
20
  "CommonHeader should retry statistics when the page becomes visible"
17
21
  );
18
22
  assert(clientStat.includes('const INSTANCE_ID_KEY = "jx3box:device_id"'), "PC should reuse the client instance id key");
19
23
  assert(clientStat.includes('product: "jx3box"'), "heartbeat should identify the JX3BOX product");
24
+ assert(clientStat.includes('app_version: "jx3box-ui"'), "PC heartbeat should identify the shared header reporter");
25
+ assert(clientStat.includes("app_build: PACKAGE_VERSION"), "PC heartbeat should report jx3box-ui package.json.version");
26
+ assert(
27
+ clientStat.includes("const WEB_VERSION = `jx3box-ui@${PACKAGE_VERSION}`"),
28
+ "PC heartbeat should namespace its web version"
29
+ );
30
+ assert(clientStat.includes("web_version: WEB_VERSION"), "PC heartbeat should report the namespaced web version");
20
31
  assert(clientStat.includes('const STAT_SDK_VERSION = "v0.0.2"'), "statistics SDK version should remain explicit");
21
32
  assert(clientStat.includes('return "pc_web"'), "desktop browser traffic should report pc_web");
22
33
  assert(clientStat.includes('return "mobile_web"'), "mobile browsers visiting PC pages should report mobile_web");
@@ -30,10 +41,6 @@ assert(
30
41
  clientStat.includes("/ArkWeb|HarmonyOS|OpenHarmony/i") && clientStat.includes('os_name: "OpenHarmony"'),
31
42
  "PC statistics should normalize OpenHarmony before reporting"
32
43
  );
33
- assert(
34
- clientStat.includes("window.__APP_VERSION__ || process.env.VUE_APP_VERSION || process.env.VITE_APP_VERSION"),
35
- "web version should use the shared app version fallback chain"
36
- );
37
44
  assert(!clientStat.includes("__JX3BOX_VERSION__"), "shared statistics must not depend on a JX3BOX-specific version global");
38
45
  assert(!clientStat.includes("VUE_APP_BUILD_VERSION"), "web version should not use the deprecated build-version fallback");
39
46
  assert(clientStat.includes("/api/cms/system/stat/heartbeat"), "heartbeat should use the new statistics endpoint");
@@ -16,6 +16,7 @@ require("@babel/register")({
16
16
  });
17
17
 
18
18
  const assert = require("node:assert/strict");
19
+ const fs = require("node:fs");
19
20
  const { test } = require("node:test");
20
21
  const fixture = require("@jx3box/jx3box-common/docs/fixtures/analytics-dual-sink-v1.json");
21
22
  const SHARED_ANALYTICS_QUEUE_STORAGE_KEY = "jx3box:analytics:queue:v1";
@@ -23,10 +24,17 @@ const { createEventQueue, createQueueStorage, createTrafficSink } = require("@jx
23
24
  const {
24
25
  DEFAULT_BLOCK_STORAGE_KEY,
25
26
  DEFAULT_QUEUE_STORAGE_KEY,
27
+ createAbsoluteRouteRouter,
26
28
  createJx3boxTrafficAnalytics,
27
29
  normalizeRecipientDomain,
28
30
  normalizeTrafficPermission,
31
+ resolvePcTrafficGameClient,
32
+ resolveAbsoluteRoutePattern,
29
33
  } = require("../src/utils/traffic-analytics.js");
34
+ const trafficSource = fs.readFileSync(path.resolve(__dirname, "../src/utils/traffic-analytics.js"), "utf8");
35
+
36
+ assert.match(trafficSource, /const COMMON_HEADER_TRAFFIC_PROJECT = "jx3box-ui"/);
37
+ assert.doesNotMatch(trafficSource, /PC_PROJECT_ROOTS|resolvePcTrafficProject/);
30
38
 
31
39
  function createMemoryStorage(initial) {
32
40
  const values = new Map(Object.entries(initial || {}));
@@ -85,6 +93,39 @@ function createRouter() {
85
93
  };
86
94
  }
87
95
 
96
+ test("CommonHeader restores a multi-entry Router base without exposing real path IDs", () => {
97
+ const router = createRouter();
98
+ router.options = { history: { base: "/macro/" } };
99
+ const runtime = { location: { pathname: "/macro/123" } };
100
+ const detail = {
101
+ name: "single",
102
+ matched: [{ path: "/:id(\\d+)" }],
103
+ meta: {},
104
+ params: { id: "123" },
105
+ };
106
+ assert.equal(resolveAbsoluteRoutePattern(router, detail, runtime), "/macro/:id");
107
+ assert.equal(resolveAbsoluteRoutePattern(router, { matched: [{ path: "/" }], meta: {} }, runtime), "/macro");
108
+
109
+ const adapted = createAbsoluteRouteRouter(router, runtime);
110
+ let received = null;
111
+ adapted.afterEach((to) => {
112
+ received = to;
113
+ });
114
+ router.navigate(detail);
115
+ assert.equal(received.meta.analytics.route_pattern, "/macro/:id");
116
+ assert.equal(received.meta.analytics.route_pattern.includes("123"), false);
117
+ });
118
+
119
+ test("CommonHeader does not duplicate an already absolute route template", () => {
120
+ const router = createRouter();
121
+ router.options = { history: { base: "/pet" } };
122
+ const runtime = { location: { pathname: "/pet/456" } };
123
+ assert.equal(
124
+ resolveAbsoluteRoutePattern(router, { matched: [{ path: "/pet/:id" }], meta: {} }, runtime),
125
+ "/pet/:id"
126
+ );
127
+ });
128
+
88
129
  function createRuntime(options) {
89
130
  const settings = options || {};
90
131
  const listeners = new Map();
@@ -204,6 +245,24 @@ test("recipient domains preserve web hosts and normalize embedded surfaces", ()
204
245
  assert.equal(normalizeRecipientDomain("https://www.jx3box.com/private", "pc_web"), "");
205
246
  });
206
247
 
248
+ test("PC CommonHeader resolves query-first game_client and defaults every non-origin host to std", () => {
249
+ assert.equal(
250
+ resolvePcTrafficGameClient({ location: { hostname: "www.jx3box.com", search: "?client=origin" } }),
251
+ "origin"
252
+ );
253
+ assert.equal(
254
+ resolvePcTrafficGameClient({ location: { hostname: "origin.jx3box.com", search: "?client=std" } }),
255
+ "std"
256
+ );
257
+ assert.equal(
258
+ resolvePcTrafficGameClient({ location: { hostname: "origin.jx3box.com", search: "?client=all" } }),
259
+ "origin"
260
+ );
261
+ assert.equal(resolvePcTrafficGameClient({ location: { hostname: "localhost", search: "" } }), "std");
262
+ assert.equal(resolvePcTrafficGameClient({ location: { hostname: "localhost" } }), "std");
263
+ assert.equal(resolvePcTrafficGameClient({ location: { hostname: "other.jx3box.com" } }), "std");
264
+ });
265
+
207
266
  test("headless opt-in emits the official Traffic fixture once and honors per-id ACK", async () => {
208
267
  const requests = [];
209
268
  const timers = createNoopTimers();