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.
Files changed (39) hide show
  1. package/README.md +77 -0
  2. package/dist/base-path.d.ts +2 -0
  3. package/dist/base-path.d.ts.map +1 -0
  4. package/dist/base-path.js +2 -0
  5. package/dist/components/DevUserMenu/JwtGeneratorModal.d.ts +13 -0
  6. package/dist/components/DevUserMenu/JwtGeneratorModal.d.ts.map +1 -0
  7. package/dist/components/DevUserMenu/JwtGeneratorModal.js +137 -0
  8. package/dist/components/DevUserMenu/index.d.ts +31 -0
  9. package/dist/components/DevUserMenu/index.d.ts.map +1 -0
  10. package/dist/components/DevUserMenu/index.js +219 -0
  11. package/dist/components/DevUserMenu/jwt-utils.d.ts +34 -0
  12. package/dist/components/DevUserMenu/jwt-utils.d.ts.map +1 -0
  13. package/dist/components/DevUserMenu/jwt-utils.js +85 -0
  14. package/dist/components/DevUserMenu/styles.d.ts +5 -0
  15. package/dist/components/DevUserMenu/styles.d.ts.map +1 -0
  16. package/dist/components/DevUserMenu/styles.js +225 -0
  17. package/dist/components/StandaloneAppLayout/index.d.ts +63 -0
  18. package/dist/components/StandaloneAppLayout/index.d.ts.map +1 -0
  19. package/dist/components/StandaloneAppLayout/index.js +161 -0
  20. package/dist/components/StandaloneAppLayout/styles.d.ts +5 -0
  21. package/dist/components/StandaloneAppLayout/styles.d.ts.map +1 -0
  22. package/dist/components/StandaloneAppLayout/styles.js +230 -0
  23. package/dist/index.d.ts +5 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +4 -0
  26. package/dist/manifest-loader.d.ts +50 -0
  27. package/dist/manifest-loader.d.ts.map +1 -0
  28. package/dist/manifest-loader.js +90 -0
  29. package/package.json +50 -0
  30. package/src/base-path.ts +2 -0
  31. package/src/components/DevUserMenu/JwtGeneratorModal.tsx +219 -0
  32. package/src/components/DevUserMenu/index.tsx +372 -0
  33. package/src/components/DevUserMenu/jwt-utils.ts +121 -0
  34. package/src/components/DevUserMenu/styles.ts +229 -0
  35. package/src/components/StandaloneAppLayout/index.tsx +332 -0
  36. package/src/components/StandaloneAppLayout/styles.ts +234 -0
  37. package/src/index.ts +4 -0
  38. package/src/manifest-loader.ts +146 -0
  39. package/src/types/scss.d.ts +19 -0
@@ -0,0 +1,372 @@
1
+ import React, { useState, useEffect, useCallback, useRef } from 'react';
2
+ import { devUserMenuStyles, modalStyles, formGroupStyles } from './styles';
3
+ import { JwtGeneratorModal } from './JwtGeneratorModal';
4
+
5
+ const STORAGE_KEYS = {
6
+ accessToken: 'accessToken',
7
+ refreshToken: 'refreshToken',
8
+ lastPhone: 'devtools:lastPhone',
9
+ userInfo: 'devtools:userInfo',
10
+ } as const;
11
+
12
+ interface UserInfo {
13
+ userId: string;
14
+ nickname: string;
15
+ avatar?: string;
16
+ phone: string;
17
+ }
18
+
19
+ export interface DevUserMenuProps {
20
+ /**
21
+ * API 基础路径,默认 '/portal/api'
22
+ */
23
+ apiBasePath?: string;
24
+ /**
25
+ * 是否显示切换组织按钮
26
+ * @default true
27
+ */
28
+ showOrgSwitch?: boolean;
29
+ /**
30
+ * 组织切换页面路径
31
+ * @default '/org-select'
32
+ */
33
+ orgSwitchPath?: string;
34
+ }
35
+
36
+ /**
37
+ * 开发环境用户菜单组件
38
+ *
39
+ * 提供简化的登录功能,方便本地独立开发调试。
40
+ * - 手机号 + 验证码登录
41
+ * - 自动记录上次登录的手机号
42
+ * - 验证码默认填写 1024
43
+ */
44
+ export function DevUserMenu({
45
+ apiBasePath = '/portal/api',
46
+ showOrgSwitch = true,
47
+ orgSwitchPath = '/org-select',
48
+ }: DevUserMenuProps) {
49
+ const [isLoggedIn, setIsLoggedIn] = useState(false);
50
+ const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
51
+ const [showDropdown, setShowDropdown] = useState(false);
52
+ const [showJwtGenerator, setShowJwtGenerator] = useState(false);
53
+ const dropdownRef = useRef<HTMLDivElement>(null);
54
+
55
+ // 初始化时检查登录状态
56
+ useEffect(() => {
57
+ const token = localStorage.getItem(STORAGE_KEYS.accessToken);
58
+ const savedUserInfo = localStorage.getItem(STORAGE_KEYS.userInfo);
59
+
60
+ if (token && savedUserInfo) {
61
+ try {
62
+ setUserInfo(JSON.parse(savedUserInfo));
63
+ setIsLoggedIn(true);
64
+ } catch {
65
+ // 解析失败,清除无效数据
66
+ clearAuth();
67
+ }
68
+ }
69
+ }, []);
70
+
71
+ // 点击外部关闭下拉菜单
72
+ useEffect(() => {
73
+ function handleClickOutside(event: MouseEvent) {
74
+ if (
75
+ dropdownRef.current &&
76
+ !dropdownRef.current.contains(event.target as Node)
77
+ ) {
78
+ setShowDropdown(false);
79
+ }
80
+ }
81
+
82
+ document.addEventListener('mousedown', handleClickOutside);
83
+ return () => document.removeEventListener('mousedown', handleClickOutside);
84
+ }, []);
85
+
86
+ const clearAuth = useCallback(() => {
87
+ localStorage.removeItem(STORAGE_KEYS.accessToken);
88
+ localStorage.removeItem(STORAGE_KEYS.refreshToken);
89
+ localStorage.removeItem(STORAGE_KEYS.userInfo);
90
+ setIsLoggedIn(false);
91
+ setUserInfo(null);
92
+ }, []);
93
+
94
+ const handleLogout = useCallback(() => {
95
+ clearAuth();
96
+ setShowDropdown(false);
97
+ }, [clearAuth]);
98
+
99
+ const handleJwtGeneratorSuccess = useCallback(() => {
100
+ // 重新加载用户信息
101
+ const token = localStorage.getItem(STORAGE_KEYS.accessToken);
102
+ const savedUserInfo = localStorage.getItem(STORAGE_KEYS.userInfo);
103
+
104
+ if (token && savedUserInfo) {
105
+ try {
106
+ setUserInfo(JSON.parse(savedUserInfo));
107
+ setIsLoggedIn(true);
108
+ setShowJwtGenerator(false);
109
+ } catch {
110
+ clearAuth();
111
+ }
112
+ }
113
+ }, [clearAuth]);
114
+
115
+ const getAvatarContent = () => {
116
+ if (userInfo?.avatar) {
117
+ return (
118
+ <img
119
+ src={userInfo.avatar}
120
+ alt="avatar"
121
+ style={devUserMenuStyles.avatarImage as React.CSSProperties}
122
+ />
123
+ );
124
+ }
125
+
126
+ if (userInfo?.nickname) {
127
+ return userInfo.nickname.charAt(0).toUpperCase();
128
+ }
129
+
130
+ return '?';
131
+ };
132
+
133
+ const handleSwitchOrganization = useCallback(() => {
134
+ window.location.href = orgSwitchPath;
135
+ }, [orgSwitchPath]);
136
+
137
+ return (
138
+ <div style={devUserMenuStyles.container as React.CSSProperties} ref={dropdownRef}>
139
+ {isLoggedIn ? (
140
+ <>
141
+ <button
142
+ style={devUserMenuStyles.avatar}
143
+ onClick={() => setShowDropdown(!showDropdown)}
144
+ title={userInfo?.nickname || '用户'}
145
+ >
146
+ {getAvatarContent()}
147
+ </button>
148
+ {showDropdown && (
149
+ <div style={devUserMenuStyles.dropdown as React.CSSProperties}>
150
+ <div style={devUserMenuStyles.dropdownHeader as React.CSSProperties}>
151
+ <span style={devUserMenuStyles.dropdownNickname}>
152
+ {userInfo?.nickname || '未知用户'}
153
+ </span>
154
+ <span style={devUserMenuStyles.dropdownPhone}>
155
+ {userInfo?.phone}
156
+ </span>
157
+ </div>
158
+ <div style={devUserMenuStyles.dropdownDivider} />
159
+ <button
160
+ style={devUserMenuStyles.dropdownItem as React.CSSProperties}
161
+ onClick={handleLogout}
162
+ >
163
+ 退出登录
164
+ </button>
165
+ </div>
166
+ )}
167
+ </>
168
+ ) : (
169
+ <button
170
+ style={devUserMenuStyles.loginButton}
171
+ onClick={() => setShowJwtGenerator(true)}
172
+ >
173
+ 本地签发 JWT
174
+ </button>
175
+ )}
176
+
177
+ {showJwtGenerator && (
178
+ <JwtGeneratorModal
179
+ onClose={() => setShowJwtGenerator(false)}
180
+ onSuccess={handleJwtGeneratorSuccess}
181
+ />
182
+ )}
183
+ </div>
184
+ );
185
+ }
186
+
187
+ interface LoginModalProps {
188
+ apiBasePath: string;
189
+ onClose: () => void;
190
+ onSuccess: (userInfo: UserInfo) => void;
191
+ }
192
+
193
+ function LoginModal({ apiBasePath, onClose, onSuccess }: LoginModalProps) {
194
+ const [phone, setPhone] = useState(() => {
195
+ return localStorage.getItem(STORAGE_KEYS.lastPhone) || '';
196
+ });
197
+ const [code, setCode] = useState('1024');
198
+ const [loading, setLoading] = useState(false);
199
+ const [error, setError] = useState<string | null>(null);
200
+ const [codeSent, setCodeSent] = useState(false);
201
+ const [countdown, setCountdown] = useState(0);
202
+
203
+ // 验证码倒计时
204
+ useEffect(() => {
205
+ if (countdown > 0) {
206
+ const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
207
+ return () => clearTimeout(timer);
208
+ }
209
+ }, [countdown]);
210
+
211
+ const sendCode = async () => {
212
+ if (!phone || phone.length !== 11) {
213
+ setError('请输入有效的手机号');
214
+ return;
215
+ }
216
+
217
+ setLoading(true);
218
+ setError(null);
219
+
220
+ try {
221
+ const response = await fetch(`${apiBasePath}/base/sms/send-code`, {
222
+ method: 'POST',
223
+ headers: { 'Content-Type': 'application/json' },
224
+ body: JSON.stringify({ phone, action: 'login' }),
225
+ });
226
+
227
+ const data = await response.json();
228
+
229
+ if (data.code === '000000') {
230
+ setCodeSent(true);
231
+ setCountdown(60);
232
+ // 保存手机号
233
+ localStorage.setItem(STORAGE_KEYS.lastPhone, phone);
234
+ } else {
235
+ setError(data.message || '发送验证码失败');
236
+ }
237
+ } catch (err) {
238
+ setError('网络错误,请稍后重试');
239
+ } finally {
240
+ setLoading(false);
241
+ }
242
+ };
243
+
244
+ const handleLogin = async (e: React.FormEvent) => {
245
+ e.preventDefault();
246
+
247
+ if (!phone || !code) {
248
+ setError('请填写手机号和验证码');
249
+ return;
250
+ }
251
+
252
+ setLoading(true);
253
+ setError(null);
254
+
255
+ try {
256
+ const response = await fetch(`${apiBasePath}/base/auth/login`, {
257
+ method: 'POST',
258
+ headers: { 'Content-Type': 'application/json' },
259
+ body: JSON.stringify({
260
+ method: 'phone_sms',
261
+ phone,
262
+ code,
263
+ }),
264
+ });
265
+
266
+ const data = await response.json();
267
+
268
+ if (data.code === '000000' && data.data) {
269
+ const loginData = data.data;
270
+
271
+ // 保存 token
272
+ localStorage.setItem(STORAGE_KEYS.accessToken, loginData.accessToken);
273
+ localStorage.setItem(STORAGE_KEYS.refreshToken, loginData.refreshToken);
274
+ localStorage.setItem(STORAGE_KEYS.lastPhone, phone);
275
+
276
+ // 构建用户信息
277
+ const userInfo: UserInfo = {
278
+ userId: loginData.userId,
279
+ nickname: loginData.nickname || phone,
280
+ avatar: loginData.avatar,
281
+ phone,
282
+ };
283
+ localStorage.setItem(STORAGE_KEYS.userInfo, JSON.stringify(userInfo));
284
+
285
+ onSuccess(userInfo);
286
+ } else {
287
+ setError(data.message || '登录失败');
288
+ }
289
+ } catch (err) {
290
+ setError('网络错误,请稍后重试');
291
+ } finally {
292
+ setLoading(false);
293
+ }
294
+ };
295
+
296
+ return (
297
+ <div style={modalStyles.overlay as React.CSSProperties} onClick={onClose}>
298
+ <div
299
+ style={modalStyles.modal}
300
+ onClick={(e) => e.stopPropagation()}
301
+ >
302
+ <div style={modalStyles.header}>
303
+ <h3 style={modalStyles.title}>开发登录</h3>
304
+ <button style={modalStyles.closeButton} onClick={onClose}>
305
+ ×
306
+ </button>
307
+ </div>
308
+
309
+ <form style={modalStyles.form} onSubmit={handleLogin}>
310
+ <div style={formGroupStyles.group}>
311
+ <label style={formGroupStyles.label}>手机号</label>
312
+ <input
313
+ type="tel"
314
+ style={formGroupStyles.input}
315
+ value={phone}
316
+ onChange={(e) => setPhone(e.target.value)}
317
+ placeholder="请输入手机号"
318
+ maxLength={11}
319
+ autoComplete="tel"
320
+ />
321
+ </div>
322
+
323
+ <div style={formGroupStyles.group}>
324
+ <label style={formGroupStyles.label}>验证码</label>
325
+ <div style={formGroupStyles.inputRow}>
326
+ <input
327
+ type="text"
328
+ style={{ ...formGroupStyles.input, flex: 1 }}
329
+ value={code}
330
+ onChange={(e) => setCode(e.target.value)}
331
+ placeholder="验证码"
332
+ maxLength={6}
333
+ />
334
+ <button
335
+ type="button"
336
+ style={{
337
+ ...formGroupStyles.sendButton,
338
+ ...(loading || countdown > 0 ? formGroupStyles.sendButtonDisabled : {}),
339
+ }}
340
+ onClick={sendCode}
341
+ disabled={loading || countdown > 0}
342
+ >
343
+ {countdown > 0 ? `${countdown}s` : codeSent ? '重新发送' : '发送验证码'}
344
+ </button>
345
+ </div>
346
+ </div>
347
+
348
+ {error && <div style={modalStyles.error}>{error}</div>}
349
+
350
+ <div style={modalStyles.tip}>
351
+ 💡 开发环境验证码默认为 <code style={modalStyles.tipCode}>1024</code>
352
+ </div>
353
+
354
+ <button
355
+ type="submit"
356
+ style={{
357
+ ...modalStyles.submitButton,
358
+ ...(loading ? modalStyles.submitButtonDisabled : {}),
359
+ }}
360
+ disabled={loading}
361
+ >
362
+ {loading ? '登录中...' : '登录'}
363
+ </button>
364
+ </form>
365
+ </div>
366
+ </div>
367
+ );
368
+ }
369
+
370
+ DevUserMenu.displayName = 'DevUserMenu';
371
+
372
+ export default DevUserMenu;
@@ -0,0 +1,121 @@
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
+ interface JwtPayload {
16
+ id: string;
17
+ sub: string;
18
+ aud: string;
19
+ nickname: string;
20
+ avatar?: string;
21
+ phone: string;
22
+ orgId?: string;
23
+ [key: string]: any;
24
+ }
25
+
26
+ /**
27
+ * Base64Url 编码(支持 Unicode 字符)
28
+ */
29
+ function base64UrlEncode(str: string): string {
30
+ // 使用 TextEncoder 将字符串转换为 UTF-8 字节数组
31
+ const encoder = new TextEncoder();
32
+ const bytes = encoder.encode(str);
33
+
34
+ // 将字节数组转换为二进制字符串
35
+ const binary = String.fromCharCode(...bytes);
36
+
37
+ // Base64 编码
38
+ const base64 = btoa(binary);
39
+
40
+ // 转换为 Base64Url 格式
41
+ return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
42
+ }
43
+
44
+ /**
45
+ * ArrayBuffer 转 Base64Url
46
+ * 注意:不能调用 base64UrlEncode,因为那个函数会用 TextEncoder 重新编码
47
+ */
48
+ function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
49
+ const bytes = new Uint8Array(buffer);
50
+ const binary = String.fromCharCode(...bytes);
51
+
52
+ // 直接进行 Base64 编码,不使用 TextEncoder
53
+ const base64 = btoa(binary);
54
+
55
+ // 转换为 Base64Url 格式
56
+ return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
57
+ }
58
+
59
+ /**
60
+ * 使用 HS256 算法签名 JWT
61
+ */
62
+ export async function signJwtHS256(
63
+ payload: JwtPayload,
64
+ secret: string,
65
+ expiresIn: number = 86400, // 默认 1 天(秒)
66
+ ): Promise<string> {
67
+ // 1. JWT Header
68
+ const header = {
69
+ alg: 'HS256',
70
+ typ: 'JWT',
71
+ };
72
+
73
+ // 2. 添加过期时间到 payload
74
+ const now = Math.floor(Date.now() / 1000);
75
+ const fullPayload = {
76
+ ...payload,
77
+ iat: now,
78
+ exp: now + expiresIn,
79
+ };
80
+
81
+ // 3. 编码 header 和 payload
82
+ const encodedHeader = base64UrlEncode(JSON.stringify(header));
83
+ const encodedPayload = base64UrlEncode(JSON.stringify(fullPayload));
84
+ const message = `${encodedHeader}.${encodedPayload}`;
85
+
86
+ // 4. 使用 Web Crypto API 进行 HMAC-SHA256 签名
87
+ const encoder = new TextEncoder();
88
+ const keyData = encoder.encode(secret);
89
+ const messageData = encoder.encode(message);
90
+
91
+ const cryptoKey = await crypto.subtle.importKey(
92
+ 'raw',
93
+ keyData,
94
+ { name: 'HMAC', hash: 'SHA-256' },
95
+ false,
96
+ ['sign'],
97
+ );
98
+
99
+ const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageData);
100
+
101
+ // 5. 组装完整的 JWT
102
+ const encodedSignature = arrayBufferToBase64Url(signature);
103
+ return `${message}.${encodedSignature}`;
104
+ }
105
+
106
+ /**
107
+ * 获取默认的 JWT Payload
108
+ * 格式与 portal-backend auth.service.ts 中签发的内部 JWT 保持一致
109
+ */
110
+ export function getDefaultJwtPayload(): JwtPayload {
111
+ const userId = '1000000001';
112
+ return {
113
+ id: userId,
114
+ sub: userId,
115
+ aud: 'internal',
116
+ nickname: '开发用户',
117
+ avatar: '',
118
+ phone: '13800138000',
119
+ orgId: '',
120
+ };
121
+ }
@@ -0,0 +1,229 @@
1
+ import type { CSSProperties } from 'react';
2
+
3
+ export const devUserMenuStyles: Record<string, CSSProperties> = {
4
+ container: {
5
+ position: 'relative',
6
+ display: 'flex',
7
+ alignItems: 'center',
8
+ gap: 10,
9
+ },
10
+ switchOrgButton: {
11
+ height: 32,
12
+ padding: '0 14px',
13
+ border: '1px solid #d8e0eb',
14
+ borderRadius: 16,
15
+ background: '#ffffff',
16
+ color: '#10233d',
17
+ fontSize: 13,
18
+ fontWeight: 500,
19
+ cursor: 'pointer',
20
+ transition: 'border-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease',
21
+ },
22
+ avatar: {
23
+ width: 32,
24
+ height: 32,
25
+ borderRadius: '50%',
26
+ background: '#1890ff',
27
+ color: '#fff',
28
+ border: 'none',
29
+ cursor: 'pointer',
30
+ display: 'flex',
31
+ alignItems: 'center',
32
+ justifyContent: 'center',
33
+ fontSize: 14,
34
+ fontWeight: 500,
35
+ transition: 'opacity 0.2s',
36
+ },
37
+ avatarImage: {
38
+ width: '100%',
39
+ height: '100%',
40
+ borderRadius: '50%',
41
+ objectFit: 'cover',
42
+ },
43
+ loginButton: {
44
+ padding: '6px 16px',
45
+ background: '#1890ff',
46
+ color: '#fff',
47
+ border: 'none',
48
+ borderRadius: 4,
49
+ cursor: 'pointer',
50
+ fontSize: 14,
51
+ transition: 'background 0.2s',
52
+ },
53
+ dropdown: {
54
+ position: 'absolute',
55
+ top: '100%',
56
+ right: 0,
57
+ marginTop: 8,
58
+ minWidth: 180,
59
+ background: '#fff',
60
+ borderRadius: 6,
61
+ boxShadow: '0 3px 12px rgba(0, 0, 0, 0.15)',
62
+ zIndex: 1000,
63
+ overflow: 'hidden',
64
+ },
65
+ dropdownHeader: {
66
+ padding: '12px 16px',
67
+ display: 'flex',
68
+ flexDirection: 'column',
69
+ gap: 4,
70
+ },
71
+ dropdownNickname: {
72
+ fontSize: 14,
73
+ fontWeight: 500,
74
+ color: '#333',
75
+ },
76
+ dropdownPhone: {
77
+ fontSize: 12,
78
+ color: '#999',
79
+ },
80
+ dropdownDivider: {
81
+ height: 1,
82
+ background: '#f0f0f0',
83
+ margin: '0 8px',
84
+ },
85
+ dropdownItem: {
86
+ display: 'block',
87
+ width: '100%',
88
+ padding: '10px 16px',
89
+ textAlign: 'left',
90
+ background: 'none',
91
+ border: 'none',
92
+ fontSize: 14,
93
+ color: '#333',
94
+ cursor: 'pointer',
95
+ transition: 'background 0.2s',
96
+ },
97
+ };
98
+
99
+ export const modalStyles: Record<string, CSSProperties> = {
100
+ overlay: {
101
+ position: 'fixed',
102
+ top: 0,
103
+ left: 0,
104
+ right: 0,
105
+ bottom: 0,
106
+ background: 'rgba(0, 0, 0, 0.45)',
107
+ display: 'flex',
108
+ alignItems: 'center',
109
+ justifyContent: 'center',
110
+ zIndex: 10000,
111
+ },
112
+ modal: {
113
+ width: 360,
114
+ background: '#fff',
115
+ borderRadius: 8,
116
+ boxShadow: '0 6px 24px rgba(0, 0, 0, 0.2)',
117
+ },
118
+ header: {
119
+ display: 'flex',
120
+ alignItems: 'center',
121
+ justifyContent: 'space-between',
122
+ padding: '16px 20px',
123
+ borderBottom: '1px solid #f0f0f0',
124
+ },
125
+ title: {
126
+ margin: 0,
127
+ fontSize: 16,
128
+ fontWeight: 500,
129
+ color: '#333',
130
+ },
131
+ closeButton: {
132
+ width: 24,
133
+ height: 24,
134
+ background: 'none',
135
+ border: 'none',
136
+ fontSize: 20,
137
+ color: '#999',
138
+ cursor: 'pointer',
139
+ display: 'flex',
140
+ alignItems: 'center',
141
+ justifyContent: 'center',
142
+ borderRadius: 4,
143
+ transition: 'background 0.2s',
144
+ },
145
+ form: {
146
+ padding: 20,
147
+ },
148
+ tip: {
149
+ margin: '12px 0',
150
+ padding: '8px 12px',
151
+ background: '#fffbe6',
152
+ border: '1px solid #ffe58f',
153
+ borderRadius: 4,
154
+ fontSize: 12,
155
+ color: '#8c6d1f',
156
+ },
157
+ tipCode: {
158
+ padding: '2px 6px',
159
+ background: 'rgba(0, 0, 0, 0.06)',
160
+ borderRadius: 3,
161
+ fontFamily: 'monospace',
162
+ },
163
+ submitButton: {
164
+ width: '100%',
165
+ padding: 10,
166
+ background: '#1890ff',
167
+ color: '#fff',
168
+ border: 'none',
169
+ borderRadius: 4,
170
+ fontSize: 14,
171
+ cursor: 'pointer',
172
+ transition: 'background 0.2s',
173
+ },
174
+ submitButtonDisabled: {
175
+ opacity: 0.65,
176
+ cursor: 'not-allowed',
177
+ },
178
+ error: {
179
+ marginBottom: 12,
180
+ padding: '8px 12px',
181
+ background: '#fff2f0',
182
+ border: '1px solid #ffccc7',
183
+ borderRadius: 4,
184
+ fontSize: 13,
185
+ color: '#cf1322',
186
+ },
187
+ };
188
+
189
+ export const formGroupStyles: Record<string, CSSProperties> = {
190
+ group: {
191
+ marginBottom: 16,
192
+ },
193
+ label: {
194
+ display: 'block',
195
+ marginBottom: 6,
196
+ fontSize: 14,
197
+ color: '#333',
198
+ },
199
+ input: {
200
+ width: '100%',
201
+ padding: '8px 12px',
202
+ border: '1px solid #d9d9d9',
203
+ borderRadius: 4,
204
+ fontSize: 14,
205
+ outline: 'none',
206
+ transition: 'border-color 0.2s',
207
+ boxSizing: 'border-box',
208
+ },
209
+ inputRow: {
210
+ display: 'flex',
211
+ gap: 8,
212
+ },
213
+ sendButton: {
214
+ flexShrink: 0,
215
+ padding: '8px 12px',
216
+ background: '#fff',
217
+ border: '1px solid #d9d9d9',
218
+ borderRadius: 4,
219
+ fontSize: 14,
220
+ color: '#333',
221
+ cursor: 'pointer',
222
+ whiteSpace: 'nowrap',
223
+ transition: 'border-color 0.2s, color 0.2s',
224
+ },
225
+ sendButtonDisabled: {
226
+ color: '#bfbfbf',
227
+ cursor: 'not-allowed',
228
+ },
229
+ };