yingzios-platform-frontend-devtools 1.0.1
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/README.md +77 -0
- package/dist/base-path.d.ts +2 -0
- package/dist/base-path.d.ts.map +1 -0
- package/dist/base-path.js +2 -0
- package/dist/components/DevUserMenu/JwtGeneratorModal.d.ts +13 -0
- package/dist/components/DevUserMenu/JwtGeneratorModal.d.ts.map +1 -0
- package/dist/components/DevUserMenu/JwtGeneratorModal.js +137 -0
- package/dist/components/DevUserMenu/index.d.ts +31 -0
- package/dist/components/DevUserMenu/index.d.ts.map +1 -0
- package/dist/components/DevUserMenu/index.js +219 -0
- package/dist/components/DevUserMenu/jwt-utils.d.ts +34 -0
- package/dist/components/DevUserMenu/jwt-utils.d.ts.map +1 -0
- package/dist/components/DevUserMenu/jwt-utils.js +85 -0
- package/dist/components/DevUserMenu/styles.d.ts +5 -0
- package/dist/components/DevUserMenu/styles.d.ts.map +1 -0
- package/dist/components/DevUserMenu/styles.js +225 -0
- package/dist/components/StandaloneAppLayout/index.d.ts +63 -0
- package/dist/components/StandaloneAppLayout/index.d.ts.map +1 -0
- package/dist/components/StandaloneAppLayout/index.js +161 -0
- package/dist/components/StandaloneAppLayout/styles.d.ts +5 -0
- package/dist/components/StandaloneAppLayout/styles.d.ts.map +1 -0
- package/dist/components/StandaloneAppLayout/styles.js +230 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/manifest-loader.d.ts +50 -0
- package/dist/manifest-loader.d.ts.map +1 -0
- package/dist/manifest-loader.js +90 -0
- package/package.json +50 -0
- package/src/base-path.ts +2 -0
- package/src/components/DevUserMenu/JwtGeneratorModal.tsx +219 -0
- package/src/components/DevUserMenu/index.tsx +372 -0
- package/src/components/DevUserMenu/jwt-utils.ts +121 -0
- package/src/components/DevUserMenu/styles.ts +229 -0
- package/src/components/StandaloneAppLayout/index.tsx +332 -0
- package/src/components/StandaloneAppLayout/styles.ts +234 -0
- package/src/index.ts +4 -0
- package/src/manifest-loader.ts +146 -0
- package/src/types/scss.d.ts +19 -0
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frontend-devtools
|
|
2
|
+
|
|
3
|
+
开发时工具库,提供微应用本地独立运行所需的组件和工具。
|
|
4
|
+
|
|
5
|
+
## 使用场景
|
|
6
|
+
|
|
7
|
+
- 微应用本地独立开发时的布局组件
|
|
8
|
+
- 开发调试工具
|
|
9
|
+
- 简化的登录功能
|
|
10
|
+
|
|
11
|
+
## 重要说明
|
|
12
|
+
|
|
13
|
+
这个库应该作为 **devDependencies** 使用,**不应包含在生产构建中**。
|
|
14
|
+
|
|
15
|
+
## 工具函数
|
|
16
|
+
|
|
17
|
+
> **注意**:`getBasePath`、`getApiBasePath`、`hasSubPath` 已迁移至 `@org/frontend-core`。
|
|
18
|
+
> 本库保留重导出以兼容旧代码,新代码请直接从 `@org/frontend-core` 导入。
|
|
19
|
+
|
|
20
|
+
## 组件
|
|
21
|
+
|
|
22
|
+
### StandaloneAppLayout
|
|
23
|
+
|
|
24
|
+
独立运行模式下的应用布局,自动从 `app-manifest.json` 加载配置并生成侧边栏菜单。
|
|
25
|
+
默认包含登录功能(DevUserMenu)。
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
import { StandaloneAppLayout } from 'yz-frontend-devtools';
|
|
29
|
+
|
|
30
|
+
function StandaloneApp() {
|
|
31
|
+
return (
|
|
32
|
+
<StandaloneAppLayout>
|
|
33
|
+
<AppRoutes />
|
|
34
|
+
</StandaloneAppLayout>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
#### Props
|
|
40
|
+
|
|
41
|
+
| 属性 | 类型 | 默认值 | 说明 |
|
|
42
|
+
|------|------|--------|------|
|
|
43
|
+
| manifestUrl | string | 'app-manifest.json' | manifest 文件路径(相对路径,受 `<base>` 标签影响) |
|
|
44
|
+
| children | ReactNode | - | 子内容 |
|
|
45
|
+
| logo | ReactNode | - | 自定义 Logo |
|
|
46
|
+
| headerRight | ReactNode \| false | DevUserMenu | 顶部右侧自定义内容,设为 false 则不显示 |
|
|
47
|
+
| renderIcon | (iconKey) => ReactNode | - | 自定义图标渲染函数 |
|
|
48
|
+
| apiBasePath | string | '/portal/api' | API 基础路径,用于登录接口 |
|
|
49
|
+
|
|
50
|
+
### DevUserMenu
|
|
51
|
+
|
|
52
|
+
开发环境用户菜单组件,提供简化的登录功能。
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
import { DevUserMenu } from 'yz-frontend-devtools';
|
|
56
|
+
|
|
57
|
+
function Header() {
|
|
58
|
+
return (
|
|
59
|
+
<header>
|
|
60
|
+
<DevUserMenu apiBasePath="/portal/api" />
|
|
61
|
+
</header>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
#### 功能特点
|
|
67
|
+
|
|
68
|
+
- 手机号 + 验证码登录
|
|
69
|
+
- 自动记录上次登录的手机号(localStorage)
|
|
70
|
+
- 验证码默认填写 `1024`(开发环境便捷登录)
|
|
71
|
+
- 登录状态持久化(token 存储在 localStorage)
|
|
72
|
+
|
|
73
|
+
#### Props
|
|
74
|
+
|
|
75
|
+
| 属性 | 类型 | 默认值 | 说明 |
|
|
76
|
+
|------|------|--------|------|
|
|
77
|
+
| apiBasePath | string | '/portal/api' | API 基础路径 |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"base-path.d.ts","sourceRoot":"","sources":["../src/base-path.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
interface JwtGeneratorModalProps {
|
|
3
|
+
onClose: () => void;
|
|
4
|
+
onSuccess: () => void;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* JWT 生成器对话框
|
|
8
|
+
*
|
|
9
|
+
* 用于本地开发时手动签发 JWT Token,无需依赖 portal 网关。
|
|
10
|
+
*/
|
|
11
|
+
export declare function JwtGeneratorModal({ onClose, onSuccess }: JwtGeneratorModalProps): React.JSX.Element;
|
|
12
|
+
export {};
|
|
13
|
+
//# sourceMappingURL=JwtGeneratorModal.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"JwtGeneratorModal.d.ts","sourceRoot":"","sources":["../../../src/components/DevUserMenu/JwtGeneratorModal.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAmB,MAAM,OAAO,CAAC;AAKxC,UAAU,sBAAsB;IAC9B,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,SAAS,EAAE,MAAM,IAAI,CAAC;CACvB;AAsCD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,EAAE,sBAAsB,qBAuK/E"}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Form, Input, message } from 'antd';
|
|
3
|
+
import { modalStyles } from './styles';
|
|
4
|
+
import { signJwtHS256, getDefaultJwtPayload } from './jwt-utils';
|
|
5
|
+
const STORAGE_KEY = 'devtools:jwtFormData';
|
|
6
|
+
function getInitialValues() {
|
|
7
|
+
if (typeof window === 'undefined') {
|
|
8
|
+
return {
|
|
9
|
+
payloadText: JSON.stringify(getDefaultJwtPayload(), null, 2),
|
|
10
|
+
secret: 'this-is-a-secret-key-for-jwt',
|
|
11
|
+
expiresIn: 86400,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const saved = localStorage.getItem(STORAGE_KEY);
|
|
16
|
+
if (saved) {
|
|
17
|
+
const parsed = JSON.parse(saved);
|
|
18
|
+
// 验证 payload 是否为有效 JSON
|
|
19
|
+
JSON.parse(parsed.payloadText);
|
|
20
|
+
return parsed;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// 解析失败,使用默认值
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
payloadText: JSON.stringify(getDefaultJwtPayload(), null, 2),
|
|
28
|
+
secret: 'this-is-a-secret-key-for-jwt',
|
|
29
|
+
expiresIn: 86400,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* JWT 生成器对话框
|
|
34
|
+
*
|
|
35
|
+
* 用于本地开发时手动签发 JWT Token,无需依赖 portal 网关。
|
|
36
|
+
*/
|
|
37
|
+
export function JwtGeneratorModal({ onClose, onSuccess }) {
|
|
38
|
+
const [form] = Form.useForm();
|
|
39
|
+
const [loading, setLoading] = useState(false);
|
|
40
|
+
const handleGenerate = async (values) => {
|
|
41
|
+
const { payloadText, secret, expiresIn } = values;
|
|
42
|
+
setLoading(true);
|
|
43
|
+
try {
|
|
44
|
+
// 1. 解析 payload
|
|
45
|
+
const payload = JSON.parse(payloadText);
|
|
46
|
+
// 2. 生成 JWT
|
|
47
|
+
const token = await signJwtHS256(payload, secret, expiresIn);
|
|
48
|
+
// 3. 保存到 localStorage
|
|
49
|
+
localStorage.setItem('accessToken', token);
|
|
50
|
+
// 4. 保存用户信息到 localStorage(用于 DevUserMenu 显示)
|
|
51
|
+
const userInfo = {
|
|
52
|
+
userId: payload.sub || payload.id,
|
|
53
|
+
nickname: payload.nickname,
|
|
54
|
+
avatar: payload.avatar || '',
|
|
55
|
+
phone: payload.phone,
|
|
56
|
+
};
|
|
57
|
+
localStorage.setItem('devtools:userInfo', JSON.stringify(userInfo));
|
|
58
|
+
// 5. 保存表单数据到 localStorage
|
|
59
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(values));
|
|
60
|
+
// 6. 显示成功提示并通知父组件
|
|
61
|
+
message.success('JWT Token 生成成功!2 秒后自动刷新页面...');
|
|
62
|
+
onSuccess();
|
|
63
|
+
// 7. 2 秒后自动刷新页面
|
|
64
|
+
setTimeout(() => {
|
|
65
|
+
window.location.reload();
|
|
66
|
+
}, 2000);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
console.error('[JwtGeneratorModal] 生成 JWT 失败:', err);
|
|
70
|
+
message.error(err instanceof Error ? err.message : '生成 JWT 失败,请检查输入');
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
setLoading(false);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
return (React.createElement("div", { style: modalStyles.overlay, onClick: onClose },
|
|
77
|
+
React.createElement("div", { style: { ...modalStyles.modal, width: '750px', maxWidth: '90vw' }, onClick: (e) => e.stopPropagation() },
|
|
78
|
+
React.createElement("div", { style: modalStyles.header },
|
|
79
|
+
React.createElement("h3", { style: modalStyles.title }, "\u672C\u5730\u7B7E\u53D1 JWT Token"),
|
|
80
|
+
React.createElement("button", { style: modalStyles.closeButton, onClick: onClose }, "\u00D7")),
|
|
81
|
+
React.createElement(Form, { form: form, layout: "vertical", initialValues: getInitialValues(), onFinish: handleGenerate, style: modalStyles.form },
|
|
82
|
+
React.createElement(Form.Item, { label: React.createElement("span", null,
|
|
83
|
+
"Payload ",
|
|
84
|
+
React.createElement("span", { style: { color: '#ff4d4f' } }, "*")), name: "payloadText", rules: [
|
|
85
|
+
{ required: true, message: '请输入 Payload' },
|
|
86
|
+
{
|
|
87
|
+
validator: async (_, value) => {
|
|
88
|
+
if (!value)
|
|
89
|
+
return;
|
|
90
|
+
try {
|
|
91
|
+
const payload = JSON.parse(value);
|
|
92
|
+
// 验证必填字段
|
|
93
|
+
if (!payload.id && !payload.sub) {
|
|
94
|
+
throw new Error('缺少必填字段: id 或 sub(用户ID)');
|
|
95
|
+
}
|
|
96
|
+
const requiredFields = ['aud', 'nickname', 'phone'];
|
|
97
|
+
for (const field of requiredFields) {
|
|
98
|
+
if (!payload[field]) {
|
|
99
|
+
throw new Error(`缺少必填字段: ${field}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
if (err instanceof SyntaxError) {
|
|
105
|
+
throw new Error('Payload 格式错误,请输入有效的 JSON');
|
|
106
|
+
}
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
], extra: "JSON \u683C\u5F0F\uFF0C\u5FC5\u586B\u5B57\u6BB5\uFF1Aid/sub\uFF08\u7528\u6237ID\uFF09\u3001aud\uFF08\u53D7\u4F17\uFF09\u3001nickname\uFF08\u6635\u79F0\uFF09\u3001phone\uFF08\u624B\u673A\u53F7\uFF09\u3001orgId\uFF08\u7EC4\u7EC7ID\uFF0C\u53EF\u9009\uFF09" },
|
|
112
|
+
React.createElement(Input.TextArea, { rows: 8, placeholder: '{"id": "1000000001", "sub": "1000000001", "aud": "internal", ...}', style: {
|
|
113
|
+
fontFamily: 'monospace',
|
|
114
|
+
fontSize: '13px',
|
|
115
|
+
} })),
|
|
116
|
+
React.createElement(Form.Item, { label: React.createElement("span", null,
|
|
117
|
+
"JWT \u5BC6\u94A5\uFF08Secret\uFF09",
|
|
118
|
+
React.createElement("span", { style: { color: '#ff4d4f' } }, "*")), name: "secret", rules: [
|
|
119
|
+
{ required: true, message: '请输入 JWT 密钥' },
|
|
120
|
+
{ whitespace: true, message: 'JWT 密钥不能为空' },
|
|
121
|
+
], extra: "\u7B7E\u540D\u7B97\u6CD5\uFF1AHS256\uFF08HMAC SHA256\uFF09" },
|
|
122
|
+
React.createElement(Input, { placeholder: "your-secret-key" })),
|
|
123
|
+
React.createElement(Form.Item, { label: React.createElement("span", null,
|
|
124
|
+
"\u6709\u6548\u671F\uFF08\u79D2\uFF09",
|
|
125
|
+
React.createElement("span", { style: { color: '#ff4d4f' } }, "*")), name: "expiresIn", rules: [
|
|
126
|
+
{ required: true, message: '请输入有效期' },
|
|
127
|
+
{ type: 'number', min: 1, message: '有效期必须是正整数' },
|
|
128
|
+
], extra: "\u9ED8\u8BA4 86400 \u79D2\uFF081 \u5929\uFF09" },
|
|
129
|
+
React.createElement(Input, { type: "number", placeholder: "86400", min: 1 })),
|
|
130
|
+
React.createElement("div", { style: modalStyles.tip },
|
|
131
|
+
"\uD83D\uDCA1 \u751F\u6210\u7684 Token \u5C06\u4FDD\u5B58\u5230 ",
|
|
132
|
+
React.createElement("code", { style: modalStyles.tipCode }, "localStorage.accessToken")),
|
|
133
|
+
React.createElement("button", { type: "submit", style: {
|
|
134
|
+
...modalStyles.submitButton,
|
|
135
|
+
...(loading ? modalStyles.submitButtonDisabled : {}),
|
|
136
|
+
}, disabled: loading }, loading ? '生成中...' : '生成并保存 Token')))));
|
|
137
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export interface DevUserMenuProps {
|
|
3
|
+
/**
|
|
4
|
+
* API 基础路径,默认 '/portal/api'
|
|
5
|
+
*/
|
|
6
|
+
apiBasePath?: string;
|
|
7
|
+
/**
|
|
8
|
+
* 是否显示切换组织按钮
|
|
9
|
+
* @default true
|
|
10
|
+
*/
|
|
11
|
+
showOrgSwitch?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* 组织切换页面路径
|
|
14
|
+
* @default '/org-select'
|
|
15
|
+
*/
|
|
16
|
+
orgSwitchPath?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* 开发环境用户菜单组件
|
|
20
|
+
*
|
|
21
|
+
* 提供简化的登录功能,方便本地独立开发调试。
|
|
22
|
+
* - 手机号 + 验证码登录
|
|
23
|
+
* - 自动记录上次登录的手机号
|
|
24
|
+
* - 验证码默认填写 1024
|
|
25
|
+
*/
|
|
26
|
+
export declare function DevUserMenu({ apiBasePath, showOrgSwitch, orgSwitchPath, }: DevUserMenuProps): React.JSX.Element;
|
|
27
|
+
export declare namespace DevUserMenu {
|
|
28
|
+
var displayName: string;
|
|
29
|
+
}
|
|
30
|
+
export default DevUserMenu;
|
|
31
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/DevUserMenu/index.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAmD,MAAM,OAAO,CAAC;AAkBxE,MAAM,WAAW,gBAAgB;IAC/B;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,EAC1B,WAA2B,EAC3B,aAAoB,EACpB,aAA6B,GAC9B,EAAE,gBAAgB,qBAyIlB;yBA7Ie,WAAW;;;AAwU3B,eAAe,WAAW,CAAC"}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
2
|
+
import { devUserMenuStyles, modalStyles, formGroupStyles } from './styles';
|
|
3
|
+
import { JwtGeneratorModal } from './JwtGeneratorModal';
|
|
4
|
+
const STORAGE_KEYS = {
|
|
5
|
+
accessToken: 'accessToken',
|
|
6
|
+
refreshToken: 'refreshToken',
|
|
7
|
+
lastPhone: 'devtools:lastPhone',
|
|
8
|
+
userInfo: 'devtools:userInfo',
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* 开发环境用户菜单组件
|
|
12
|
+
*
|
|
13
|
+
* 提供简化的登录功能,方便本地独立开发调试。
|
|
14
|
+
* - 手机号 + 验证码登录
|
|
15
|
+
* - 自动记录上次登录的手机号
|
|
16
|
+
* - 验证码默认填写 1024
|
|
17
|
+
*/
|
|
18
|
+
export function DevUserMenu({ apiBasePath = '/portal/api', showOrgSwitch = true, orgSwitchPath = '/org-select', }) {
|
|
19
|
+
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
|
20
|
+
const [userInfo, setUserInfo] = useState(null);
|
|
21
|
+
const [showDropdown, setShowDropdown] = useState(false);
|
|
22
|
+
const [showJwtGenerator, setShowJwtGenerator] = useState(false);
|
|
23
|
+
const dropdownRef = useRef(null);
|
|
24
|
+
// 初始化时检查登录状态
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const token = localStorage.getItem(STORAGE_KEYS.accessToken);
|
|
27
|
+
const savedUserInfo = localStorage.getItem(STORAGE_KEYS.userInfo);
|
|
28
|
+
if (token && savedUserInfo) {
|
|
29
|
+
try {
|
|
30
|
+
setUserInfo(JSON.parse(savedUserInfo));
|
|
31
|
+
setIsLoggedIn(true);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// 解析失败,清除无效数据
|
|
35
|
+
clearAuth();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}, []);
|
|
39
|
+
// 点击外部关闭下拉菜单
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
function handleClickOutside(event) {
|
|
42
|
+
if (dropdownRef.current &&
|
|
43
|
+
!dropdownRef.current.contains(event.target)) {
|
|
44
|
+
setShowDropdown(false);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
48
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
49
|
+
}, []);
|
|
50
|
+
const clearAuth = useCallback(() => {
|
|
51
|
+
localStorage.removeItem(STORAGE_KEYS.accessToken);
|
|
52
|
+
localStorage.removeItem(STORAGE_KEYS.refreshToken);
|
|
53
|
+
localStorage.removeItem(STORAGE_KEYS.userInfo);
|
|
54
|
+
setIsLoggedIn(false);
|
|
55
|
+
setUserInfo(null);
|
|
56
|
+
}, []);
|
|
57
|
+
const handleLogout = useCallback(() => {
|
|
58
|
+
clearAuth();
|
|
59
|
+
setShowDropdown(false);
|
|
60
|
+
}, [clearAuth]);
|
|
61
|
+
const handleJwtGeneratorSuccess = useCallback(() => {
|
|
62
|
+
// 重新加载用户信息
|
|
63
|
+
const token = localStorage.getItem(STORAGE_KEYS.accessToken);
|
|
64
|
+
const savedUserInfo = localStorage.getItem(STORAGE_KEYS.userInfo);
|
|
65
|
+
if (token && savedUserInfo) {
|
|
66
|
+
try {
|
|
67
|
+
setUserInfo(JSON.parse(savedUserInfo));
|
|
68
|
+
setIsLoggedIn(true);
|
|
69
|
+
setShowJwtGenerator(false);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
clearAuth();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}, [clearAuth]);
|
|
76
|
+
const getAvatarContent = () => {
|
|
77
|
+
if (userInfo?.avatar) {
|
|
78
|
+
return (React.createElement("img", { src: userInfo.avatar, alt: "avatar", style: devUserMenuStyles.avatarImage }));
|
|
79
|
+
}
|
|
80
|
+
if (userInfo?.nickname) {
|
|
81
|
+
return userInfo.nickname.charAt(0).toUpperCase();
|
|
82
|
+
}
|
|
83
|
+
return '?';
|
|
84
|
+
};
|
|
85
|
+
const handleSwitchOrganization = useCallback(() => {
|
|
86
|
+
window.location.href = orgSwitchPath;
|
|
87
|
+
}, [orgSwitchPath]);
|
|
88
|
+
return (React.createElement("div", { style: devUserMenuStyles.container, ref: dropdownRef },
|
|
89
|
+
isLoggedIn ? (React.createElement(React.Fragment, null,
|
|
90
|
+
React.createElement("button", { style: devUserMenuStyles.avatar, onClick: () => setShowDropdown(!showDropdown), title: userInfo?.nickname || '用户' }, getAvatarContent()),
|
|
91
|
+
showDropdown && (React.createElement("div", { style: devUserMenuStyles.dropdown },
|
|
92
|
+
React.createElement("div", { style: devUserMenuStyles.dropdownHeader },
|
|
93
|
+
React.createElement("span", { style: devUserMenuStyles.dropdownNickname }, userInfo?.nickname || '未知用户'),
|
|
94
|
+
React.createElement("span", { style: devUserMenuStyles.dropdownPhone }, userInfo?.phone)),
|
|
95
|
+
React.createElement("div", { style: devUserMenuStyles.dropdownDivider }),
|
|
96
|
+
React.createElement("button", { style: devUserMenuStyles.dropdownItem, onClick: handleLogout }, "\u9000\u51FA\u767B\u5F55"))))) : (React.createElement("button", { style: devUserMenuStyles.loginButton, onClick: () => setShowJwtGenerator(true) }, "\u672C\u5730\u7B7E\u53D1 JWT")),
|
|
97
|
+
showJwtGenerator && (React.createElement(JwtGeneratorModal, { onClose: () => setShowJwtGenerator(false), onSuccess: handleJwtGeneratorSuccess }))));
|
|
98
|
+
}
|
|
99
|
+
function LoginModal({ apiBasePath, onClose, onSuccess }) {
|
|
100
|
+
const [phone, setPhone] = useState(() => {
|
|
101
|
+
return localStorage.getItem(STORAGE_KEYS.lastPhone) || '';
|
|
102
|
+
});
|
|
103
|
+
const [code, setCode] = useState('1024');
|
|
104
|
+
const [loading, setLoading] = useState(false);
|
|
105
|
+
const [error, setError] = useState(null);
|
|
106
|
+
const [codeSent, setCodeSent] = useState(false);
|
|
107
|
+
const [countdown, setCountdown] = useState(0);
|
|
108
|
+
// 验证码倒计时
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (countdown > 0) {
|
|
111
|
+
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
|
|
112
|
+
return () => clearTimeout(timer);
|
|
113
|
+
}
|
|
114
|
+
}, [countdown]);
|
|
115
|
+
const sendCode = async () => {
|
|
116
|
+
if (!phone || phone.length !== 11) {
|
|
117
|
+
setError('请输入有效的手机号');
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
setLoading(true);
|
|
121
|
+
setError(null);
|
|
122
|
+
try {
|
|
123
|
+
const response = await fetch(`${apiBasePath}/base/sms/send-code`, {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
headers: { 'Content-Type': 'application/json' },
|
|
126
|
+
body: JSON.stringify({ phone, action: 'login' }),
|
|
127
|
+
});
|
|
128
|
+
const data = await response.json();
|
|
129
|
+
if (data.code === '000000') {
|
|
130
|
+
setCodeSent(true);
|
|
131
|
+
setCountdown(60);
|
|
132
|
+
// 保存手机号
|
|
133
|
+
localStorage.setItem(STORAGE_KEYS.lastPhone, phone);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
setError(data.message || '发送验证码失败');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
setError('网络错误,请稍后重试');
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
setLoading(false);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const handleLogin = async (e) => {
|
|
147
|
+
e.preventDefault();
|
|
148
|
+
if (!phone || !code) {
|
|
149
|
+
setError('请填写手机号和验证码');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
setLoading(true);
|
|
153
|
+
setError(null);
|
|
154
|
+
try {
|
|
155
|
+
const response = await fetch(`${apiBasePath}/base/auth/login`, {
|
|
156
|
+
method: 'POST',
|
|
157
|
+
headers: { 'Content-Type': 'application/json' },
|
|
158
|
+
body: JSON.stringify({
|
|
159
|
+
method: 'phone_sms',
|
|
160
|
+
phone,
|
|
161
|
+
code,
|
|
162
|
+
}),
|
|
163
|
+
});
|
|
164
|
+
const data = await response.json();
|
|
165
|
+
if (data.code === '000000' && data.data) {
|
|
166
|
+
const loginData = data.data;
|
|
167
|
+
// 保存 token
|
|
168
|
+
localStorage.setItem(STORAGE_KEYS.accessToken, loginData.accessToken);
|
|
169
|
+
localStorage.setItem(STORAGE_KEYS.refreshToken, loginData.refreshToken);
|
|
170
|
+
localStorage.setItem(STORAGE_KEYS.lastPhone, phone);
|
|
171
|
+
// 构建用户信息
|
|
172
|
+
const userInfo = {
|
|
173
|
+
userId: loginData.userId,
|
|
174
|
+
nickname: loginData.nickname || phone,
|
|
175
|
+
avatar: loginData.avatar,
|
|
176
|
+
phone,
|
|
177
|
+
};
|
|
178
|
+
localStorage.setItem(STORAGE_KEYS.userInfo, JSON.stringify(userInfo));
|
|
179
|
+
onSuccess(userInfo);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
setError(data.message || '登录失败');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
setError('网络错误,请稍后重试');
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
setLoading(false);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
return (React.createElement("div", { style: modalStyles.overlay, onClick: onClose },
|
|
193
|
+
React.createElement("div", { style: modalStyles.modal, onClick: (e) => e.stopPropagation() },
|
|
194
|
+
React.createElement("div", { style: modalStyles.header },
|
|
195
|
+
React.createElement("h3", { style: modalStyles.title }, "\u5F00\u53D1\u767B\u5F55"),
|
|
196
|
+
React.createElement("button", { style: modalStyles.closeButton, onClick: onClose }, "\u00D7")),
|
|
197
|
+
React.createElement("form", { style: modalStyles.form, onSubmit: handleLogin },
|
|
198
|
+
React.createElement("div", { style: formGroupStyles.group },
|
|
199
|
+
React.createElement("label", { style: formGroupStyles.label }, "\u624B\u673A\u53F7"),
|
|
200
|
+
React.createElement("input", { type: "tel", style: formGroupStyles.input, value: phone, onChange: (e) => setPhone(e.target.value), placeholder: "\u8BF7\u8F93\u5165\u624B\u673A\u53F7", maxLength: 11, autoComplete: "tel" })),
|
|
201
|
+
React.createElement("div", { style: formGroupStyles.group },
|
|
202
|
+
React.createElement("label", { style: formGroupStyles.label }, "\u9A8C\u8BC1\u7801"),
|
|
203
|
+
React.createElement("div", { style: formGroupStyles.inputRow },
|
|
204
|
+
React.createElement("input", { type: "text", style: { ...formGroupStyles.input, flex: 1 }, value: code, onChange: (e) => setCode(e.target.value), placeholder: "\u9A8C\u8BC1\u7801", maxLength: 6 }),
|
|
205
|
+
React.createElement("button", { type: "button", style: {
|
|
206
|
+
...formGroupStyles.sendButton,
|
|
207
|
+
...(loading || countdown > 0 ? formGroupStyles.sendButtonDisabled : {}),
|
|
208
|
+
}, onClick: sendCode, disabled: loading || countdown > 0 }, countdown > 0 ? `${countdown}s` : codeSent ? '重新发送' : '发送验证码'))),
|
|
209
|
+
error && React.createElement("div", { style: modalStyles.error }, error),
|
|
210
|
+
React.createElement("div", { style: modalStyles.tip },
|
|
211
|
+
"\uD83D\uDCA1 \u5F00\u53D1\u73AF\u5883\u9A8C\u8BC1\u7801\u9ED8\u8BA4\u4E3A ",
|
|
212
|
+
React.createElement("code", { style: modalStyles.tipCode }, "1024")),
|
|
213
|
+
React.createElement("button", { type: "submit", style: {
|
|
214
|
+
...modalStyles.submitButton,
|
|
215
|
+
...(loading ? modalStyles.submitButtonDisabled : {}),
|
|
216
|
+
}, disabled: loading }, loading ? '登录中...' : '登录')))));
|
|
217
|
+
}
|
|
218
|
+
DevUserMenu.displayName = 'DevUserMenu';
|
|
219
|
+
export default DevUserMenu;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWT 生成工具(浏览器端实现)
|
|
3
|
+
* 使用 Web Crypto API 实现 HS256 签名
|
|
4
|
+
*
|
|
5
|
+
* JWT Payload 字段说明(与 portal-backend auth.service.ts 保持一致):
|
|
6
|
+
* - id: 用户ID(JwtStrategy 优先从此字段提取 userId)
|
|
7
|
+
* - sub: 用户ID(标准 JWT 字段,作为 id 的备选)
|
|
8
|
+
* - aud: 受众,固定为 'internal'
|
|
9
|
+
* - nickname: 用户昵称
|
|
10
|
+
* - avatar: 用户头像 URL
|
|
11
|
+
* - phone: 手机号
|
|
12
|
+
* - orgId: 当前组织 ID(可选)
|
|
13
|
+
*/
|
|
14
|
+
interface JwtPayload {
|
|
15
|
+
id: string;
|
|
16
|
+
sub: string;
|
|
17
|
+
aud: string;
|
|
18
|
+
nickname: string;
|
|
19
|
+
avatar?: string;
|
|
20
|
+
phone: string;
|
|
21
|
+
orgId?: string;
|
|
22
|
+
[key: string]: any;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 使用 HS256 算法签名 JWT
|
|
26
|
+
*/
|
|
27
|
+
export declare function signJwtHS256(payload: JwtPayload, secret: string, expiresIn?: number): Promise<string>;
|
|
28
|
+
/**
|
|
29
|
+
* 获取默认的 JWT Payload
|
|
30
|
+
* 格式与 portal-backend auth.service.ts 中签发的内部 JWT 保持一致
|
|
31
|
+
*/
|
|
32
|
+
export declare function getDefaultJwtPayload(): JwtPayload;
|
|
33
|
+
export {};
|
|
34
|
+
//# sourceMappingURL=jwt-utils.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"jwt-utils.d.ts","sourceRoot":"","sources":["../../../src/components/DevUserMenu/jwt-utils.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,UAAU,UAAU;IAClB,EAAE,EAAE,MAAM,CAAC;IACX,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAC;CACpB;AAmCD;;GAEG;AACH,wBAAsB,YAAY,CAChC,OAAO,EAAE,UAAU,EACnB,MAAM,EAAE,MAAM,EACd,SAAS,GAAE,MAAc,GACxB,OAAO,CAAC,MAAM,CAAC,CAsCjB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,IAAI,UAAU,CAWjD"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWT 生成工具(浏览器端实现)
|
|
3
|
+
* 使用 Web Crypto API 实现 HS256 签名
|
|
4
|
+
*
|
|
5
|
+
* JWT Payload 字段说明(与 portal-backend auth.service.ts 保持一致):
|
|
6
|
+
* - id: 用户ID(JwtStrategy 优先从此字段提取 userId)
|
|
7
|
+
* - sub: 用户ID(标准 JWT 字段,作为 id 的备选)
|
|
8
|
+
* - aud: 受众,固定为 'internal'
|
|
9
|
+
* - nickname: 用户昵称
|
|
10
|
+
* - avatar: 用户头像 URL
|
|
11
|
+
* - phone: 手机号
|
|
12
|
+
* - orgId: 当前组织 ID(可选)
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Base64Url 编码(支持 Unicode 字符)
|
|
16
|
+
*/
|
|
17
|
+
function base64UrlEncode(str) {
|
|
18
|
+
// 使用 TextEncoder 将字符串转换为 UTF-8 字节数组
|
|
19
|
+
const encoder = new TextEncoder();
|
|
20
|
+
const bytes = encoder.encode(str);
|
|
21
|
+
// 将字节数组转换为二进制字符串
|
|
22
|
+
const binary = String.fromCharCode(...bytes);
|
|
23
|
+
// Base64 编码
|
|
24
|
+
const base64 = btoa(binary);
|
|
25
|
+
// 转换为 Base64Url 格式
|
|
26
|
+
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* ArrayBuffer 转 Base64Url
|
|
30
|
+
* 注意:不能调用 base64UrlEncode,因为那个函数会用 TextEncoder 重新编码
|
|
31
|
+
*/
|
|
32
|
+
function arrayBufferToBase64Url(buffer) {
|
|
33
|
+
const bytes = new Uint8Array(buffer);
|
|
34
|
+
const binary = String.fromCharCode(...bytes);
|
|
35
|
+
// 直接进行 Base64 编码,不使用 TextEncoder
|
|
36
|
+
const base64 = btoa(binary);
|
|
37
|
+
// 转换为 Base64Url 格式
|
|
38
|
+
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 使用 HS256 算法签名 JWT
|
|
42
|
+
*/
|
|
43
|
+
export async function signJwtHS256(payload, secret, expiresIn = 86400) {
|
|
44
|
+
// 1. JWT Header
|
|
45
|
+
const header = {
|
|
46
|
+
alg: 'HS256',
|
|
47
|
+
typ: 'JWT',
|
|
48
|
+
};
|
|
49
|
+
// 2. 添加过期时间到 payload
|
|
50
|
+
const now = Math.floor(Date.now() / 1000);
|
|
51
|
+
const fullPayload = {
|
|
52
|
+
...payload,
|
|
53
|
+
iat: now,
|
|
54
|
+
exp: now + expiresIn,
|
|
55
|
+
};
|
|
56
|
+
// 3. 编码 header 和 payload
|
|
57
|
+
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
|
58
|
+
const encodedPayload = base64UrlEncode(JSON.stringify(fullPayload));
|
|
59
|
+
const message = `${encodedHeader}.${encodedPayload}`;
|
|
60
|
+
// 4. 使用 Web Crypto API 进行 HMAC-SHA256 签名
|
|
61
|
+
const encoder = new TextEncoder();
|
|
62
|
+
const keyData = encoder.encode(secret);
|
|
63
|
+
const messageData = encoder.encode(message);
|
|
64
|
+
const cryptoKey = await crypto.subtle.importKey('raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
65
|
+
const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageData);
|
|
66
|
+
// 5. 组装完整的 JWT
|
|
67
|
+
const encodedSignature = arrayBufferToBase64Url(signature);
|
|
68
|
+
return `${message}.${encodedSignature}`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 获取默认的 JWT Payload
|
|
72
|
+
* 格式与 portal-backend auth.service.ts 中签发的内部 JWT 保持一致
|
|
73
|
+
*/
|
|
74
|
+
export function getDefaultJwtPayload() {
|
|
75
|
+
const userId = '1000000001';
|
|
76
|
+
return {
|
|
77
|
+
id: userId,
|
|
78
|
+
sub: userId,
|
|
79
|
+
aud: 'internal',
|
|
80
|
+
nickname: '开发用户',
|
|
81
|
+
avatar: '',
|
|
82
|
+
phone: '13800138000',
|
|
83
|
+
orgId: '',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
2
|
+
export declare const devUserMenuStyles: Record<string, CSSProperties>;
|
|
3
|
+
export declare const modalStyles: Record<string, CSSProperties>;
|
|
4
|
+
export declare const formGroupStyles: Record<string, CSSProperties>;
|
|
5
|
+
//# sourceMappingURL=styles.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"styles.d.ts","sourceRoot":"","sources":["../../../src/components/DevUserMenu/styles.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,OAAO,CAAC;AAE3C,eAAO,MAAM,iBAAiB,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CA8F3D,CAAC;AAEF,eAAO,MAAM,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAwFrD,CAAC;AAEF,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAwCzD,CAAC"}
|