@jx3box/jx3box-ui 2.3.10 → 2.3.12

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.12",
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,41 @@
26
26
  :header-config="headerConfig"
27
27
  :header-config-loaded="headerConfigLoaded"
28
28
  :header-config-managed="true"
29
+ :account-ready-issues="accountReadyIssues"
30
+ :account-ready-incomplete="accountReadyIncomplete"
31
+ @open-account-ready="openAccountReadyDialog"
29
32
  />
30
33
  </div>
31
34
  <header-box v-if="isMobile" class="c-header__box c-header-jx3box" :overlayEnable="overlayEnable" />
32
35
  <header-box2 v-else class="c-header__box c-header__box--desktop" />
33
36
  </header>
37
+ <el-dialog
38
+ v-model="accountReadyDialogVisible"
39
+ class="c-header-account-ready"
40
+ width="min(92vw, 520px)"
41
+ title="⚠️ 完善账号安全信息"
42
+ append-to-body
43
+ @close="markAccountReadyDismissed"
44
+ >
45
+ <div class="c-header-account-ready__body">
46
+ <p class="u-desc">为了保障 App 登录、账号找回和卡密等功能正常使用,请先补全以下账号安全信息。</p>
47
+ <div class="u-list">
48
+ <div class="u-item" v-if="accountReadyIssues.includes('contact')">
49
+ <div class="u-title">绑定邮箱和手机号</div>
50
+ <div class="u-text">当前账号的邮箱或手机号尚未完善。请完成手机号绑定,并绑定且验证邮箱,否则后续可能无法找回账号。</div>
51
+ <el-button type="primary" @click="goAccountReady('/dashboard/notice')">去绑定</el-button>
52
+ </div>
53
+ <div class="u-item" v-if="accountReadyIssues.includes('password')">
54
+ <div class="u-title">设置登录密码</div>
55
+ <div class="u-text">当前账号还没有设置密码。未设置密码时,可能无法使用卡密等功能,也无法在 App 中通过账号密码登录。</div>
56
+ <el-button type="primary" @click="goAccountReady('/dashboard/pwd')">去设置</el-button>
57
+ </div>
58
+ </div>
59
+ </div>
60
+ <template #footer>
61
+ <el-button @click="accountReadyDialogVisible = false">7天后再说</el-button>
62
+ </template>
63
+ </el-dialog>
34
64
  </template>
35
65
 
36
66
  <script>
@@ -53,11 +83,15 @@ import miniprogram from "@jx3box/jx3box-common/data/miniprogram.json";
53
83
 
54
84
  // 数据
55
85
  import { getGlobalConfig } from "../service/header";
56
- import { getConfig } from "../service/cms";
86
+ import { getConfig, getMyAccountStatus } from "../service/cms";
87
+ import { refreshTokenIfNeeded } from "./utils/auth-token-refresh";
57
88
  import User from "@jx3box/jx3box-common/js/user.js";
58
89
  import JX3BOX from "@jx3box/jx3box-common/data/jx3box.json";
59
90
 
60
- const HEADER_CONFIG_KEYS = ["important_notice", "important_notice_url", "vip", "mall"];
91
+ const HEADER_CONFIG_KEYS = ["important_notice", "important_notice_url", "vip", "mall", "user_profile_ready"];
92
+ const ACCOUNT_READY_STORAGE_PREFIX = "jx3box:account-ready-dismissed-until";
93
+ const ACCOUNT_READY_COMPLETE_STORAGE_PREFIX = "jx3box:account-ready-complete";
94
+ const ACCOUNT_READY_DISMISS_INTERVAL = 7 * 24 * 60 * 60 * 1000;
61
95
 
62
96
  export default {
63
97
  name: "Header",
@@ -80,6 +114,10 @@ export default {
80
114
  asset: {},
81
115
  headerConfig: {},
82
116
  headerConfigLoaded: false,
117
+ accountReadyDialogVisible: false,
118
+ accountReadyIssues: [],
119
+ accountReadyIncomplete: false,
120
+ accountReadyChecking: false,
83
121
  };
84
122
  },
85
123
  computed: {
@@ -156,6 +194,8 @@ export default {
156
194
 
157
195
  if (User.isLogin()) {
158
196
  this.loadAsset();
197
+ this.refreshAuthToken();
198
+ this.initAccountReadyState();
159
199
  }
160
200
 
161
201
  // 获取全局配置
@@ -196,6 +236,18 @@ export default {
196
236
  });
197
237
  },
198
238
 
239
+ refreshAuthToken: function () {
240
+ refreshTokenIfNeeded().catch(() => {
241
+ // 自动续期失败不影响公共头渲染,后续鉴权请求会按既有逻辑处理登录态。
242
+ });
243
+ },
244
+
245
+ handleVisibilityChange: function () {
246
+ if (document.visibilityState === "visible") {
247
+ this.refreshAuthToken();
248
+ }
249
+ },
250
+
199
251
  getUrlParam(name) {
200
252
  var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
201
253
  var r = window.location.search.substr(1).match(reg);
@@ -225,6 +277,7 @@ export default {
225
277
  })
226
278
  .finally(() => {
227
279
  this.headerConfigLoaded = true;
280
+ this.checkAccountReadiness();
228
281
  });
229
282
  },
230
283
  normalizeHeaderConfig: function (data) {
@@ -238,6 +291,138 @@ export default {
238
291
  }
239
292
  return data?.key ? { [data.key]: data } : {};
240
293
  },
294
+ getConfigValue: function (key) {
295
+ const config = this.headerConfig?.[key];
296
+ return config?.val ?? config;
297
+ },
298
+ isAccountReadyEnabled: function () {
299
+ return String(this.getConfigValue("user_profile_ready") || "").trim() === "1";
300
+ },
301
+ getAccountReadyUserId: function () {
302
+ const uid = User.getInfo()?.uid || localStorage.getItem("uid") || "0";
303
+ return uid || "0";
304
+ },
305
+ getAccountReadyStorageKey: function () {
306
+ return `${ACCOUNT_READY_STORAGE_PREFIX}:${this.getAccountReadyUserId()}`;
307
+ },
308
+ getAccountReadyCompleteStorageKey: function () {
309
+ return `${ACCOUNT_READY_COMPLETE_STORAGE_PREFIX}:${this.getAccountReadyUserId()}`;
310
+ },
311
+ isAccountReadyCompleted: function () {
312
+ try {
313
+ return !!localStorage.getItem(this.getAccountReadyCompleteStorageKey());
314
+ } catch (e) {
315
+ return false;
316
+ }
317
+ },
318
+ markAccountReadyCompleted: function () {
319
+ try {
320
+ localStorage.setItem(this.getAccountReadyCompleteStorageKey(), String(Date.now()));
321
+ } catch (e) {}
322
+ this.clearAccountReadyDismissed();
323
+ },
324
+ initAccountReadyState: function () {
325
+ this.accountReadyIncomplete = !this.isAccountReadyCompleted();
326
+ },
327
+ isAccountReadyDismissed: function () {
328
+ try {
329
+ const key = this.getAccountReadyStorageKey();
330
+ const until = Number(localStorage.getItem(key));
331
+ if (!until) return false;
332
+ if (Date.now() < until) return true;
333
+ localStorage.removeItem(key);
334
+ } catch (e) {}
335
+ return false;
336
+ },
337
+ markAccountReadyDismissed: function () {
338
+ if (!User.isLogin() || !this.accountReadyIssues.length) return;
339
+ try {
340
+ localStorage.setItem(this.getAccountReadyStorageKey(), String(Date.now() + ACCOUNT_READY_DISMISS_INTERVAL));
341
+ } catch (e) {}
342
+ },
343
+ buildAccountReadyIssues: function (status = {}) {
344
+ const issues = [];
345
+ if (!status.has_phone || !status.has_verified_email) {
346
+ issues.push("contact");
347
+ }
348
+ if (!status.has_password) {
349
+ issues.push("password");
350
+ }
351
+ return issues;
352
+ },
353
+ isAccountReadyTargetPage: function () {
354
+ return ["/dashboard/notice", "/dashboard/pwd"].some((path) => location.pathname.startsWith(path));
355
+ },
356
+ checkAccountReadiness: function () {
357
+ if (this.accountReadyChecking || !User.isLogin()) return;
358
+ if (this.isAccountReadyCompleted()) {
359
+ this.accountReadyIssues = [];
360
+ this.accountReadyIncomplete = false;
361
+ this.accountReadyDialogVisible = false;
362
+ return;
363
+ }
364
+
365
+ this.accountReadyChecking = true;
366
+ getMyAccountStatus()
367
+ .then((status) => {
368
+ const issues = this.buildAccountReadyIssues(status);
369
+ if (!issues.length) {
370
+ this.accountReadyIssues = [];
371
+ this.accountReadyIncomplete = false;
372
+ this.accountReadyDialogVisible = false;
373
+ this.markAccountReadyCompleted();
374
+ return;
375
+ }
376
+ this.accountReadyIssues = issues;
377
+ this.accountReadyIncomplete = true;
378
+ if (this.isAccountReadyEnabled() && !this.isAccountReadyTargetPage() && !this.isAccountReadyDismissed()) {
379
+ this.accountReadyDialogVisible = true;
380
+ }
381
+ })
382
+ .catch(() => {})
383
+ .finally(() => {
384
+ this.accountReadyChecking = false;
385
+ });
386
+ },
387
+ clearAccountReadyDismissed: function () {
388
+ try {
389
+ localStorage.removeItem(this.getAccountReadyStorageKey());
390
+ } catch (e) {}
391
+ },
392
+ openAccountReadyDialog: function (issues) {
393
+ if (Array.isArray(issues) && issues.length) {
394
+ this.accountReadyIssues = issues;
395
+ }
396
+ if (this.accountReadyIssues.length) {
397
+ this.accountReadyDialogVisible = true;
398
+ return;
399
+ }
400
+ if (!User.isLogin() || this.isAccountReadyCompleted()) return;
401
+
402
+ this.accountReadyChecking = true;
403
+ getMyAccountStatus()
404
+ .then((status) => {
405
+ const freshIssues = this.buildAccountReadyIssues(status);
406
+ if (!freshIssues.length) {
407
+ this.accountReadyIssues = [];
408
+ this.accountReadyIncomplete = false;
409
+ this.accountReadyDialogVisible = false;
410
+ this.markAccountReadyCompleted();
411
+ return;
412
+ }
413
+ this.accountReadyIssues = freshIssues;
414
+ this.accountReadyIncomplete = true;
415
+ this.accountReadyDialogVisible = true;
416
+ })
417
+ .catch(() => {})
418
+ .finally(() => {
419
+ this.accountReadyChecking = false;
420
+ });
421
+ },
422
+ goAccountReady: function (url) {
423
+ this.markAccountReadyDismissed();
424
+ window.open(url, "_blank", "noopener");
425
+ },
241
426
  updateScreen() {
242
427
  this.isMobile = window.innerWidth <= 768;
243
428
  },
@@ -245,6 +430,7 @@ export default {
245
430
  created: function () {
246
431
  this.init();
247
432
  window.addEventListener("resize", this.updateScreen, { passive: true });
433
+ document.addEventListener("visibilitychange", this.handleVisibilityChange);
248
434
 
249
435
  if (this.overlayEnable) {
250
436
  this.__overlayScrollHandler = _.throttle(() => {
@@ -256,6 +442,7 @@ export default {
256
442
  },
257
443
  beforeUnmount: function () {
258
444
  window.removeEventListener("resize", this.updateScreen);
445
+ document.removeEventListener("visibilitychange", this.handleVisibilityChange);
259
446
  if (this.__overlayScrollHandler) {
260
447
  window.removeEventListener("scroll", this.__overlayScrollHandler);
261
448
  this.__overlayScrollHandler.cancel && this.__overlayScrollHandler.cancel();
@@ -296,6 +483,62 @@ export default {
296
483
  .flex;
297
484
  }
298
485
  }
486
+
487
+ .c-header-account-ready {
488
+ border-radius: 10px;
489
+
490
+ .el-dialog__header {
491
+ margin-right: 0;
492
+ padding: 24px 24px 10px;
493
+ }
494
+
495
+ .el-dialog__title {
496
+ font-weight: 700;
497
+ color: #222;
498
+ }
499
+
500
+ .el-dialog__body {
501
+ padding: 8px 24px 4px;
502
+ }
503
+
504
+ .el-dialog__footer {
505
+ padding: 12px 24px 22px;
506
+ }
507
+
508
+ &__body {
509
+ .u-desc {
510
+ margin: 0 0 16px;
511
+ color: #666;
512
+ line-height: 1.7;
513
+ }
514
+
515
+ .u-list {
516
+ display: flex;
517
+ flex-direction: column;
518
+ gap: 12px;
519
+ }
520
+
521
+ .u-item {
522
+ padding: 14px 16px;
523
+ border: 1px solid #e8edf5;
524
+ border-radius: 8px;
525
+ background: #f8fbff;
526
+ }
527
+
528
+ .u-title {
529
+ margin-bottom: 6px;
530
+ font-size: 15px;
531
+ font-weight: 700;
532
+ color: #1f2937;
533
+ }
534
+
535
+ .u-text {
536
+ margin-bottom: 12px;
537
+ color: #5f6b7a;
538
+ line-height: 1.7;
539
+ }
540
+ }
541
+ }
299
542
  .c-header.isOverlay {
300
543
  background-color: rgba(0, 0, 0, 0.85);
301
544
  }
@@ -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="isFeaturePanelItem(item) && 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,16 @@ export default {
86
96
  type: Boolean,
87
97
  default: false,
88
98
  },
99
+ accountReadyIssues: {
100
+ type: Array,
101
+ default: () => [],
102
+ },
103
+ accountReadyIncomplete: {
104
+ type: Boolean,
105
+ default: false,
106
+ },
89
107
  },
108
+ emits: ["open-account-ready"],
90
109
  computed: {
91
110
  userPanel: function () {
92
111
  return this.panel.filter((item) => {
@@ -101,6 +120,12 @@ export default {
101
120
  isAuth() {
102
121
  return User.isPhoneMember();
103
122
  },
123
+ hasAccountReadyIssue() {
124
+ return this.accountReadyIncomplete;
125
+ },
126
+ showPanelPop() {
127
+ return this.showPop || this.hasAccountReadyIssue;
128
+ },
104
129
  },
105
130
  mounted() {
106
131
  if (this.headerConfigLoaded) {
@@ -119,6 +144,9 @@ export default {
119
144
  if (item?.key) return this.$jx3boxT(`jx3boxUi.commonHeader.panel.${item.key}`, item.label || item.key);
120
145
  return item?.label || "";
121
146
  },
147
+ isFeaturePanelItem(item = {}) {
148
+ return item.remark === "feature" || !!item.meta || item.key === "featureUpdate" || item.label === "功能更新";
149
+ },
122
150
  loadPanel: async function () {
123
151
  try {
124
152
  const panel = JSON.parse(sessionStorage.getItem("panel"));
@@ -172,6 +200,9 @@ export default {
172
200
  this.showPop = false;
173
201
  }
174
202
  },
203
+ openAccountReady() {
204
+ this.$emit("open-account-ready", this.accountReadyIssues);
205
+ },
175
206
  },
176
207
  };
177
208
  </script>
@@ -240,5 +271,13 @@ export default {
240
271
  color: #fff;
241
272
  padding: 0px 6px;
242
273
  }
274
+
275
+ .u-new--important {
276
+ background-color: #f56c6c;
277
+ }
278
+
279
+ .u-menu-item--account-ready {
280
+ color: #f56c6c;
281
+ }
243
282
  }
244
283
  </style>
@@ -23,6 +23,9 @@
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
+ :account-ready-incomplete="accountReadyIncomplete"
28
+ @open-account-ready="$emit('open-account-ready', $event)"
26
29
  />
27
30
 
28
31
  <!-- 语言切换 -->
@@ -95,7 +98,16 @@ export default {
95
98
  type: Boolean,
96
99
  default: false,
97
100
  },
101
+ accountReadyIssues: {
102
+ type: Array,
103
+ default: () => [],
104
+ },
105
+ accountReadyIncomplete: {
106
+ type: Boolean,
107
+ default: false,
108
+ },
98
109
  },
110
+ emits: ["open-account-ready"],
99
111
  components: {
100
112
  message,
101
113
  publish,
@@ -37,3 +37,38 @@ 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
+ localStorage.removeItem('jx3box:account-ready-complete:8');
60
+ sessionStorage.removeItem('panel');
61
+
62
+ return { args };
63
+ },
64
+ template: `
65
+ <div style="min-height: 100vh; background: #f5f7fb;">
66
+ <CommonHeader v-bind="args" />
67
+ <main style="padding: 96px 32px;">
68
+ <h1 style="margin: 0 0 12px; font-size: 28px;">公共头账号安全提醒 mock</h1>
69
+ <p style="margin: 0; color: #666;">当前 mock 用户未绑定邮箱/手机,也未设置密码,公共头会自动弹出提醒。</p>
70
+ </main>
71
+ </div>
72
+ `,
73
+ }),
74
+ };
@@ -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");