@flun/html-template 4.0.10

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 (59) hide show
  1. package/.env +9 -0
  2. package/LICENSE +15 -0
  3. package/build.js +3 -0
  4. package/compile.js +349 -0
  5. package/copy-files.js +200 -0
  6. package/customize/account.js +726 -0
  7. package/customize/data.json +484 -0
  8. package/customize/functions.js +48 -0
  9. package/customize/hotReloadInjector.js +25 -0
  10. package/customize/routes.js +141 -0
  11. package/customize/users.json +44 -0
  12. package/customize/variables.js +70 -0
  13. package/dev-server.js +344 -0
  14. package/dev.js +4 -0
  15. package/f-CHANGELOG.md +4 -0
  16. package/f-README.md +485 -0
  17. package/index.d.ts +133 -0
  18. package/index.js +4 -0
  19. package/package.json +77 -0
  20. package/restoreDefaults.js +8 -0
  21. package/services/templateService.js +962 -0
  22. package/static/about.css +118 -0
  23. package/static/auth.js +27 -0
  24. package/static/constants.css +138 -0
  25. package/static/img/dark.png +0 -0
  26. package/static/img/favicon.ico +0 -0
  27. package/static/img/light.png +0 -0
  28. package/static/img/top.png +0 -0
  29. package/static/index.css +86 -0
  30. package/static/mouseOrTouch.js +156 -0
  31. package/static/public.css +288 -0
  32. package/static/script.css +318 -0
  33. package/static/script.js +392 -0
  34. package/static/styling.css +874 -0
  35. package/static/styling.js +933 -0
  36. package/static/themeImg.css +10 -0
  37. package/static/themeImg.js +19 -0
  38. package/static/themeModule.js +222 -0
  39. package/static/topImg.css +19 -0
  40. package/static/topImg.js +21 -0
  41. package/static/utils/browser13.js +270 -0
  42. package/static/utils/closebrackets.js +166 -0
  43. package/static/utils/css-lint.js +308 -0
  44. package/static/utils/custom-css-hint.js +876 -0
  45. package/static/utils/foldgutter.js +141 -0
  46. package/static/utils/match-highlighter.js +70 -0
  47. package/templates/about.html +236 -0
  48. package/templates/account/2fa.html +184 -0
  49. package/templates/account/forgot-password.html +226 -0
  50. package/templates/account/login.html +230 -0
  51. package/templates/account/profile.html +977 -0
  52. package/templates/account/register.html +224 -0
  53. package/templates/account/reset-password.html +205 -0
  54. package/templates/account/verify-email.html +163 -0
  55. package/templates/base.html +71 -0
  56. package/templates/footer-content.html +5 -0
  57. package/templates/index.html +140 -0
  58. package/templates/script.html +209 -0
  59. package/templates/test-include.html +11 -0
@@ -0,0 +1,977 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>个人资料 - 安全账户中心</title>
8
+
9
+ <!-- 引入公共主题变量与全局样式 -->
10
+ <link rel="stylesheet" href="/static/constants.css" /> <!-- 样式常量 -->
11
+ <link rel="stylesheet" href="/static/public.css" /> <!-- 公共样式 -->
12
+ <link rel="stylesheet" href="/static/themeImg.css" /> <!-- 主题图标 -->
13
+ <link rel="stylesheet" href="/static/topImg.css" /> <!-- 返回顶部图标 -->
14
+
15
+ <style>
16
+ body {
17
+ background: var(--body-bg);
18
+ background-color: var(--bg-color);
19
+ padding: 40px 20px;
20
+ }
21
+
22
+ .container {
23
+ max-width: 600px;
24
+ margin: 0 auto;
25
+ }
26
+
27
+ /* 卡片 */
28
+ .card {
29
+ background: var(--container-bg);
30
+ border-radius: 12px;
31
+ box-shadow: 0 2px 10px var(--content-shadow);
32
+ padding: 30px;
33
+ margin-bottom: 20px;
34
+ transition: background 0.5s ease, box-shadow 0.5s ease;
35
+ }
36
+
37
+ .card h2 {
38
+ color: var(--h1-color);
39
+ margin-bottom: 20px;
40
+ font-weight: 600;
41
+ font-size: 24px;
42
+ border-bottom: 1px solid var(--content-border);
43
+ padding-bottom: 10px;
44
+ display: flex;
45
+ align-items: center;
46
+ flex-wrap: wrap;
47
+ gap: 12px;
48
+ }
49
+
50
+ .card h2 .title-text {
51
+ display: inline-flex;
52
+ align-items: center;
53
+ gap: 8px;
54
+ }
55
+
56
+ .twofa-checkmark {
57
+ color: #2b9348;
58
+ font-size: 1.3rem;
59
+ font-weight: normal;
60
+ }
61
+
62
+ .card h2 .btn {
63
+ margin-right: 0;
64
+ padding: 6px 16px;
65
+ font-size: 13px;
66
+ }
67
+
68
+ /* 信息行 */
69
+ .info-row {
70
+ display: flex;
71
+ padding: 12px 0;
72
+ border-bottom: 1px solid var(--content-border);
73
+ }
74
+
75
+ .info-label {
76
+ width: 120px;
77
+ color: var(--text-color);
78
+ opacity: 0.75;
79
+ font-weight: 500;
80
+ }
81
+
82
+ .info-value {
83
+ flex: 1;
84
+ color: var(--text-color);
85
+ }
86
+
87
+ /* 通用按钮 */
88
+ .btn {
89
+ padding: 10px 20px;
90
+ background: var(--btn-bg);
91
+ color: var(--text-color);
92
+ border: none;
93
+ border-radius: 6px;
94
+ font-size: 14px;
95
+ font-weight: 500;
96
+ cursor: pointer;
97
+ transition: background 0.2s, opacity 0.2s;
98
+ margin-right: 10px;
99
+ }
100
+
101
+ .btn:hover {
102
+ background: var(--btn-hover);
103
+ }
104
+
105
+ /* 功能按钮颜色保留原语义(不随主题变化) */
106
+ .btn-danger {
107
+ background: #e53e3e;
108
+ }
109
+
110
+ .btn-danger:hover {
111
+ background: #c53030;
112
+ }
113
+
114
+ .btn-warning {
115
+ background: #ed8936;
116
+ }
117
+
118
+ .btn-warning:hover {
119
+ background: #dd6b20;
120
+ }
121
+
122
+ .btn-secondary {
123
+ background: #718096;
124
+ }
125
+
126
+ .btn-secondary:hover {
127
+ background: #4a5568;
128
+ }
129
+
130
+ .btn-outline {
131
+ background: transparent;
132
+ border: 1px solid var(--link-color);
133
+ color: var(--link-color);
134
+ }
135
+
136
+ .btn-outline:hover {
137
+ background: var(--link-color);
138
+ color: var(--text-color);
139
+ }
140
+
141
+ /* 禁用状态统一 */
142
+ .btn:disabled,
143
+ .btn-danger:disabled,
144
+ .btn-secondary:disabled,
145
+ .btn-outline:disabled,
146
+ .btn-warning:disabled {
147
+ background: #b0b0b0 !important;
148
+ border-color: #b0b0b0 !important;
149
+ color: white !important;
150
+ cursor: not-allowed;
151
+ opacity: 0.65;
152
+ }
153
+
154
+ /* 备用码展示 */
155
+ .backup-codes {
156
+ background: var(--li-bg);
157
+ border: 1px dashed var(--content-border);
158
+ border-radius: 8px;
159
+ padding: 15px;
160
+ margin-top: 15px;
161
+ font-family: monospace;
162
+ font-size: 16px;
163
+ word-break: break-word;
164
+ }
165
+
166
+ .code-item {
167
+ display: inline-block;
168
+ background: var(--container-bg);
169
+ padding: 5px 10px;
170
+ margin: 5px;
171
+ border-radius: 4px;
172
+ font-family: 'SF Mono', monospace;
173
+ font-weight: 500;
174
+ color: var(--text-color);
175
+ }
176
+
177
+ /* 消息提示(语义色保留) */
178
+ .message {
179
+ margin-top: 15px;
180
+ padding: 10px;
181
+ border-radius: 6px;
182
+ }
183
+
184
+ .success {
185
+ background: #c6f6d5;
186
+ color: #22543d;
187
+ }
188
+
189
+ .error {
190
+ background: #fed7d7;
191
+ color: #742a2a;
192
+ }
193
+
194
+ .info {
195
+ background: #bee3f8;
196
+ color: #2c5282;
197
+ }
198
+
199
+ .warning {
200
+ background: #feebc8;
201
+ color: #7b341e;
202
+ }
203
+
204
+ /* 表单 */
205
+ .form-group {
206
+ margin-bottom: 15px;
207
+ }
208
+
209
+ .form-group input {
210
+ width: 100%;
211
+ padding: 10px 12px;
212
+ border: 1px solid var(--content-border);
213
+ border-radius: 6px;
214
+ font-size: 14px;
215
+ background: var(--li-bg);
216
+ color: var(--text-color);
217
+ transition: border-color 0.2s, background 0.5s ease, color 0.5s ease;
218
+ }
219
+
220
+ .form-group input:focus {
221
+ border-color: var(--link-color);
222
+ outline: none;
223
+ }
224
+
225
+ /* 操作按钮组 */
226
+ .action-buttons {
227
+ display: flex;
228
+ gap: 10px;
229
+ margin-top: 20px;
230
+ flex-wrap: wrap;
231
+ }
232
+
233
+ .backup-actions {
234
+ margin-top: 15px;
235
+ display: flex;
236
+ gap: 12px;
237
+ justify-content: flex-start;
238
+ }
239
+
240
+ .backup-actions .btn {
241
+ margin-right: 0;
242
+ }
243
+
244
+ /* 二维码 */
245
+ .qr-placeholder {
246
+ text-align: center;
247
+ margin: 15px 0;
248
+ }
249
+
250
+ .qr-placeholder img {
251
+ max-width: 200px;
252
+ border: 1px solid var(--content-border);
253
+ border-radius: 8px;
254
+ padding: 8px;
255
+ background: var(--container-bg);
256
+ }
257
+
258
+ /* 其他辅助样式 */
259
+ .flex-buttons {
260
+ display: flex;
261
+ gap: 10px;
262
+ margin-top: 8px;
263
+ }
264
+
265
+ .webauthn-panel {
266
+ margin-top: 16px;
267
+ }
268
+
269
+ .device-list {
270
+ list-style: none;
271
+ padding: 0;
272
+ margin-bottom: 16px;
273
+ }
274
+
275
+ .device-item {
276
+ display: flex;
277
+ justify-content: space-between;
278
+ align-items: center;
279
+ padding: 10px 0;
280
+ border-bottom: 1px solid var(--content-border);
281
+ }
282
+
283
+ .device-name {
284
+ display: flex;
285
+ align-items: center;
286
+ gap: 8px;
287
+ }
288
+
289
+ .btn-small {
290
+ padding: 4px 12px;
291
+ font-size: 12px;
292
+ }
293
+
294
+ [hidden] {
295
+ display: none !important;
296
+ }
297
+
298
+ #printBackupContainer {
299
+ display: none;
300
+ }
301
+
302
+ @media print {
303
+ body> :not(#printBackupContainer) {
304
+ display: none !important;
305
+ }
306
+
307
+ #printBackupContainer {
308
+ position: static !important;
309
+ display: block !important;
310
+ margin: 20px;
311
+ }
312
+
313
+ .print-backup {
314
+ font-family: monospace;
315
+ max-width: 700px;
316
+ margin: 0 auto;
317
+ text-align: center;
318
+ }
319
+
320
+ .codes-grid {
321
+ display: flex;
322
+ flex-wrap: wrap;
323
+ justify-content: center;
324
+ gap: 12px;
325
+ margin: 20px 0;
326
+ }
327
+
328
+ .code-card {
329
+ background: #f0f0f0;
330
+ border: 1px solid #ccc;
331
+ padding: 8px 12px;
332
+ font-size: 16px;
333
+ font-weight: bold;
334
+ font-family: monospace;
335
+ border-radius: 6px;
336
+ }
337
+
338
+ .warning {
339
+ color: #d9534f;
340
+ margin-top: 20px;
341
+ }
342
+
343
+ .footer {
344
+ margin-top: 20px;
345
+ font-size: 12px;
346
+ color: #666;
347
+ }
348
+ }
349
+ </style>
350
+ </head>
351
+
352
+ <body>
353
+ <div class="container">
354
+ <!-- 资料卡片 -->
355
+ <div class="card">
356
+ <h2>个人资料</h2>
357
+ <div class="info-row"><span class="info-label">用户名</span>
358
+ <span class="info-value" id="username">加载中...</span>
359
+ </div>
360
+ <div class="info-row">
361
+ <span class="info-label">邮箱</span>
362
+ <span class="info-value">
363
+ <span id="email">加载中...</span>
364
+ <span id="emailVerifiedBadge" title="邮箱已验证">✅</span>
365
+ </span>
366
+ </div>
367
+ <div class="info-row"><span class="info-label">注册时间</span>
368
+ <span class="info-value" id="createdAt">-</span>
369
+ </div>
370
+ <div class="action-buttons">
371
+ <button class="btn btn-outline" id="editProfileBtn">编辑资料</button>
372
+ <button class="btn btn-outline" id="showChangePasswordBtn">修改密码</button>
373
+ <button class="btn btn-secondary" id="logoutBtn">退出登录</button>
374
+ </div>
375
+ </div>
376
+
377
+ <!-- 修改资料卡片 -->
378
+ <div class="card" id="editProfileCard" hidden>
379
+ <h2>修改个人资料</h2>
380
+ <form onsubmit="return false;">
381
+ <div class="form-group">
382
+ <input type="text" id="newUsername" placeholder="新用户名" autocomplete="off" maxlength="50">
383
+ </div>
384
+ <div class="form-group">
385
+ <input type="email" id="newEmail" placeholder="新邮箱" autocomplete="off" maxlength="100">
386
+ </div>
387
+ <div class="form-group">
388
+ <input type="password" id="profileCurrentPassword" placeholder="请输入密码" maxlength="72"
389
+ autocomplete="off">
390
+ </div>
391
+ <div style="display: flex; gap: 10px;">
392
+ <button class="btn" id="updateProfileBtn">保存修改</button>
393
+ <button class="btn btn-secondary" id="cancelEditBtn">取消</button>
394
+ </div>
395
+ <div id="updateProfileMessage" class="message"></div>
396
+ </form>
397
+ </div>
398
+
399
+ <!-- 修改密码卡片 -->
400
+ <div class="card" id="changePasswordCard" hidden>
401
+ <h2>修改密码</h2>
402
+ <form onsubmit="return false;">
403
+ <div class="form-group">
404
+ <input type="password" id="currentPassword" placeholder="当前密码" maxlength="72" autocomplete="off">
405
+ </div>
406
+ <div class="form-group">
407
+ <input type="password" id="newPassword" placeholder="新密码(至少6位)" maxlength="72" autocomplete="off">
408
+ </div>
409
+ <div class="form-group">
410
+ <input type="password" id="confirmPassword" placeholder="确认新密码" maxlength="72" autocomplete="off">
411
+ </div>
412
+ <div style="display: flex; gap: 10px;">
413
+ <button class="btn" id="changePasswordBtn">确认提交</button>
414
+ <button class="btn btn-secondary" id="cancelChangePasswordBtn">取消</button>
415
+ </div>
416
+ <div id="changePasswordMessage" class="message"></div>
417
+ </form>
418
+ </div>
419
+
420
+ <!-- 双因素认证区域 -->
421
+ <div class="card" id="twofaCard">
422
+ <h2>
423
+ <span class="title-text">
424
+ 双因素认证 (2FA) <span id="twofaCheckmark" class="twofa-checkmark"></span>
425
+ </span>
426
+ <button class="btn" id="toggle2faBtn">启用2FA</button>
427
+ </h2>
428
+
429
+ <div id="enable2faPanel" hidden style="margin-top: 16px;">
430
+ <form onsubmit="return false;">
431
+ <p>请使用 Google Authenticator 或类似应用扫描二维码添加认证:</p>
432
+ <div class="qr-placeholder">
433
+ <img id="qrCodeImg" alt="2FA QR Code">
434
+ </div>
435
+ <p>或手动输入密钥:<code id="secretCode">-</code></p>
436
+ <input type="text" id="verifyToken" placeholder="输入6位验证码" maxlength="6"
437
+ style="width: 100%; padding: 8px; margin: 12px 0;" autocomplete="off">
438
+ <div class="flex-buttons">
439
+ <button class="btn" id="confirm2faBtn">验证并启用</button>
440
+ <button class="btn btn-secondary" id="cancelEnable2faBtn">取消</button>
441
+ </div>
442
+ <div id="confirmMessage" class="message" hidden></div>
443
+ </form>
444
+ </div>
445
+
446
+ <div id="manage2faPanel" hidden style="margin-top: 16px;">
447
+ <button class="btn btn-warning" id="showBackupBtn">生成新的备份码</button>
448
+ <div id="backupManageMessage" class="message" hidden></div>
449
+ <div id="backupCodesPanel" hidden style="margin-top: 15px;">
450
+ <p class="info">备用码(仅显示一次,请妥善保存):</p>
451
+ <div id="backupCodesList" class="backup-codes"></div>
452
+ <div class="backup-actions">
453
+ <button class="btn btn-outline" id="saveBackupFileBtn">💾 保存到文件</button>
454
+ <button class="btn btn-outline" id="printBackupCodesBtn">🖨️ 打印备份码</button>
455
+ </div>
456
+ </div>
457
+ </div>
458
+ </div>
459
+
460
+ <!-- 硬件验证卡片 -->
461
+ <div class="card" id="webauthnCard">
462
+ <h2>
463
+ <span class="title-text">
464
+ 硬件验证(指纹/人脸等) <span id="webauthnStatusIcon"></span>
465
+ </span>
466
+ <button class="btn" id="toggleWebAuthnBtn">启用硬件验证</button>
467
+ </h2>
468
+
469
+ <div id="webauthnMessage" class="message" hidden></div>
470
+ <div id="manageWebAuthnPanel" class="webauthn-panel" hidden>
471
+ <p id="noDeviceMsg" style="color: #666;">暂无硬件设备,点击上方「添加新设备」按钮添加</p>
472
+ <ul id="webauthnDeviceList" class="device-list"></ul>
473
+ <li id="deviceItemExample" class="device-item" style="display: none;">
474
+ <span class="device-name"></span>
475
+ <button class="btn btn-secondary btn-small">删除</button>
476
+ </li>
477
+ <div style="display: flex; gap: 12px; margin-top: 12px; flex-wrap: wrap;">
478
+ <button class="btn btn-outline" id="manageAddDeviceBtn">➕ 添加新设备</button>
479
+ </div>
480
+ <div id="webauthnManageMessage" class="message" hidden></div>
481
+ </div>
482
+ </div>
483
+
484
+ <!-- 注销账户卡片 -->
485
+ <div class="card">
486
+ <h2>
487
+ <span class="title-text">
488
+ 注销账户 <span style="color:#e53e3e;font-size: 1rem;">(警告:此操作数据无法恢复)</span>
489
+ </span>
490
+ <button class="btn btn-danger" id="showDeleteAccountBtn">注销</button>
491
+ </h2>
492
+ <div id="deleteAccountForm" hidden style="margin-top:15px;">
493
+ <form onsubmit="return false;">
494
+ <div class="form-group">
495
+ <input type="password" id="deleteAccountPassword" placeholder="输入当前密码以确认" maxlength="72"
496
+ autocomplete="off">
497
+ </div>
498
+ <div style="display: flex; gap: 10px;">
499
+ <button class="btn btn-danger" id="deleteAccountBtn">永久注销账户</button>
500
+ <button class="btn btn-secondary" id="cancelDeleteBtn">取消</button>
501
+ </div>
502
+ <div id="deleteAccountMessage" class="message"></div>
503
+ </form>
504
+ </div>
505
+ </div>
506
+ </div>
507
+ <div id="printBackupContainer"></div>
508
+
509
+ <!-- 公共逻辑 -->
510
+ <script src="/static/themeModule.js" defer></script><!-- 引入主题自适应模块 -->
511
+ <script src="/static/mouseOrTouch.js" defer></script><!-- 引入鼠标或触摸操作 -->
512
+ <script src="/static/themeImg.js" defer></script> <!-- 引入主题图标模块 -->
513
+ <script src="/static/topImg.js" defer></script> <!-- 引入返回顶部图标模块 -->
514
+ <script src="/static/utils/browser13.js"></script>
515
+ <script>
516
+ // ==================== 全局变量 ====================
517
+ let currentUser = null, targetEmail = null, emailCheckInterval = null, isEmailPollingActive = false,
518
+ latestBackupCodes = [], currentWebAuthnCredentials = [];
519
+
520
+ // ==================== DOM 元素获取 ====================
521
+ const [
522
+ usernameEl, emailEl, createdAtEl,
523
+ editProfileCard, newUsernameInput, newEmailInput, profileCurrentPasswordInput,
524
+ updateProfileMessage, editProfileBtn, cancelEditBtn, updateProfileBtn,
525
+ changePasswordCard, currentPasswordInput, newPasswordInput, confirmPasswordInput,
526
+ changePasswordMessage, showChangePasswordBtn, cancelChangePasswordBtn, changePasswordBtn,
527
+ twofaCheckmark, toggle2faBtn, enable2faPanel, manage2faPanel, printBackupContainer,
528
+ qrCodeImg, secretCode, verifyTokenInput, confirm2faBtn, confirmMessage, cancelEnable2faBtn,
529
+ showBackupBtn, backupCodesPanel, backupCodesList, saveBackupFileBtn, printBackupCodesBtn,
530
+ showDeleteAccountBtn, deleteAccountForm, deleteAccountPassword, deleteAccountMessage, cancelDeleteBtn,
531
+ deleteAccountBtn, logoutBtn,
532
+ webauthnStatusIcon, toggleWebAuthnBtn, manageWebAuthnPanel,
533
+ webauthnMessage, webauthnManageMessage, manageAddDeviceBtn,
534
+ noDeviceMsg, webauthnDeviceList, deviceItemExample,
535
+ backupManageMessage, emailVerifiedBadge
536
+ ] = [
537
+ 'username', 'email', 'createdAt',
538
+ 'editProfileCard', 'newUsername', 'newEmail', 'profileCurrentPassword',
539
+ 'updateProfileMessage', 'editProfileBtn', 'cancelEditBtn', 'updateProfileBtn',
540
+ 'changePasswordCard', 'currentPassword', 'newPassword', 'confirmPassword',
541
+ 'changePasswordMessage', 'showChangePasswordBtn', 'cancelChangePasswordBtn', 'changePasswordBtn',
542
+ 'twofaCheckmark', 'toggle2faBtn', 'enable2faPanel', 'manage2faPanel', 'printBackupContainer',
543
+ 'qrCodeImg', 'secretCode', 'verifyToken', 'confirm2faBtn', 'confirmMessage', 'cancelEnable2faBtn',
544
+ 'showBackupBtn', 'backupCodesPanel', 'backupCodesList', 'saveBackupFileBtn', 'printBackupCodesBtn',
545
+ 'showDeleteAccountBtn', 'deleteAccountForm', 'deleteAccountPassword', 'deleteAccountMessage',
546
+ 'cancelDeleteBtn', 'deleteAccountBtn', 'logoutBtn',
547
+ 'webauthnStatusIcon', 'toggleWebAuthnBtn', 'manageWebAuthnPanel',
548
+ 'webauthnMessage', 'webauthnManageMessage', 'manageAddDeviceBtn',
549
+ 'noDeviceMsg', 'webauthnDeviceList', 'deviceItemExample',
550
+ 'backupManageMessage', 'emailVerifiedBadge'
551
+ ].map(id => document.getElementById(id)),
552
+ // ==================== 通用辅助函数 ====================
553
+ showMessage = (element, type, text) => {
554
+ element.className = `message ${type}`, element.textContent = text, element.hidden = false;
555
+ }, hideMessage = element => element.hidden = true,
556
+ requestApi = async (url, options, msgElement = null, btnElement = null, suppressSuccessMsg = false) => {
557
+ if (btnElement && typeof btnElement === 'boolean') suppressSuccessMsg = btnElement, btnElement = null;
558
+ if (btnElement) btnElement.disabled = true;
559
+ try {
560
+ const res = await fetch(url, options), data = await res.json(), { message: msg } = data;
561
+ if (res.ok) {
562
+ if (msgElement && !suppressSuccessMsg && msg) showMessage(msgElement, 'success', msg);
563
+ return { ok: true, data };
564
+ } else {
565
+ if (msgElement) showMessage(msgElement, 'error', msg);
566
+ if (btnElement) btnElement.disabled = false;
567
+ return { ok: false, data };
568
+ }
569
+ } catch (err) {
570
+ if (msgElement) showMessage(msgElement, 'error', '网络错误,请稍后重试');
571
+ if (btnElement) btnElement.disabled = false;
572
+ return { ok: false, error: err };
573
+ }
574
+ },
575
+ stopEmailPolling = () => {
576
+ clearInterval(emailCheckInterval), emailCheckInterval = null, isEmailPollingActive = false;
577
+ },
578
+ startEmailCheckPolling = (newEmail, timeoutCallback) => {
579
+ targetEmail = newEmail, isEmailPollingActive = true;
580
+ emailCheckInterval = setInterval(async () => {
581
+ try {
582
+ const res = await fetch('/api/user');
583
+ if (res.ok) {
584
+ const user = await res.json();
585
+ if (user.email === targetEmail && user.emailVerified === true) stopEmailPolling(), location.reload();
586
+ }
587
+ } catch (e) { console.error(e); }
588
+ }, 2000);
589
+ setTimeout(() => {
590
+ if (isEmailPollingActive) stopEmailPolling(), timeoutCallback?.();
591
+ }, 900000);
592
+ },
593
+ resetEditProfileForm = () => {
594
+ profileCurrentPasswordInput.value = '', newUsernameInput.value = currentUser?.username;
595
+ newEmailInput.value = currentUser?.email;
596
+ },
597
+ resetPasswordForm = () => {
598
+ currentPasswordInput.value = '', newPasswordInput.value = '', confirmPasswordInput.value = '';
599
+ },
600
+ saveBackupCodesToFile = (backupCodes, prefix = '我的账户备份码') => {
601
+ if (backupCodes?.length === 0) return alert('没有可保存的备份码');
602
+ const now = new Date(), time = now.toLocaleString(), fileTime = time.replace(/[\/:]/g, '-').replace(/ /g, '_');
603
+ let content = `===== ${prefix} =====\n生成时间: ${time}\n\n`;
604
+ content += `共计 ${backupCodes.length} 个备用码,请妥善保管,每个码仅可使用一次;\n\n`;
605
+ backupCodes.forEach((code, idx) => content += `备用码${idx + 1}: ${code}\n`);
606
+ content += `\n重要提示:请将备用码保存在安全的位置,切勿泄露给他人;\n`;
607
+
608
+ const link = document.createElement('a'), blob = new Blob([content], { type: 'text/plain;charset=utf-8' }),
609
+ url = URL.createObjectURL(blob), filename = `backup_codes_${fileTime}.txt`;
610
+
611
+ saveBackupFileBtn.disabled = true, link.href = url, link.download = filename, document.body.append(link);
612
+ link.click(), link.remove(), URL.revokeObjectURL(url);
613
+ },
614
+ printBackupCodes = (backupCodes, title = '双因素认证备用码') => {
615
+ if (!backupCodes || backupCodes.length === 0) return alert('没有可打印的备份码');
616
+ printBackupContainer.innerHTML = `
617
+ <div class="print-backup">
618
+ <h1>🔐 ${title}</h1>
619
+ <p class="timestamp">生成时间:${new Date().toLocaleString()}</p>
620
+ <div class="codes-grid">
621
+ ${backupCodes.map(code => `<div class="code-card">${code}</div>`).join('')}
622
+ </div>
623
+ <div class="warning">⚠️ 每个备用码仅能使用一次,请妥善保管;</div>
624
+ <div class="footer">共计 ${backupCodes.length} 个备用码</div>
625
+ </div>
626
+ `;
627
+ printBackupCodesBtn.disabled = true, window.print(), setTimeout(() => printBackupContainer.innerHTML = '', 500);
628
+ },
629
+ syncBackupButtonState = () => {
630
+ const isPanelVisible = !backupCodesPanel.hidden;
631
+ if (isPanelVisible) {
632
+ showBackupBtn.textContent = '关闭备份码显示', showBackupBtn.classList.remove('btn-warning');
633
+ showBackupBtn.classList.add('btn-secondary'), showBackupBtn.disabled = false;
634
+ } else {
635
+ showBackupBtn.textContent = '生成新的备份码', showBackupBtn.classList.remove('btn-secondary');
636
+ showBackupBtn.classList.add('btn-warning');
637
+ }
638
+ },
639
+ renderBackupCodes = codes => {
640
+ if (codes?.length === 0) return backupCodesPanel.hidden = true, latestBackupCodes = [], syncBackupButtonState();
641
+ backupCodesList.innerHTML = '', latestBackupCodes = [...codes];
642
+ codes.forEach(code => {
643
+ const span = document.createElement('span');
644
+ span.className = 'code-item', span.textContent = code, backupCodesList.append(span);
645
+ });
646
+ backupCodesPanel.hidden = false, syncBackupButtonState();
647
+ },
648
+ hideBackupPanel = () => {
649
+ backupCodesPanel.hidden = true, backupCodesList.innerHTML = '', latestBackupCodes = [], syncBackupButtonState();
650
+ },
651
+ // ==================== 个人资料修改模块 ====================
652
+ closeEditProfileCard = () => {
653
+ if (!editProfileCard.hidden) {
654
+ if (isEmailPollingActive) stopEmailPolling();
655
+ editProfileCard.hidden = true, updateProfileBtn.disabled = false, editProfileBtn.disabled = false;
656
+ updateProfileBtn.textContent = '保存修改', hideMessage(updateProfileMessage), resetEditProfileForm();
657
+ }
658
+ },
659
+ openEditProfileCard = () => {
660
+ closeChangePasswordCard(), editProfileCard.hidden = false;
661
+ if (currentUser) newUsernameInput.value = currentUser.username, newEmailInput.value = currentUser.email;
662
+ if (isEmailPollingActive) stopEmailPolling();
663
+ profileCurrentPasswordInput.value = '', editProfileBtn.disabled = true, setTimeout(() => newUsernameInput.focus(), 50);
664
+ updateProfileBtn.disabled = false, updateProfileBtn.textContent = '保存修改', hideMessage(updateProfileMessage);
665
+ };
666
+
667
+ editProfileBtn.addEventListener('click', openEditProfileCard);
668
+ cancelEditBtn.addEventListener('click', () => { closeEditProfileCard(), editProfileBtn.focus() });
669
+ updateProfileBtn.addEventListener('click', async () => {
670
+ if (isEmailPollingActive) return showMessage(updateProfileMessage, 'info', '邮件验证中,请稍后...');
671
+
672
+ const [username, email, pwd] = [newUsernameInput, newEmailInput, profileCurrentPasswordInput]
673
+ .map(input => input.value.trim());
674
+
675
+ if (!username || !email || !pwd) return showMessage(updateProfileMessage, 'error', '请填写所有字段');
676
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return showMessage(updateProfileMessage, 'error', '邮箱格式不正确');
677
+ const emailChanged = currentUser && email !== currentUser.email;
678
+ let skipFinallyRestore = false;
679
+ try {
680
+ const { ok } = await requestApi('/api/update-profile', {
681
+ method: 'POST',
682
+ headers: { 'Content-Type': 'application/json' },
683
+ body: JSON.stringify({ username, email, currentPassword: pwd })
684
+ }, updateProfileMessage, updateProfileBtn);
685
+ if (ok) {
686
+ if (emailChanged) {
687
+ if (currentUser) currentUser.emailVerified = false, emailVerifiedBadge.textContent = '';
688
+ skipFinallyRestore = true, updateProfileBtn.textContent = '等待邮箱验证...';
689
+ startEmailCheckPolling(email, () => {
690
+ updateProfileBtn.textContent = '保存修改';
691
+ showMessage(updateProfileMessage, 'warning', '邮件验证超时,请稍后重新尝试;');
692
+ });
693
+ }
694
+ else closeEditProfileCard(), loadUser();
695
+ }
696
+ }
697
+ finally {
698
+ if (!skipFinallyRestore) updateProfileBtn.disabled = false;
699
+ }
700
+ });
701
+
702
+ // ==================== 密码修改模块 ====================
703
+ const closeChangePasswordCard = () => {
704
+ if (!changePasswordCard.hidden) {
705
+ changePasswordCard.hidden = true, resetPasswordForm(), showChangePasswordBtn.disabled = false;
706
+ hideMessage(changePasswordMessage);
707
+ }
708
+ },
709
+ openChangePasswordCard = () => {
710
+ closeEditProfileCard(), changePasswordCard.hidden = false, showChangePasswordBtn.disabled = true;
711
+ resetPasswordForm(), setTimeout(() => currentPasswordInput.focus(), 50), hideMessage(changePasswordMessage);
712
+ };
713
+
714
+ showChangePasswordBtn.addEventListener('click', openChangePasswordCard);
715
+ cancelChangePasswordBtn.addEventListener('click', () => {
716
+ closeChangePasswordCard(), showChangePasswordBtn.focus();
717
+ });
718
+ changePasswordBtn.addEventListener('click', async () => {
719
+ const [current, newPwd, confirm] = [currentPasswordInput, newPasswordInput, confirmPasswordInput]
720
+ .map(input => input.value.trim());
721
+
722
+ if (!current || !newPwd || !confirm) return showMessage(changePasswordMessage, 'error', '请填写所有字段 ');
723
+ if (newPwd.length < 6) return showMessage(changePasswordMessage, 'error', '新密码至少6位');
724
+ if (newPwd !== confirm) return showMessage(changePasswordMessage, 'error', '两次密码不一致');
725
+ const { ok } = await requestApi('/api/change-password', {
726
+ method: 'POST',
727
+ headers: { 'Content-Type': 'application/json' },
728
+ body: JSON.stringify({ currentPassword: current, newPassword: newPwd })
729
+ }, changePasswordMessage, changePasswordBtn);
730
+ if (ok) setTimeout(() => (closeChangePasswordCard(), window.location.href = '/login'), 1000);
731
+ });
732
+
733
+ // ==================== 2FA 功能模块 ====================
734
+ const showEnable2faPanel = async () => {
735
+ verifyTokenInput.value = '', hideMessage(confirmMessage), qrCodeImg.src = '';
736
+ secretCode.textContent = '加载中...', enable2faPanel.hidden = false, confirm2faBtn.disabled = false;
737
+ const { ok, data } = await requestApi('/api/enable-2fa', { method: 'POST' }, confirmMessage, toggle2faBtn),
738
+ { qrCode, secret } = data;
739
+ if (ok && qrCode && secret) qrCodeImg.src = qrCode, secretCode.textContent = secret, verifyTokenInput.focus();
740
+ else enable2faPanel.hidden = true;
741
+ },
742
+ hideEnable2faPanel = () => {
743
+ toggle2faBtn.disabled = false, enable2faPanel.hidden = true, verifyTokenInput.value = '', hideMessage(confirmMessage);
744
+ },
745
+ refreshTwofaUI = () => {
746
+ toggle2faBtn.disabled = false;
747
+ const isEnabled = currentUser?.twoFactorEnabled;
748
+ twofaCheckmark.innerHTML = isEnabled ? '✅' : '', twofaCheckmark.title = isEnabled ? '已启用双因素认证' : '';
749
+ toggle2faBtn.textContent = isEnabled ? '关闭2FA' : '启用2FA', toggle2faBtn.classList.toggle('btn-danger', isEnabled);
750
+ if (isEnabled) manage2faPanel.hidden = false, hideBackupPanel();
751
+ else manage2faPanel.hidden = true, hideMessage(confirmMessage);
752
+ };
753
+
754
+ toggle2faBtn.addEventListener('click', async () => {
755
+ const isEnabled = currentUser?.twoFactorEnabled;
756
+ if (!isEnabled) {
757
+ if (enable2faPanel.hidden) await showEnable2faPanel();
758
+ else hideEnable2faPanel();
759
+ } else {
760
+ if (!confirm('确定关闭双因素认证吗?关闭后账户安全性将降低。')) return;
761
+ const { ok, data } = await requestApi('/api/disable-2fa', { method: 'POST' }, null, toggle2faBtn);
762
+ if (ok) await loadUser();
763
+ else alert(data.message);
764
+ }
765
+ });
766
+ cancelEnable2faBtn.addEventListener('click', hideEnable2faPanel);
767
+ confirm2faBtn.addEventListener('click', async () => {
768
+ const token = verifyTokenInput.value.trim();
769
+ if (!token) return showMessage(confirmMessage, 'error', '请输入6位验证码');
770
+ const { ok, data } = await requestApi('/api/confirm-2fa', {
771
+ method: 'POST',
772
+ headers: { 'Content-Type': 'application/json' },
773
+ body: JSON.stringify({ token })
774
+ }, confirmMessage, confirm2faBtn);
775
+ if (ok && data.backupCodes) {
776
+ await loadUser(), renderBackupCodes(data.backupCodes), hideEnable2faPanel();
777
+ setTimeout(() => hideMessage(confirmMessage), 3000);
778
+ } else verifyTokenInput.value = '', verifyTokenInput.focus();
779
+ });
780
+ showBackupBtn.addEventListener('click', async () => {
781
+ saveBackupFileBtn.disabled = false, printBackupCodesBtn.disabled = false;
782
+ showBackupBtn.disabled = true, showBackupBtn.textContent = '加载中...', hideMessage(backupManageMessage);
783
+ try {
784
+ const isPanelVisible = !backupCodesPanel.hidden;
785
+ if (isPanelVisible) return hideBackupPanel(), showBackupBtn.disabled = false;
786
+ if (!confirm('重新生成备用码,将会使旧备用码失效,是否继续?')) return showBackupBtn.disabled = false;
787
+ backupCodesPanel.hidden = false;
788
+ const result = await requestApi('/api/regenerate-backup-codes', { method: 'POST' }, backupManageMessage, showBackupBtn);
789
+ if (result.ok && result.data.backupCodes) renderBackupCodes(result.data.backupCodes);
790
+ else backupCodesPanel.hidden = true, backupCodesList.innerHTML = '', syncBackupButtonState();
791
+ } catch (err) {
792
+ backupCodesPanel.hidden = true, backupCodesList.innerHTML = '', syncBackupButtonState();
793
+ }
794
+ });
795
+ saveBackupFileBtn.addEventListener('click', () => {
796
+ if (latestBackupCodes.length) saveBackupCodesToFile(latestBackupCodes, `${currentUser?.username}*2FA备用码`);
797
+ else alert('没有备份码可保存');
798
+ });
799
+ printBackupCodesBtn.addEventListener('click', () => {
800
+ if (latestBackupCodes.length) printBackupCodes(latestBackupCodes, `${currentUser?.username}*2FA备用码`);
801
+ else alert('没有备份码可打印');
802
+ });
803
+
804
+ // ==================== WebAuthn 硬件验证模块 ====================
805
+ const loadWebAuthnData = async () => {
806
+ try {
807
+ const { ok, data } = await requestApi('/api/webauthn/credentials', { method: 'GET' }, null, null, true);
808
+ if (ok) currentWebAuthnCredentials = data.credentials || [];
809
+ else throw new Error(data.message);
810
+ } catch (err) {
811
+ currentWebAuthnCredentials = [];
812
+ const targetMsg = manageWebAuthnPanel.hidden ? webauthnMessage : webauthnManageMessage;
813
+ showMessage(targetMsg, 'error', '加载硬件凭证失败,请刷新页面重试');
814
+ } finally { refreshWebAuthnUI(); }
815
+ },
816
+ renderCredentialsList = () => {
817
+ if (currentWebAuthnCredentials.length === 0) return noDeviceMsg.style.display = 'block';
818
+
819
+ webauthnDeviceList.innerHTML = '', noDeviceMsg.style.display = 'none';
820
+ currentWebAuthnCredentials.forEach((cred, idx) => {
821
+ const li = deviceItemExample.cloneNode(true), nameSpan = li.querySelector('.device-name'),
822
+ delBtn = li.querySelector('button'), time = new Date(cred.createdAt).toLocaleString();
823
+
824
+ nameSpan.textContent = `🔑 ${cred.deviceName} 硬件${idx + 1}(添加于${time})`;
825
+ li.style.display = 'flex', webauthnDeviceList.append(li);
826
+ delBtn.addEventListener('click', async e => {
827
+ e.preventDefault();
828
+ if (!confirm('确定删除此设备吗?删除后将无法用于登录验证;')) return;
829
+ const { ok } = await requestApi('/api/webauthn/credentials/delete',
830
+ {
831
+ method: 'POST',
832
+ headers: { 'Content-Type': 'application/json' },
833
+ body: JSON.stringify({ credentialId: cred.id })
834
+ }, webauthnManageMessage, delBtn);
835
+ if (ok) await loadUser(), await loadWebAuthnData();
836
+ });
837
+ });
838
+ },
839
+ refreshWebAuthnUI = () => {
840
+ hideMessage(webauthnMessage), hideMessage(webauthnManageMessage);
841
+ const isEnabled = currentUser?.webauthnEnabled === true;
842
+ webauthnStatusIcon.innerHTML = isEnabled ? '✅' : '';
843
+ toggleWebAuthnBtn.textContent = isEnabled ? '关闭硬件验证' : '启用硬件验证';
844
+ toggleWebAuthnBtn.classList.toggle('btn-danger', isEnabled);
845
+ toggleWebAuthnBtn.disabled = false, manageAddDeviceBtn.disabled = false;
846
+ if (isEnabled) webauthnMessage.hidden = true, manageWebAuthnPanel.hidden = false, renderCredentialsList();
847
+ else {
848
+ manageWebAuthnPanel.hidden = true, webauthnMessage.hidden = false;
849
+ webauthnDeviceList.innerHTML = '', noDeviceMsg.style.display = 'block';
850
+ }
851
+ },
852
+ startAddDeviceAndEnableIfNeeded = async () => {
853
+ hideMessage(webauthnMessage), hideMessage(webauthnManageMessage);
854
+ try {
855
+ const beginResult = await requestApi('/api/webauthn/register/begin', { method: 'POST' }, webauthnMessage, null, true);
856
+ if (!beginResult.ok) return;
857
+ const options = beginResult.data, attResp = await flunWebAuthnBrowser.startRegistration({ optionsJSON: options }),
858
+ completeResult = await requestApi('/api/webauthn/register/complete', {
859
+ method: 'POST',
860
+ headers: { 'Content-Type': 'application/json' },
861
+ body: JSON.stringify({ attestationResponse: attResp })
862
+ }, webauthnMessage);
863
+ if (!completeResult.ok) return;
864
+ await loadUser(), await loadWebAuthnData();
865
+ if (!currentUser?.webauthnEnabled) {
866
+ const { ok, data } = await requestApi('/api/webauthn/toggle', { method: 'POST' }, webauthnMessage);
867
+ if (ok) await loadUser();
868
+ }
869
+ } catch (err) {
870
+ if (err.name === 'AbortError' || err.name === 'NotAllowedError') return;
871
+ let errorMsg = '添加失败:';
872
+ if (err.name === 'NotAllowedError') errorMsg += '操作被拒绝或超时,请确保使用 HTTPS 且设备已配置';
873
+ else errorMsg += '未知错误';
874
+ showMessage(webauthnMessage, 'error', errorMsg);
875
+ }
876
+ },
877
+ toggleWebAuthn = async () => {
878
+ const targetState = !currentUser?.webauthnEnabled;
879
+ if (!targetState) {
880
+ if (!confirm('确定关闭硬件验证吗?关闭后登录时不再需要硬件验证;')) return;
881
+ const oldUser = { ...currentUser };
882
+ if (currentUser) currentUser.webauthnEnabled = false;
883
+ refreshWebAuthnUI();
884
+ const { ok, data } = await requestApi('/api/webauthn/toggle', { method: 'POST' }, null, toggleWebAuthnBtn);
885
+ if (ok) await loadUser();
886
+ else {
887
+ if (currentUser) currentUser.webauthnEnabled = oldUser?.webauthnEnabled;
888
+ refreshWebAuthnUI(), alert(data.message);
889
+ }
890
+ return;
891
+ }
892
+ if (currentWebAuthnCredentials.length > 0) {
893
+ const { ok } = await requestApi('/api/webauthn/toggle', { method: 'POST' }, webauthnMessage, toggleWebAuthnBtn);
894
+ if (ok) await loadUser();
895
+ } else {
896
+ try { await startAddDeviceAndEnableIfNeeded() }
897
+ finally { toggleWebAuthnBtn.disabled = false }
898
+ }
899
+ };
900
+ toggleWebAuthnBtn.addEventListener('click', toggleWebAuthn);
901
+ manageAddDeviceBtn.addEventListener('click', startAddDeviceAndEnableIfNeeded);
902
+
903
+ // ==================== 注销账户模块 ====================
904
+ const toggleDeleteForm = show => {
905
+ if (show) {
906
+ showDeleteAccountBtn.hidden = true, deleteAccountForm.hidden = false;
907
+ setTimeout(() => deleteAccountPassword.focus(), 50), hideMessage(deleteAccountMessage);
908
+ } else {
909
+ showDeleteAccountBtn.hidden = false, deleteAccountForm.hidden = true, deleteAccountPassword.value = '';
910
+ hideMessage(deleteAccountMessage), showDeleteAccountBtn.focus();
911
+ }
912
+ };
913
+
914
+ showDeleteAccountBtn.addEventListener('click', () => toggleDeleteForm(true));
915
+ cancelDeleteBtn.addEventListener('click', () => toggleDeleteForm(false));
916
+ deleteAccountBtn.addEventListener('click', async () => {
917
+ const pwd = deleteAccountPassword.value.trim();
918
+ if (!pwd) return showMessage(deleteAccountMessage, 'error', '请输入密码');
919
+ if (!confirm('您确定要永久注销账户吗?此操作不可撤销,所有数据将被删除!!!')) return;
920
+
921
+ const { ok } = await requestApi('/api/delete-account', {
922
+ method: 'POST',
923
+ headers: { 'Content-Type': 'application/json' },
924
+ body: JSON.stringify({ password: pwd })
925
+ }, deleteAccountMessage, deleteAccountBtn);
926
+ if (ok) setTimeout(() => window.location.href = '/login', 2000);
927
+ });
928
+ logoutBtn.addEventListener('click', async () => {
929
+ const { ok } = await requestApi('/api/logout', { method: 'POST' }, null, logoutBtn);
930
+ if (ok) window.location.href = '/login';
931
+ else alert('退出失败,请重试');
932
+ });
933
+
934
+ // ==================== 初始化及加载 ====================
935
+ const loadUser = async () => {
936
+ try {
937
+ const { ok, data } = await requestApi('/api/user', { method: 'GET' }, null, null, true);
938
+ if (!ok || [401, 404].includes(data?.status ?? data?.code)) return window.location.href = '/login';
939
+ currentUser = data;
940
+ const { username, email, emailVerified, createdAt } = currentUser;
941
+ usernameEl.textContent = username, emailEl.textContent = email ?? '未设置';
942
+ emailVerifiedBadge.textContent = emailVerified ? '✅' : '';
943
+ createdAtEl.textContent = new Date(createdAt).toLocaleString(), await Promise.all([refreshTwofaUI(), loadWebAuthnData()]);
944
+ } catch (err) { alert('加载用户信息失败,请刷新页面重试'); }
945
+ },
946
+ enterHandlers = [
947
+ { input: profileCurrentPasswordInput, button: updateProfileBtn },
948
+ { input: confirmPasswordInput, button: changePasswordBtn },
949
+ { input: deleteAccountPassword, button: deleteAccountBtn },
950
+ { input: verifyTokenInput, button: confirm2faBtn }
951
+ ],
952
+ clearMessageMappings = [
953
+ { messageEl: updateProfileMessage, inputs: [newUsernameInput, newEmailInput, profileCurrentPasswordInput] },
954
+ { messageEl: changePasswordMessage, inputs: [currentPasswordInput, newPasswordInput, confirmPasswordInput] },
955
+ { messageEl: confirmMessage, inputs: [verifyTokenInput] },
956
+ { messageEl: deleteAccountMessage, inputs: [deleteAccountPassword] }
957
+ ],
958
+ setupInputClearMessage = () => {
959
+ clearMessageMappings.forEach(({ messageEl, inputs }) => {
960
+ inputs.forEach(input => {
961
+ if (input.hasAttribute('data-clear-msg-bound')) return;
962
+ input.setAttribute('data-clear-msg-bound', 'true');
963
+ input.addEventListener('input', () => hideMessage(messageEl));
964
+ });
965
+ });
966
+ };
967
+ enterHandlers.forEach(({ input, button }) => {
968
+ input.addEventListener('keydown', e => {
969
+ if (e.key === 'Enter') e.preventDefault(), button.click();
970
+ });
971
+ });
972
+
973
+ window.addEventListener('beforeunload', stopEmailPolling), setupInputClearMessage(), loadUser();
974
+ </script>
975
+ </body>
976
+
977
+ </html>