@jx3box/jx3box-ui 2.3.10 → 2.3.11

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.
@@ -48,10 +48,18 @@ function respond(config, data, status = 200) {
48
48
  }
49
49
 
50
50
  function mockCmsConfig(key) {
51
+ if (String(key || '').includes(',')) {
52
+ return String(key)
53
+ .split(',')
54
+ .map((item) => item.trim())
55
+ .filter(Boolean)
56
+ .map((item) => ({ key: item, ...mockCmsConfig(item) }));
57
+ }
51
58
  if (key === 'boxcoin') return { val: 1 };
52
59
  if (key === 'admin_boxcoin_visible') return { val: 1 };
53
60
  if (key === 'level_has_gift_permission') return { val: 0 };
54
61
  if (key === 'comment_strict') return { val: 0 };
62
+ if (key === 'user_profile_ready') return { val: 1 };
55
63
  return { val: '' };
56
64
  }
57
65
 
@@ -68,6 +76,99 @@ export function resolveStorybookMock(config) {
68
76
  return respond(config, { data: mockCmsConfig(params.key) });
69
77
  }
70
78
 
79
+ if (method === 'get' && path === '/api/cms/user/my/account/status') {
80
+ return respond(config, {
81
+ data: {
82
+ has_phone: false,
83
+ has_email: false,
84
+ email_verified: false,
85
+ has_verified_email: false,
86
+ has_password: false,
87
+ },
88
+ });
89
+ }
90
+
91
+ if (method === 'get' && path === '/api/letter/unread/count') {
92
+ return respond(config, { data: 0 });
93
+ }
94
+
95
+ if (method === 'get' && path === '/api/next2/userdata/messages/unread_total') {
96
+ return respond(config, { data: 0 });
97
+ }
98
+
99
+ if (method === 'get' && path === '/api/cms/user/conf') {
100
+ return respond(config, { data: null });
101
+ }
102
+
103
+ if (method === 'get' && path === '/api/cms/user/my/info') {
104
+ return respond(config, {
105
+ data: {
106
+ uid: 8,
107
+ name: 'Storybook用户',
108
+ group: 1,
109
+ token: 'storybook-token',
110
+ status: 0,
111
+ bind_wx: 0,
112
+ avatar: 'https://cdn.jx3box.com/upload/avatar/2022/3/2/8_9860765.png',
113
+ },
114
+ });
115
+ }
116
+
117
+ if (method === 'get' && path === '/api/cms/config/menu/panel') {
118
+ return respond(config, {
119
+ data: {
120
+ val: [
121
+ {
122
+ key: 'featureUpdate',
123
+ label: '功能更新',
124
+ link: '/notice',
125
+ icon: 'notice.svg',
126
+ remark: 'feature',
127
+ meta: 'storybook-feature',
128
+ },
129
+ {
130
+ key: 'manageCenter',
131
+ label: '后台管理',
132
+ link: '/os',
133
+ icon: 'cube.svg',
134
+ onlyAdmin: true,
135
+ },
136
+ ],
137
+ },
138
+ });
139
+ }
140
+
141
+ if (method === 'get' && path === '/api/cms/user/my/meta') {
142
+ return respond(config, { data: null });
143
+ }
144
+
145
+ if (method === 'post' && path === '/api/cms/user/account/email/logout') {
146
+ return respond(config, { data: true });
147
+ }
148
+
149
+ if (method === 'post' && path === '/api/personal/task/everyday/sign-in') {
150
+ return respond(config, { code: 0, data: true });
151
+ }
152
+
153
+ if (method === 'get' && path === '/api/vip/i') {
154
+ return respond(config, {
155
+ data: {
156
+ was_vip: 0,
157
+ expire_date: '1970-02-02T16:00:00.000Z',
158
+ total_day: 0,
159
+ was_pro: 0,
160
+ pro_expire_date: '1970-02-02T16:00:00.000Z',
161
+ pro_total_day: 0,
162
+ rename_card_count: 0,
163
+ had_renamed: 0,
164
+ namespace_card_count: 0,
165
+ box_coin: 0,
166
+ points: 0,
167
+ experience: 0,
168
+ },
169
+ });
170
+ }
171
+
71
172
  if (method === 'get' && path.match(/^\/api\/cms\/post\/\d+\/authors$/)) {
72
173
  return respond(config, { data: mockCreators });
73
174
  }
@@ -0,0 +1,56 @@
1
+ # Auth token refresh
2
+
3
+ PC 端自动续期挂在公共头 `src/CommonHeader.vue`。这个组件会被各 PC 页面引用,因此不要把 refresh 逻辑散到各业务页面。
4
+
5
+ ## Source of truth
6
+
7
+ - 续期接口:`POST /api/cms/user/account/token/refresh`
8
+ - service:`service/cms.js#refreshAuth`
9
+ - 续期工具:`src/utils/auth-token-refresh.js`
10
+ - 触发组件:`src/CommonHeader.vue`
11
+
12
+ ## 触发点
13
+
14
+ - 公共头初始化时,如果 `User.isLogin()` 为 true,检查一次。
15
+ - 页面从后台恢复可见时,监听 `visibilitychange`,在 `document.visibilityState === "visible"` 时检查一次。
16
+
17
+ 检查函数会先解析本地 JWT payload,未进入续期窗口时不会请求后端。
18
+
19
+ ## 续期窗口
20
+
21
+ 当前规则:
22
+
23
+ - token 距离 `exp` 小于等于 7 天。
24
+ - token 距离 `iat` 至少 1 天。
25
+ - token 已经过期时不刷新。
26
+ - 同一时间只允许一个 refresh 请求在飞。
27
+
28
+ ## PC 账号切换注意事项
29
+
30
+ PC 公共库存在马甲切换:
31
+
32
+ - 马甲缓存 key:`jx3box-alternate-{uid}`
33
+ - 当前请求 token 读取优先级:`__token` 优先于 `token`
34
+
35
+ 因此 refresh 成功后必须同时更新:
36
+
37
+ - `token`
38
+ - `__token`
39
+ - `created_at`
40
+ - 当前激活账号的 `jx3box-alternate-{uid}` 记录
41
+
42
+ 否则用户切换马甲时可能拿回旧 token,或者后续请求继续优先使用旧 `__token`。
43
+
44
+ ## 自动续期开关
45
+
46
+ 默认开启。可通过 localStorage 关闭:
47
+
48
+ ```js
49
+ localStorage.setItem("jx3box:auto_refresh_token", "false");
50
+ ```
51
+
52
+ 值为 `"false"` 或 `"0"` 时不自动续期;其它情况按默认开启处理。
53
+
54
+ ## 失败语义
55
+
56
+ 自动续期失败不主动登出,也不阻断公共头渲染。后续业务请求如果发现 token 过期或无权限,仍按宿主项目既有鉴权失败逻辑处理。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jx3box/jx3box-ui",
3
- "version": "2.3.10",
3
+ "version": "2.3.11",
4
4
  "description": "JX3BOX Vue3 UI",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/service/cms.js CHANGED
@@ -167,7 +167,7 @@ function setUserMeta(key, data) {
167
167
 
168
168
  // 刷新凭证
169
169
  function refreshAuth() {
170
- return $cms().post(`/api/cms/user/account/email/refresh`);
170
+ return $cms().post(`/api/cms/user/account/token/refresh`);
171
171
  }
172
172
 
173
173
  function getUserHonor(uid) {
@@ -202,6 +202,14 @@ function getUserPermission() {
202
202
  return $cms().get(`/api/cms/account/permission/i`);
203
203
  }
204
204
 
205
+ function getMyAccountStatus() {
206
+ return $cms({ mute: true })
207
+ .get(`/api/cms/user/my/account/status`)
208
+ .then((res) => {
209
+ return res.data.data;
210
+ });
211
+ }
212
+
205
213
  export {
206
214
  getPost,
207
215
  getPostAuthors,
@@ -227,6 +235,7 @@ export {
227
235
  uploadFile,
228
236
  refreshQQBotImage,
229
237
  getUserPermission,
238
+ getMyAccountStatus,
230
239
  getUserConfig,
231
240
  setUserConfig,
232
241
  };
@@ -26,11 +26,40 @@
26
26
  :header-config="headerConfig"
27
27
  :header-config-loaded="headerConfigLoaded"
28
28
  :header-config-managed="true"
29
+ :account-ready-issues="accountReadyIssues"
30
+ @open-account-ready="openAccountReadyDialog"
29
31
  />
30
32
  </div>
31
33
  <header-box v-if="isMobile" class="c-header__box c-header-jx3box" :overlayEnable="overlayEnable" />
32
34
  <header-box2 v-else class="c-header__box c-header__box--desktop" />
33
35
  </header>
36
+ <el-dialog
37
+ v-model="accountReadyDialogVisible"
38
+ class="c-header-account-ready"
39
+ width="min(92vw, 520px)"
40
+ title="⚠️ 完善账号安全信息"
41
+ append-to-body
42
+ @close="markAccountReadyDismissed"
43
+ >
44
+ <div class="c-header-account-ready__body">
45
+ <p class="u-desc">为了保障 App 登录、账号找回和卡密等功能正常使用,请先补全以下账号安全信息。</p>
46
+ <div class="u-list">
47
+ <div class="u-item" v-if="accountReadyIssues.includes('contact')">
48
+ <div class="u-title">绑定邮箱或手机号</div>
49
+ <div class="u-text">当前账号还没有可用的邮箱或手机号。请至少绑定其中一项,否则后续可能无法找回账号。</div>
50
+ <el-button type="primary" @click="goAccountReady('/dashboard/notice')">去绑定</el-button>
51
+ </div>
52
+ <div class="u-item" v-if="accountReadyIssues.includes('password')">
53
+ <div class="u-title">设置登录密码</div>
54
+ <div class="u-text">当前账号还没有设置密码。未设置密码时,可能无法使用卡密等功能,也无法在 App 中通过账号密码登录。</div>
55
+ <el-button type="primary" @click="goAccountReady('/dashboard/pwd')">去设置</el-button>
56
+ </div>
57
+ </div>
58
+ </div>
59
+ <template #footer>
60
+ <el-button @click="accountReadyDialogVisible = false">7天后再说</el-button>
61
+ </template>
62
+ </el-dialog>
34
63
  </template>
35
64
 
36
65
  <script>
@@ -53,11 +82,14 @@ import miniprogram from "@jx3box/jx3box-common/data/miniprogram.json";
53
82
 
54
83
  // 数据
55
84
  import { getGlobalConfig } from "../service/header";
56
- import { getConfig } from "../service/cms";
85
+ import { getConfig, getMyAccountStatus } from "../service/cms";
86
+ import { refreshTokenIfNeeded } from "./utils/auth-token-refresh";
57
87
  import User from "@jx3box/jx3box-common/js/user.js";
58
88
  import JX3BOX from "@jx3box/jx3box-common/data/jx3box.json";
59
89
 
60
- const HEADER_CONFIG_KEYS = ["important_notice", "important_notice_url", "vip", "mall"];
90
+ const HEADER_CONFIG_KEYS = ["important_notice", "important_notice_url", "vip", "mall", "user_profile_ready"];
91
+ const ACCOUNT_READY_STORAGE_PREFIX = "jx3box:account-ready-dismissed-until";
92
+ const ACCOUNT_READY_DISMISS_INTERVAL = 7 * 24 * 60 * 60 * 1000;
61
93
 
62
94
  export default {
63
95
  name: "Header",
@@ -80,6 +112,9 @@ export default {
80
112
  asset: {},
81
113
  headerConfig: {},
82
114
  headerConfigLoaded: false,
115
+ accountReadyDialogVisible: false,
116
+ accountReadyIssues: [],
117
+ accountReadyChecking: false,
83
118
  };
84
119
  },
85
120
  computed: {
@@ -156,6 +191,7 @@ export default {
156
191
 
157
192
  if (User.isLogin()) {
158
193
  this.loadAsset();
194
+ this.refreshAuthToken();
159
195
  }
160
196
 
161
197
  // 获取全局配置
@@ -196,6 +232,18 @@ export default {
196
232
  });
197
233
  },
198
234
 
235
+ refreshAuthToken: function () {
236
+ refreshTokenIfNeeded().catch(() => {
237
+ // 自动续期失败不影响公共头渲染,后续鉴权请求会按既有逻辑处理登录态。
238
+ });
239
+ },
240
+
241
+ handleVisibilityChange: function () {
242
+ if (document.visibilityState === "visible") {
243
+ this.refreshAuthToken();
244
+ }
245
+ },
246
+
199
247
  getUrlParam(name) {
200
248
  var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
201
249
  var r = window.location.search.substr(1).match(reg);
@@ -219,6 +267,7 @@ export default {
219
267
  })
220
268
  .then((data) => {
221
269
  this.headerConfig = this.normalizeHeaderConfig(data);
270
+ this.checkAccountReadiness();
222
271
  })
223
272
  .catch(() => {
224
273
  this.headerConfig = {};
@@ -238,6 +287,84 @@ export default {
238
287
  }
239
288
  return data?.key ? { [data.key]: data } : {};
240
289
  },
290
+ getConfigValue: function (key) {
291
+ const config = this.headerConfig?.[key];
292
+ return config?.val ?? config;
293
+ },
294
+ isAccountReadyEnabled: function () {
295
+ return String(this.getConfigValue("user_profile_ready") || "").trim() === "1";
296
+ },
297
+ getAccountReadyStorageKey: function () {
298
+ const uid = User.getInfo()?.uid || localStorage.getItem("uid") || "0";
299
+ return `${ACCOUNT_READY_STORAGE_PREFIX}:${uid}`;
300
+ },
301
+ isAccountReadyDismissed: function () {
302
+ try {
303
+ const key = this.getAccountReadyStorageKey();
304
+ const until = Number(localStorage.getItem(key));
305
+ if (!until) return false;
306
+ if (Date.now() < until) return true;
307
+ localStorage.removeItem(key);
308
+ } catch (e) {}
309
+ return false;
310
+ },
311
+ markAccountReadyDismissed: function () {
312
+ if (!User.isLogin() || !this.accountReadyIssues.length) return;
313
+ try {
314
+ localStorage.setItem(this.getAccountReadyStorageKey(), String(Date.now() + ACCOUNT_READY_DISMISS_INTERVAL));
315
+ } catch (e) {}
316
+ },
317
+ buildAccountReadyIssues: function (status = {}) {
318
+ const issues = [];
319
+ if (!status.has_phone && !status.has_verified_email) {
320
+ issues.push("contact");
321
+ }
322
+ if (!status.has_password) {
323
+ issues.push("password");
324
+ }
325
+ return issues;
326
+ },
327
+ isAccountReadyTargetPage: function () {
328
+ return ["/dashboard/notice", "/dashboard/pwd"].some((path) => location.pathname.startsWith(path));
329
+ },
330
+ checkAccountReadiness: function () {
331
+ if (this.accountReadyChecking || !User.isLogin()) return;
332
+ if (this.isAccountReadyTargetPage()) return;
333
+ if (!this.isAccountReadyEnabled()) return;
334
+
335
+ this.accountReadyChecking = true;
336
+ getMyAccountStatus()
337
+ .then((status) => {
338
+ const issues = this.buildAccountReadyIssues(status);
339
+ if (!issues.length) {
340
+ this.accountReadyIssues = [];
341
+ this.accountReadyDialogVisible = false;
342
+ this.clearAccountReadyDismissed();
343
+ return;
344
+ }
345
+ this.accountReadyIssues = issues;
346
+ if (!this.isAccountReadyDismissed()) {
347
+ this.accountReadyDialogVisible = true;
348
+ }
349
+ })
350
+ .catch(() => {})
351
+ .finally(() => {
352
+ this.accountReadyChecking = false;
353
+ });
354
+ },
355
+ clearAccountReadyDismissed: function () {
356
+ try {
357
+ localStorage.removeItem(this.getAccountReadyStorageKey());
358
+ } catch (e) {}
359
+ },
360
+ openAccountReadyDialog: function () {
361
+ if (!this.accountReadyIssues.length) return;
362
+ this.accountReadyDialogVisible = true;
363
+ },
364
+ goAccountReady: function (url) {
365
+ this.markAccountReadyDismissed();
366
+ window.open(url, "_blank", "noopener");
367
+ },
241
368
  updateScreen() {
242
369
  this.isMobile = window.innerWidth <= 768;
243
370
  },
@@ -245,6 +372,7 @@ export default {
245
372
  created: function () {
246
373
  this.init();
247
374
  window.addEventListener("resize", this.updateScreen, { passive: true });
375
+ document.addEventListener("visibilitychange", this.handleVisibilityChange);
248
376
 
249
377
  if (this.overlayEnable) {
250
378
  this.__overlayScrollHandler = _.throttle(() => {
@@ -256,6 +384,7 @@ export default {
256
384
  },
257
385
  beforeUnmount: function () {
258
386
  window.removeEventListener("resize", this.updateScreen);
387
+ document.removeEventListener("visibilitychange", this.handleVisibilityChange);
259
388
  if (this.__overlayScrollHandler) {
260
389
  window.removeEventListener("scroll", this.__overlayScrollHandler);
261
390
  this.__overlayScrollHandler.cancel && this.__overlayScrollHandler.cancel();
@@ -296,6 +425,62 @@ export default {
296
425
  .flex;
297
426
  }
298
427
  }
428
+
429
+ .c-header-account-ready {
430
+ border-radius: 10px;
431
+
432
+ .el-dialog__header {
433
+ margin-right: 0;
434
+ padding: 24px 24px 10px;
435
+ }
436
+
437
+ .el-dialog__title {
438
+ font-weight: 700;
439
+ color: #222;
440
+ }
441
+
442
+ .el-dialog__body {
443
+ padding: 8px 24px 4px;
444
+ }
445
+
446
+ .el-dialog__footer {
447
+ padding: 12px 24px 22px;
448
+ }
449
+
450
+ &__body {
451
+ .u-desc {
452
+ margin: 0 0 16px;
453
+ color: #666;
454
+ line-height: 1.7;
455
+ }
456
+
457
+ .u-list {
458
+ display: flex;
459
+ flex-direction: column;
460
+ gap: 12px;
461
+ }
462
+
463
+ .u-item {
464
+ padding: 14px 16px;
465
+ border: 1px solid #e8edf5;
466
+ border-radius: 8px;
467
+ background: #f8fbff;
468
+ }
469
+
470
+ .u-title {
471
+ margin-bottom: 6px;
472
+ font-size: 15px;
473
+ font-weight: 700;
474
+ color: #1f2937;
475
+ }
476
+
477
+ .u-text {
478
+ margin-bottom: 12px;
479
+ color: #5f6b7a;
480
+ line-height: 1.7;
481
+ }
482
+ }
483
+ }
299
484
  .c-header.isOverlay {
300
485
  background-color: rgba(0, 0, 0, 0.85);
301
486
  }
@@ -2,7 +2,7 @@
2
2
  <div class="c-header-panel c-header-manage" id="c-header-manage">
3
3
  <span class="u-post u-manage">
4
4
  <i class="u-icon u-icon-msg">
5
- <i class="u-pop" style="display: none" v-show="showPop || !isAuth"></i>
5
+ <i class="u-pop" style="display: none" v-show="showPanelPop"></i>
6
6
  <!-- <manageIcon class="u-add" /> -->
7
7
  <img
8
8
  class="u-add"
@@ -21,6 +21,16 @@
21
21
  <span v-if="showPop" class="u-new">New!</span>
22
22
  <span v-if="item.remark == 'auth' && !isAuth" class="u-new">New!</span>
23
23
  </a>
24
+ <a
25
+ v-if="item.remark === 'feature' && hasAccountReadyIssue"
26
+ href="javascript:;"
27
+ class="u-menu-item u-menu-item--account-ready"
28
+ @click.prevent="openAccountReady"
29
+ >
30
+ <img :src="resolveImg(item.icon)" class="u-menu-icon" :alt="item.icon" />
31
+ 资料完善
32
+ <span class="u-new u-new--important">重要</span>
33
+ </a>
24
34
  </li>
25
35
  </template>
26
36
  <template v-if="isTeammate">
@@ -86,7 +96,12 @@ export default {
86
96
  type: Boolean,
87
97
  default: false,
88
98
  },
99
+ accountReadyIssues: {
100
+ type: Array,
101
+ default: () => [],
102
+ },
89
103
  },
104
+ emits: ["open-account-ready"],
90
105
  computed: {
91
106
  userPanel: function () {
92
107
  return this.panel.filter((item) => {
@@ -101,6 +116,12 @@ export default {
101
116
  isAuth() {
102
117
  return User.isPhoneMember();
103
118
  },
119
+ hasAccountReadyIssue() {
120
+ return this.accountReadyIssues.length > 0;
121
+ },
122
+ showPanelPop() {
123
+ return this.showPop || this.hasAccountReadyIssue || !this.isAuth;
124
+ },
104
125
  },
105
126
  mounted() {
106
127
  if (this.headerConfigLoaded) {
@@ -172,6 +193,9 @@ export default {
172
193
  this.showPop = false;
173
194
  }
174
195
  },
196
+ openAccountReady() {
197
+ this.$emit("open-account-ready");
198
+ },
175
199
  },
176
200
  };
177
201
  </script>
@@ -240,5 +264,13 @@ export default {
240
264
  color: #fff;
241
265
  padding: 0px 6px;
242
266
  }
267
+
268
+ .u-new--important {
269
+ background-color: #f56c6c;
270
+ }
271
+
272
+ .u-menu-item--account-ready {
273
+ color: #f56c6c;
274
+ }
243
275
  }
244
276
  </style>
@@ -23,6 +23,8 @@
23
23
  :important-notice-url="headerConfig.important_notice_url"
24
24
  :header-config-loaded="headerConfigLoaded"
25
25
  :header-config-managed="headerConfigManaged"
26
+ :account-ready-issues="accountReadyIssues"
27
+ @open-account-ready="$emit('open-account-ready')"
26
28
  />
27
29
 
28
30
  <!-- 语言切换 -->
@@ -95,7 +97,12 @@ export default {
95
97
  type: Boolean,
96
98
  default: false,
97
99
  },
100
+ accountReadyIssues: {
101
+ type: Array,
102
+ default: () => [],
103
+ },
98
104
  },
105
+ emits: ["open-account-ready"],
99
106
  components: {
100
107
  message,
101
108
  publish,
@@ -37,3 +37,37 @@ export const Overlay = {
37
37
  },
38
38
  render: Default.render,
39
39
  };
40
+
41
+ export const AccountReadyMock = {
42
+ args: {
43
+ overlayEnable: false,
44
+ },
45
+ render: (args) => ({
46
+ components: { CommonHeader },
47
+ setup() {
48
+ const now = Date.now();
49
+ localStorage.setItem('logged_in', 'true');
50
+ localStorage.setItem('created_at', String(now));
51
+ localStorage.setItem('token', 'storybook-token');
52
+ localStorage.setItem('uid', '8');
53
+ localStorage.setItem('group', '1');
54
+ localStorage.setItem('name', 'Storybook用户');
55
+ localStorage.setItem('status', '0');
56
+ localStorage.setItem('token_version', 'storybook');
57
+ localStorage.setItem('avatar', 'https://cdn.jx3box.com/upload/avatar/2022/3/2/8_9860765.png');
58
+ localStorage.removeItem('jx3box:account-ready-dismissed-until:8');
59
+ sessionStorage.removeItem('panel');
60
+
61
+ return { args };
62
+ },
63
+ template: `
64
+ <div style="min-height: 100vh; background: #f5f7fb;">
65
+ <CommonHeader v-bind="args" />
66
+ <main style="padding: 96px 32px;">
67
+ <h1 style="margin: 0 0 12px; font-size: 28px;">公共头账号安全提醒 mock</h1>
68
+ <p style="margin: 0; color: #666;">当前 mock 用户未绑定邮箱/手机,也未设置密码,公共头会自动弹出提醒。</p>
69
+ </main>
70
+ </div>
71
+ `,
72
+ }),
73
+ };
@@ -0,0 +1,152 @@
1
+ import User from "@jx3box/jx3box-common/js/user";
2
+ import { refreshAuth } from "../../service/cms";
3
+
4
+ const REFRESH_WINDOW_SECONDS = 7 * 24 * 60 * 60;
5
+ const MIN_REFRESH_INTERVAL_SECONDS = 24 * 60 * 60;
6
+ const AUTO_REFRESH_STORAGE_KEY = "jx3box:auto_refresh_token";
7
+
8
+ let refreshTask = null;
9
+
10
+ function canUseLocalStorage() {
11
+ return typeof localStorage !== "undefined";
12
+ }
13
+
14
+ function decodeBase64Url(value = "") {
15
+ const normalized = String(value).replace(/-/g, "+").replace(/_/g, "/");
16
+ const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), "=");
17
+
18
+ if (typeof atob === "function") {
19
+ return atob(padded);
20
+ }
21
+
22
+ return "";
23
+ }
24
+
25
+ export function parseJwtPayload(token = "") {
26
+ const parts = String(token || "").split(".");
27
+ if (parts.length < 2 || !parts[1]) {
28
+ return null;
29
+ }
30
+
31
+ try {
32
+ return JSON.parse(decodeBase64Url(parts[1]));
33
+ } catch (e) {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export function isAutoTokenRefreshEnabled() {
39
+ if (!canUseLocalStorage()) {
40
+ return true;
41
+ }
42
+
43
+ const value = localStorage.getItem(AUTO_REFRESH_STORAGE_KEY);
44
+ return value !== "false" && value !== "0";
45
+ }
46
+
47
+ export function shouldRefreshToken(token = "", nowSeconds = Math.floor(Date.now() / 1000)) {
48
+ const payload = parseJwtPayload(token);
49
+ const exp = Number(payload?.exp || 0);
50
+ const iat = Number(payload?.iat || 0);
51
+
52
+ if (!exp || !iat) {
53
+ return false;
54
+ }
55
+
56
+ const remaining = exp - nowSeconds;
57
+ const tokenAge = nowSeconds - iat;
58
+
59
+ return remaining > 0 && remaining <= REFRESH_WINDOW_SECONDS && tokenAge >= MIN_REFRESH_INTERVAL_SECONDS;
60
+ }
61
+
62
+ function readCurrentToken() {
63
+ if (!canUseLocalStorage()) {
64
+ return User.getToken() || "";
65
+ }
66
+
67
+ return localStorage.getItem("__token") || localStorage.getItem("token") || User.getToken() || "";
68
+ }
69
+
70
+ function normalizeRefreshProfile(data = {}) {
71
+ const user = data.user || {};
72
+ const current = User.getInfo() || {};
73
+
74
+ return {
75
+ token: data.token || current.token || "",
76
+ uid: user.ID || user.id || data.uid || current.uid || 0,
77
+ group: user.user_group || user.group || data.group || current.group || 1,
78
+ name: user.display_name || user.name || data.name || current.name || "",
79
+ status: user.user_status || user.status || data.status || current.status || 0,
80
+ bind_wx: user.wechat_unionid ? 1 : data.bind_wx || current.bind_wx || 0,
81
+ avatar: user.user_avatar || user.avatar || data.avatar || current.avatar_origin || current.avatar || "",
82
+ };
83
+ }
84
+
85
+ function updateCurrentAlternate(profile = {}) {
86
+ if (!canUseLocalStorage() || !profile.uid || !profile.token) {
87
+ return;
88
+ }
89
+
90
+ const key = `jx3box-alternate-${profile.uid}`;
91
+ let alternate = {};
92
+
93
+ try {
94
+ alternate = JSON.parse(localStorage.getItem(key) || "{}") || {};
95
+ } catch (e) {
96
+ alternate = {};
97
+ }
98
+
99
+ localStorage.setItem(
100
+ key,
101
+ JSON.stringify({
102
+ ...alternate,
103
+ uid: profile.uid,
104
+ name: profile.name,
105
+ avatar: profile.avatar,
106
+ group: ~~profile.group,
107
+ bind_wx: ~~profile.bind_wx,
108
+ token: profile.token,
109
+ status: ~~profile.status,
110
+ created_at: Number(localStorage.getItem("created_at")) || Date.now(),
111
+ })
112
+ );
113
+ }
114
+
115
+ async function applyRefreshResult(data = {}) {
116
+ const profile = normalizeRefreshProfile(data);
117
+ if (!profile.token || !profile.uid) {
118
+ return false;
119
+ }
120
+
121
+ await User.update(profile);
122
+
123
+ // @jx3box/common User.update only writes token; keep __token in sync because common API reads it first.
124
+ localStorage.setItem("__token", profile.token);
125
+ updateCurrentAlternate(profile);
126
+ return true;
127
+ }
128
+
129
+ export async function refreshTokenIfNeeded(options = {}) {
130
+ const { force = false } = options;
131
+
132
+ if (!User.isLogin() || !isAutoTokenRefreshEnabled()) {
133
+ return false;
134
+ }
135
+
136
+ const token = readCurrentToken();
137
+ if (!token || (!force && !shouldRefreshToken(token))) {
138
+ return false;
139
+ }
140
+
141
+ if (refreshTask) {
142
+ return refreshTask;
143
+ }
144
+
145
+ refreshTask = refreshAuth()
146
+ .then((res) => applyRefreshResult(res?.data?.data || {}))
147
+ .finally(() => {
148
+ refreshTask = null;
149
+ });
150
+
151
+ return refreshTask;
152
+ }
@@ -0,0 +1,52 @@
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 cmsService = fs.readFileSync(path.join(root, "service/cms.js"), "utf8");
7
+ const refreshUtil = fs.readFileSync(path.join(root, "src/utils/auth-token-refresh.js"), "utf8");
8
+ const docs = fs.readFileSync(path.join(root, "docs/agents/auth-token-refresh.md"), "utf8");
9
+
10
+ function assert(condition, message) {
11
+ if (!condition) {
12
+ throw new Error(message);
13
+ }
14
+ }
15
+
16
+ assert(
17
+ cmsService.includes("/api/cms/user/account/token/refresh"),
18
+ "refreshAuth should use the token refresh endpoint"
19
+ );
20
+
21
+ assert(commonHeader.includes("refreshTokenIfNeeded"), "CommonHeader should trigger token refresh");
22
+
23
+ assert(
24
+ commonHeader.includes("visibilitychange") && commonHeader.includes("handleVisibilityChange"),
25
+ "CommonHeader should check refresh on visibility restore"
26
+ );
27
+
28
+ assert(refreshUtil.includes("REFRESH_WINDOW_SECONDS = 7 * 24 * 60 * 60"), "refresh window should be 7 days");
29
+
30
+ assert(
31
+ refreshUtil.includes("MIN_REFRESH_INTERVAL_SECONDS = 24 * 60 * 60"),
32
+ "minimum refresh interval should be 1 day"
33
+ );
34
+
35
+ assert(
36
+ refreshUtil.includes('localStorage.getItem("__token") || localStorage.getItem("token")'),
37
+ "refresh should read __token before token"
38
+ );
39
+
40
+ assert(
41
+ refreshUtil.includes("localStorage.setItem(\"__token\", profile.token)"),
42
+ "refresh should keep __token in sync"
43
+ );
44
+
45
+ assert(
46
+ refreshUtil.includes("jx3box-alternate-${profile.uid}"),
47
+ "refresh should update the current alternate account cache"
48
+ );
49
+
50
+ assert(docs.includes("PC 账号切换注意事项"), "docs should record PC account switching constraints");
51
+
52
+ console.log("auth token refresh checks passed");