@jx3box/jx3box-ui 2.3.9 → 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.
- package/.storybook/storybookMocks.js +101 -0
- package/docs/agents/auth-token-refresh.md +56 -0
- package/docs/agents/global-config-cdn.md +30 -0
- package/package.json +1 -1
- package/service/cms.js +60 -3
- package/service/header.js +63 -3
- package/service/thx.js +8 -5
- package/src/CommonHeader.vue +187 -2
- package/src/header/manage.vue +33 -1
- package/src/header/user.vue +7 -0
- package/src/stories/layout/CommonHeader.stories.js +34 -0
- package/src/utils/auth-token-refresh.js +152 -0
- package/tests/auth-token-refresh.test.js +52 -0
|
@@ -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 过期或无权限,仍按宿主项目既有鉴权失败逻辑处理。
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# 全局配置 CDN 读取约定
|
|
2
|
+
|
|
3
|
+
## 背景
|
|
4
|
+
|
|
5
|
+
`/api/cms/config` 中有一批低频更新的全局配置,后端会构建到 CDN JSON:
|
|
6
|
+
|
|
7
|
+
```txt
|
|
8
|
+
https://cdn.jx3box.com/config/global.json
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
这类配置在公共组件库和其它魔盒前端项目中应优先读取 CDN 静态 JSON,减少页面内多组件重复请求 config 接口。
|
|
12
|
+
|
|
13
|
+
## 实现原则
|
|
14
|
+
|
|
15
|
+
- 在项目服务层封装统一 helper,不要让具体组件直接请求 CDN。
|
|
16
|
+
- helper 优先读取内存缓存,再读 `sessionStorage`,没有缓存时请求 `global.json`。
|
|
17
|
+
- 同一页面生命周期内要复用 pending promise,避免多个组件并发触发多次请求。
|
|
18
|
+
- 对旧调用保持返回结构兼容,比如继续返回 `{ key, val }` 或数组,减少组件改动面。
|
|
19
|
+
- CDN 请求失败、缺少目标 key、或查询不适合静态 JSON 时,fallback 到旧 `/api/cms/config`。
|
|
20
|
+
- 公共头可以预热全局配置,但其它组件不能隐式依赖公共头加载顺序。
|
|
21
|
+
|
|
22
|
+
## 本仓落点
|
|
23
|
+
|
|
24
|
+
- `service/header.js`:`getGlobalConfig()` 负责读取 CDN JSON,并做内存缓存、`sessionStorage` 缓存、TTL 和 pending promise 复用。
|
|
25
|
+
- `service/cms.js`:`getConfig()` 优先从 `getGlobalConfig()` 合成旧接口兼容结构,失败时回退旧接口。
|
|
26
|
+
- `service/thx.js`:`getBoxcoinStatus()` 走统一 `getConfig({ key: "boxcoin" })`。
|
|
27
|
+
|
|
28
|
+
## 适用范围
|
|
29
|
+
|
|
30
|
+
后续其它魔盒项目如果提到“全局配置读取换 CDN”或“config 改成读 `global.json`”,默认按本约定处理:优先服务层兼容改造,组件侧尽量少动,迁移期保留旧接口 fallback。
|
package/package.json
CHANGED
package/service/cms.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { $cms } from "@jx3box/jx3box-common/js/api";
|
|
2
2
|
import axios from "axios";
|
|
3
3
|
import JX3BOX from "@jx3box/jx3box-common/data/jx3box.json";
|
|
4
|
+
import { getGlobalConfig } from "./header";
|
|
5
|
+
|
|
6
|
+
const APP_CONFIG_KEYS = ["android_apk", "android_versions", "app_logo", "app_version_desc", "apple_url", "harmony_url"];
|
|
7
|
+
|
|
4
8
|
function getPostAuthors(post_id) {
|
|
5
9
|
return $cms({ mute: true }).get(`/api/cms/post/${post_id}/authors`);
|
|
6
10
|
}
|
|
@@ -80,8 +84,7 @@ function getTopicBucket(params) {
|
|
|
80
84
|
return $cms().get(`/api/cms/topic/bucket`, { params });
|
|
81
85
|
}
|
|
82
86
|
|
|
83
|
-
|
|
84
|
-
function getConfig(params) {
|
|
87
|
+
function getLegacyConfig(params) {
|
|
85
88
|
return $cms()
|
|
86
89
|
.get(`/api/cms/config`, { params })
|
|
87
90
|
.then((res) => {
|
|
@@ -89,6 +92,51 @@ function getConfig(params) {
|
|
|
89
92
|
});
|
|
90
93
|
}
|
|
91
94
|
|
|
95
|
+
function normalizeConfigItem(key, value, subtype = "") {
|
|
96
|
+
return {
|
|
97
|
+
key,
|
|
98
|
+
val: value,
|
|
99
|
+
subtype,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getConfigFromGlobal(params = {}) {
|
|
104
|
+
return getGlobalConfig().then((config) => {
|
|
105
|
+
if (!config || typeof config !== "object") return null;
|
|
106
|
+
|
|
107
|
+
if (params.key) {
|
|
108
|
+
const keys = String(params.key)
|
|
109
|
+
.split(",")
|
|
110
|
+
.map((key) => key.trim())
|
|
111
|
+
.filter(Boolean);
|
|
112
|
+
|
|
113
|
+
if (!keys.length) return null;
|
|
114
|
+
if (!keys.every((key) => Object.prototype.hasOwnProperty.call(config, key))) return null;
|
|
115
|
+
|
|
116
|
+
const items = keys.map((key) => normalizeConfigItem(key, config[key]));
|
|
117
|
+
return keys.length === 1 ? items[0] : items;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (params.subtype === "app") {
|
|
121
|
+
if (!APP_CONFIG_KEYS.every((key) => Object.prototype.hasOwnProperty.call(config, key))) return null;
|
|
122
|
+
return APP_CONFIG_KEYS.map((key) => normalizeConfigItem(key, config[key], "app"));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return null;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 获取config
|
|
130
|
+
function getConfig(params = {}) {
|
|
131
|
+
return getConfigFromGlobal(params)
|
|
132
|
+
.then((data) => {
|
|
133
|
+
return data || getLegacyConfig(params);
|
|
134
|
+
})
|
|
135
|
+
.catch(() => {
|
|
136
|
+
return getLegacyConfig(params);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
92
140
|
// 获取用户config
|
|
93
141
|
function getUserConfig(params) {
|
|
94
142
|
return $cms()
|
|
@@ -119,7 +167,7 @@ function setUserMeta(key, data) {
|
|
|
119
167
|
|
|
120
168
|
// 刷新凭证
|
|
121
169
|
function refreshAuth() {
|
|
122
|
-
return $cms().post(`/api/cms/user/account/
|
|
170
|
+
return $cms().post(`/api/cms/user/account/token/refresh`);
|
|
123
171
|
}
|
|
124
172
|
|
|
125
173
|
function getUserHonor(uid) {
|
|
@@ -154,6 +202,14 @@ function getUserPermission() {
|
|
|
154
202
|
return $cms().get(`/api/cms/account/permission/i`);
|
|
155
203
|
}
|
|
156
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
|
+
|
|
157
213
|
export {
|
|
158
214
|
getPost,
|
|
159
215
|
getPostAuthors,
|
|
@@ -179,6 +235,7 @@ export {
|
|
|
179
235
|
uploadFile,
|
|
180
236
|
refreshQQBotImage,
|
|
181
237
|
getUserPermission,
|
|
238
|
+
getMyAccountStatus,
|
|
182
239
|
getUserConfig,
|
|
183
240
|
setUserConfig,
|
|
184
241
|
};
|
package/service/header.js
CHANGED
|
@@ -2,6 +2,47 @@ import axios from "axios";
|
|
|
2
2
|
import { $cms, $next } from "@jx3box/jx3box-common/js/api";
|
|
3
3
|
import JX3BOX from "@jx3box/jx3box-common/data/jx3box.json";
|
|
4
4
|
|
|
5
|
+
const GLOBAL_CONFIG_STORAGE_KEY = "jx3box:global-config";
|
|
6
|
+
const GLOBAL_CONFIG_TTL = 6 * 60 * 60 * 1000;
|
|
7
|
+
let globalConfigCache = null;
|
|
8
|
+
let globalConfigPending = null;
|
|
9
|
+
|
|
10
|
+
function getGlobalConfigUrl() {
|
|
11
|
+
const root = JX3BOX.__ossRoot || "https://cdn.jx3box.com/";
|
|
12
|
+
return `${root.replace(/\/?$/, "/")}config/global.json`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getNow() {
|
|
16
|
+
return Date.now ? Date.now() : new Date().getTime();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readGlobalConfigStorage() {
|
|
20
|
+
if (typeof sessionStorage === "undefined") return null;
|
|
21
|
+
try {
|
|
22
|
+
const raw = sessionStorage.getItem(GLOBAL_CONFIG_STORAGE_KEY);
|
|
23
|
+
if (!raw) return null;
|
|
24
|
+
const cache = JSON.parse(raw);
|
|
25
|
+
if (!cache?.data || !cache?.time) return null;
|
|
26
|
+
if (getNow() - cache.time > GLOBAL_CONFIG_TTL) return null;
|
|
27
|
+
return cache.data;
|
|
28
|
+
} catch (e) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writeGlobalConfigStorage(data) {
|
|
34
|
+
if (typeof sessionStorage === "undefined") return;
|
|
35
|
+
try {
|
|
36
|
+
sessionStorage.setItem(
|
|
37
|
+
GLOBAL_CONFIG_STORAGE_KEY,
|
|
38
|
+
JSON.stringify({
|
|
39
|
+
time: getNow(),
|
|
40
|
+
data,
|
|
41
|
+
})
|
|
42
|
+
);
|
|
43
|
+
} catch (e) {}
|
|
44
|
+
}
|
|
45
|
+
|
|
5
46
|
function getLetter() {
|
|
6
47
|
return $next({ mute: true }).get("/api/letter/unread/count");
|
|
7
48
|
}
|
|
@@ -34,9 +75,28 @@ function getGames() {
|
|
|
34
75
|
|
|
35
76
|
// 获取全局配置
|
|
36
77
|
function getGlobalConfig() {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
78
|
+
if (globalConfigCache) return Promise.resolve(globalConfigCache);
|
|
79
|
+
|
|
80
|
+
const storageConfig = readGlobalConfigStorage();
|
|
81
|
+
if (storageConfig) {
|
|
82
|
+
globalConfigCache = storageConfig;
|
|
83
|
+
return Promise.resolve(globalConfigCache);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!globalConfigPending) {
|
|
87
|
+
globalConfigPending = axios
|
|
88
|
+
.get(getGlobalConfigUrl())
|
|
89
|
+
.then((res) => {
|
|
90
|
+
globalConfigCache = res.data || {};
|
|
91
|
+
writeGlobalConfigStorage(globalConfigCache);
|
|
92
|
+
return globalConfigCache;
|
|
93
|
+
})
|
|
94
|
+
.finally(() => {
|
|
95
|
+
globalConfigPending = null;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return globalConfigPending;
|
|
40
100
|
}
|
|
41
101
|
|
|
42
102
|
export { getLetter, getMsg, getNav, getPanel, getBox, getMenu, getGames, getGlobalConfig };
|
package/service/thx.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { $pay
|
|
1
|
+
import { $pay } from "@jx3box/jx3box-common/js/api";
|
|
2
|
+
import { getConfig } from "./cms";
|
|
2
3
|
|
|
3
4
|
function getPostBoxcoinRecords(postType, postId, params) {
|
|
4
5
|
return $pay().get(`/api/inspire/article/${postType}/${postId}/history`, {
|
|
@@ -23,10 +24,12 @@ function getPostBoxcoinConfig(postType) {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
function getBoxcoinStatus() {
|
|
26
|
-
return
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
return getConfig({ key: "boxcoin" }).then((data) => {
|
|
28
|
+
return {
|
|
29
|
+
data: {
|
|
30
|
+
data,
|
|
31
|
+
},
|
|
32
|
+
};
|
|
30
33
|
});
|
|
31
34
|
}
|
|
32
35
|
|
package/src/CommonHeader.vue
CHANGED
|
@@ -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
|
}
|
package/src/header/manage.vue
CHANGED
|
@@ -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="
|
|
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>
|
package/src/header/user.vue
CHANGED
|
@@ -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");
|