@flun/html-template 4.4.2 → 5.0.0

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.
@@ -3,7 +3,7 @@
3
3
 
4
4
  <head>
5
5
  <meta charset="UTF-8">
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
7
7
  <title>个人资料 - 安全账户中心</title>
8
8
 
9
9
  <!-- 引入公共主题变量与全局样式 -->
@@ -13,6 +13,7 @@
13
13
  <link rel="stylesheet" href="/static/topImg.css" /> <!-- 返回顶部图标 -->
14
14
 
15
15
  <style>
16
+ /* ========== 基础移动优先增强 ========== */
16
17
  body {
17
18
  background: var(--body-bg);
18
19
  background-color: var(--bg-color);
@@ -28,6 +29,7 @@
28
29
  .card {
29
30
  background: var(--container-bg);
30
31
  border-radius: 12px;
32
+ border: 1px solid #2e3135;
31
33
  box-shadow: 0 2px 10px var(--content-shadow);
32
34
  padding: 30px;
33
35
  margin-bottom: 20px;
@@ -68,22 +70,28 @@
68
70
  /* 信息行 */
69
71
  .info-row {
70
72
  display: flex;
73
+ flex-direction: column;
74
+ align-items: center;
75
+ text-align: center;
71
76
  padding: 12px 0;
72
77
  border-bottom: 1px solid var(--content-border);
73
78
  }
74
79
 
75
80
  .info-label {
76
- width: 120px;
77
- color: var(--text-color);
78
- opacity: 0.75;
81
+ margin-bottom: 6px;
82
+ font-size: 13px;
79
83
  font-weight: 500;
84
+ opacity: 0.65;
85
+ color: var(--text-color);
80
86
  }
81
87
 
82
88
  .info-value {
83
- flex: 1;
84
- color: var(--text-color);
89
+ font-size: 16px;
90
+ font-weight: 600;
91
+ color: #4a5568;
85
92
  }
86
93
 
94
+
87
95
  /* 通用按钮 */
88
96
  .btn {
89
97
  padding: 10px 20px;
@@ -96,13 +104,17 @@
96
104
  cursor: pointer;
97
105
  transition: background 0.2s, opacity 0.2s;
98
106
  margin-right: 10px;
107
+ text-align: center;
108
+ display: inline-flex;
109
+ align-items: center;
110
+ justify-content: center;
111
+ gap: 6px;
99
112
  }
100
113
 
101
114
  .btn:hover {
102
115
  background: var(--btn-hover);
103
116
  }
104
117
 
105
- /* 功能按钮颜色保留原语义(不随主题变化) */
106
118
  .btn-danger {
107
119
  background: #e53e3e;
108
120
  }
@@ -174,7 +186,6 @@
174
186
  color: var(--text-color);
175
187
  }
176
188
 
177
- /* 消息提示(语义色保留) */
178
189
  .message {
179
190
  margin-top: 15px;
180
191
  padding: 10px;
@@ -191,9 +202,54 @@
191
202
  color: #742a2a;
192
203
  }
193
204
 
194
- .info {
195
- background: #bee3f8;
196
- color: #2c5282;
205
+ #backupCodesPanel .info.info-border-flow {
206
+ margin-top: 15px;
207
+ border-style: dashed !important;
208
+ border-width: 1px !important;
209
+ border-top-color: #ff4d4d;
210
+ border-right-color: #4caf50;
211
+ border-bottom-color: #2196f3;
212
+ border-left-color: #9c27b0;
213
+ animation: borderColorFlow 4s linear infinite;
214
+ background-color: #6e523b;
215
+ border-radius: 6px;
216
+ }
217
+
218
+ @keyframes borderColorFlow {
219
+ 0% {
220
+ border-top-color: #ff4d4d;
221
+ border-right-color: #4caf50;
222
+ border-bottom-color: #2196f3;
223
+ border-left-color: #9c27b0;
224
+ }
225
+
226
+ 25% {
227
+ border-top-color: #9c27b0;
228
+ border-right-color: #ff4d4d;
229
+ border-bottom-color: #4caf50;
230
+ border-left-color: #2196f3;
231
+ }
232
+
233
+ 50% {
234
+ border-top-color: #2196f3;
235
+ border-right-color: #9c27b0;
236
+ border-bottom-color: #ff4d4d;
237
+ border-left-color: #4caf50;
238
+ }
239
+
240
+ 75% {
241
+ border-top-color: #4caf50;
242
+ border-right-color: #2196f3;
243
+ border-bottom-color: #9c27b0;
244
+ border-left-color: #ff4d4d;
245
+ }
246
+
247
+ 100% {
248
+ border-top-color: #ff4d4d;
249
+ border-right-color: #4caf50;
250
+ border-bottom-color: #2196f3;
251
+ border-left-color: #9c27b0;
252
+ }
197
253
  }
198
254
 
199
255
  .warning {
@@ -201,7 +257,6 @@
201
257
  color: #7b341e;
202
258
  }
203
259
 
204
- /* 表单 */
205
260
  .form-group {
206
261
  margin-bottom: 15px;
207
262
  }
@@ -215,6 +270,7 @@
215
270
  background: var(--li-bg);
216
271
  color: var(--text-color);
217
272
  transition: border-color 0.2s, background 0.5s ease, color 0.5s ease;
273
+ box-sizing: border-box;
218
274
  }
219
275
 
220
276
  .form-group input:focus {
@@ -222,26 +278,14 @@
222
278
  outline: none;
223
279
  }
224
280
 
225
- /* 操作按钮组 */
226
281
  .action-buttons {
227
282
  display: flex;
228
283
  gap: 10px;
229
284
  margin-top: 20px;
230
285
  flex-wrap: wrap;
286
+ justify-content: center;
231
287
  }
232
288
 
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
289
  .qr-placeholder {
246
290
  text-align: center;
247
291
  margin: 15px 0;
@@ -255,11 +299,11 @@
255
299
  background: var(--container-bg);
256
300
  }
257
301
 
258
- /* 其他辅助样式 */
259
302
  .flex-buttons {
260
303
  display: flex;
261
304
  gap: 10px;
262
305
  margin-top: 8px;
306
+ justify-content: center;
263
307
  }
264
308
 
265
309
  .webauthn-panel {
@@ -285,6 +329,7 @@
285
329
  display: flex;
286
330
  align-items: center;
287
331
  gap: 8px;
332
+ word-break: break-word;
288
333
  }
289
334
 
290
335
  .btn-small {
@@ -296,18 +341,14 @@
296
341
  display: none !important;
297
342
  }
298
343
 
299
- #printBackupContainer {
300
- display: none;
301
- }
302
-
303
344
  @media print {
304
345
  body> :not(#printBackupContainer) {
305
346
  display: none !important;
306
347
  }
307
348
 
308
349
  #printBackupContainer {
309
- position: static !important;
310
350
  display: block !important;
351
+ position: static !important;
311
352
  margin: 20px;
312
353
  }
313
354
 
@@ -347,6 +388,207 @@
347
388
  color: #666;
348
389
  }
349
390
  }
391
+
392
+ /* ==================== 移动端样式适配 (≤640px) ==================== */
393
+ @media (max-width: 640px) {
394
+ body {
395
+ padding: 16px 12px;
396
+ }
397
+
398
+ .container {
399
+ max-width: 100%;
400
+ padding: 0;
401
+ }
402
+
403
+ .card {
404
+ padding: 20px 16px;
405
+ border-radius: 16px;
406
+ margin-bottom: 16px;
407
+ }
408
+
409
+ .card h2 {
410
+ font-size: 20px;
411
+ margin-bottom: 16px;
412
+ gap: 8px;
413
+ }
414
+
415
+ .card h2 .btn {
416
+ font-size: 12px;
417
+ padding: 6px 12px;
418
+ white-space: nowrap;
419
+ }
420
+
421
+ .info-row {
422
+ align-items: flex-start;
423
+ text-align: left;
424
+ padding: 14px 0;
425
+ }
426
+
427
+ .info-label {
428
+ font-size: 12px;
429
+ margin-bottom: 6px;
430
+ }
431
+
432
+ .info-value {
433
+ font-size: 15px;
434
+ }
435
+
436
+ .action-buttons {
437
+ flex-direction: column;
438
+ gap: 12px;
439
+ }
440
+
441
+ .action-buttons .btn {
442
+ width: 100%;
443
+ margin-right: 0;
444
+ justify-content: center;
445
+ padding: 12px 16px;
446
+ font-size: 15px;
447
+ }
448
+
449
+ #editProfileCard>form>div:first-of-type,
450
+ #changePasswordCard>form>div:first-of-type,
451
+ #deleteAccountForm>div:first-of-type {
452
+ flex-direction: column !important;
453
+ gap: 12px !important;
454
+ width: 100%;
455
+ }
456
+
457
+ #editProfileCard>form>div:first-of-type .btn,
458
+ #changePasswordCard>form>div:first-of-type .btn,
459
+ #deleteAccountForm>div:first-of-type .btn {
460
+ width: 100% !important;
461
+ margin-right: 0 !important;
462
+ margin-left: 0 !important;
463
+ box-sizing: border-box;
464
+ }
465
+
466
+ .flex-buttons {
467
+ flex-direction: column;
468
+ gap: 12px;
469
+ }
470
+
471
+ .flex-buttons .btn {
472
+ width: 100%;
473
+ margin-right: 0;
474
+ }
475
+
476
+ .device-item {
477
+ flex-direction: column;
478
+ align-items: flex-start;
479
+ gap: 12px;
480
+ padding: 12px 6px;
481
+ }
482
+
483
+ .device-name {
484
+ width: 100%;
485
+ font-size: 14px;
486
+ }
487
+
488
+ .device-item .btn-small {
489
+ align-self: flex-start;
490
+ margin-top: 4px;
491
+ min-width: 80px;
492
+ }
493
+
494
+ .qr-placeholder img {
495
+ max-width: 160px;
496
+ width: 100%;
497
+ height: auto;
498
+ }
499
+
500
+ .backup-codes {
501
+ padding: 12px;
502
+ text-align: center;
503
+ }
504
+
505
+ .code-item {
506
+ display: inline-block;
507
+ font-size: 14px;
508
+ padding: 6px 10px;
509
+ margin: 6px 4px;
510
+ letter-spacing: 0.5px;
511
+ }
512
+
513
+ .form-group input,
514
+ input[type="text"],
515
+ input[type="email"],
516
+ input[type="password"] {
517
+ font-size: 16px;
518
+ padding: 12px 12px;
519
+ }
520
+
521
+ .btn {
522
+ min-height: 44px;
523
+ padding: 10px 16px;
524
+ }
525
+
526
+ #twofaCard h2 .btn {
527
+ white-space: normal;
528
+ word-break: keep-all;
529
+ }
530
+
531
+ #manageAddDeviceBtn {
532
+ width: 100%;
533
+ justify-content: center;
534
+ }
535
+
536
+ #deleteAccountForm {
537
+ margin-top: 4px;
538
+ }
539
+
540
+ .webauthn-panel {
541
+ margin-top: 12px;
542
+ }
543
+
544
+ #backupCodesPanel p.info {
545
+ font-size: 13px;
546
+ }
547
+
548
+ code#secretCode {
549
+ word-break: break-all;
550
+ display: inline-block;
551
+ max-width: 100%;
552
+ }
553
+ }
554
+
555
+ /* 针对极小屏幕 (≤480px) 微调边距 */
556
+ @media (max-width: 480px) {
557
+ body {
558
+ padding: 12px 10px;
559
+ }
560
+
561
+ .card {
562
+ padding: 16px 14px;
563
+ }
564
+
565
+ .card h2 {
566
+ font-size: 18px;
567
+ gap: 6px;
568
+ }
569
+
570
+ .info-label {
571
+ font-size: 13px;
572
+ }
573
+
574
+ .info-value {
575
+ font-size: 14px;
576
+ }
577
+
578
+ .btn {
579
+ font-size: 14px;
580
+ padding: 10px 12px;
581
+ }
582
+
583
+ .code-item {
584
+ font-size: 13px;
585
+ padding: 5px 8px;
586
+ }
587
+
588
+ .qr-placeholder img {
589
+ max-width: 140px;
590
+ }
591
+ }
350
592
  </style>
351
593
  </head>
352
594
 
@@ -366,7 +608,7 @@
366
608
  </span>
367
609
  </div>
368
610
  <div class="info-row"><span class="info-label">注册时间</span>
369
- <span class="info-value" id="createdAt">-</span>
611
+ <span class="info-value" id="createdAt">加载中...</span>
370
612
  </div>
371
613
  <div class="action-buttons">
372
614
  <button type="button" class="btn btn-outline" id="editProfileBtn">编辑资料</button>
@@ -389,7 +631,7 @@
389
631
  <input type="password" id="profileCurrentPassword" placeholder="请输入密码" maxlength="72"
390
632
  autocomplete="off">
391
633
  </div>
392
- <div style="display: flex; gap: 10px;">
634
+ <div class="flex-buttons">
393
635
  <button type="button" class="btn" id="updateProfileBtn">保存修改</button>
394
636
  <button type="button" class="btn btn-danger" id="cancelEditBtn">取消</button>
395
637
  </div>
@@ -408,9 +650,9 @@
408
650
  <input type="password" id="newPassword" placeholder="新密码(至少6位)" maxlength="72" autocomplete="off">
409
651
  </div>
410
652
  <div class="form-group">
411
- <input type="password" id="confirmPassword" placeholder="确认新密码" maxlength="72" autocomplete="off">
653
+ <input type="password" id="confirmPassword" placeholder="再次输入新密码" maxlength="72" autocomplete="off">
412
654
  </div>
413
- <div style="display: flex; gap: 10px;">
655
+ <div class="flex-buttons">
414
656
  <button type="button" class="btn" id="changePasswordBtn">确认提交</button>
415
657
  <button type="button" class="btn btn-secondary" id="cancelChangePasswordBtn">取消</button>
416
658
  </div>
@@ -418,7 +660,24 @@
418
660
  </form>
419
661
  </div>
420
662
 
421
- <!-- 双因素认证区域 -->
663
+ <!-- 备份码卡片 -->
664
+ <div class="card" id="manage2faPanel" hidden>
665
+ <div id="backupManageMessage" class="message" hidden></div>
666
+ <div id="backupCodesPanel" hidden>
667
+ <p class="info">备份码已生成,请选择你喜欢的保存方式;<br>注意:备用码只显示一次,共10组,每组不可二次使用,请务必妥善保管;</p>
668
+ <div class="flex-buttons">
669
+ <button type="button" class="btn btn-outline" id="saveBackupFileBtn">💾 保存到文件</button>
670
+ <button type="button" class="btn btn-outline" id="printBackupCodesBtn">🖨️ 打印备份码</button>
671
+ </div>
672
+ <div id="backupCodesList" class="backup-codes"></div>
673
+ </div>
674
+ <div class="flex-buttons">
675
+ <button type="button" class="btn btn-warning" id="toggleBackupPanelBtn">🔄 生成新的备份码</button>
676
+ <button type="button" class="btn btn-danger" id="deleteBackupCodesBtn" hidden>🗑️ 删除所有备份码</button>
677
+ </div>
678
+ </div>
679
+
680
+ <!-- 2FA认证卡片 -->
422
681
  <div class="card" id="twofaCard">
423
682
  <h2>
424
683
  <span class="title-text">
@@ -428,14 +687,13 @@
428
687
  </h2>
429
688
 
430
689
  <div id="enable2faPanel" hidden style="margin-top: 16px;">
431
- <form onsubmit="return false;">
690
+ <form onsubmit="return false;" class="form-group">
432
691
  <p>请使用 Google Authenticator 或类似应用扫描二维码添加认证:</p>
433
692
  <div class="qr-placeholder">
434
693
  <img id="qrCodeImg" alt="2FA QR Code">
435
694
  </div>
436
- <p>或手动输入密钥:<code id="secretCode">-</code></p>
437
- <input type="text" id="verifyToken" placeholder="输入6位验证码" maxlength="6"
438
- style="width: 100%; padding: 8px; margin: 12px 0;" autocomplete="off">
695
+ <p>或在应用中手动输入下方密钥:<code id="secretCode">-</code></p>
696
+ <input type="text" id="verifyToken" placeholder="请输入6位验证码" maxlength="6" autocomplete="off">
439
697
  <div class="flex-buttons">
440
698
  <button type="button" class="btn" id="confirm2faBtn">验证并启用</button>
441
699
  <button type="button" class="btn btn-secondary" id="cancelEnable2faBtn">取消</button>
@@ -443,42 +701,25 @@
443
701
  <div id="confirmMessage" class="message" hidden></div>
444
702
  </form>
445
703
  </div>
446
-
447
- <div id="manage2faPanel" hidden style="margin-top: 16px;">
448
- <button type="button" class="btn btn-warning" id="showBackupBtn">生成新的备份码</button>
449
- <div id="backupManageMessage" class="message" hidden></div>
450
- <div id="backupCodesPanel" hidden style="margin-top: 15px;">
451
- <p class="info">备用码(仅显示一次,请妥善保存):</p>
452
- <div id="backupCodesList" class="backup-codes"></div>
453
- <div class="backup-actions">
454
- <button type="button" class="btn btn-outline" id="saveBackupFileBtn">💾 保存到文件</button>
455
- <button type="button" class="btn btn-outline" id="printBackupCodesBtn">🖨️ 打印备份码</button>
456
- </div>
457
- </div>
458
- </div>
459
704
  </div>
460
705
 
461
- <!-- 硬件验证卡片 -->
706
+ <!-- 硬件认证卡片 -->
462
707
  <div class="card" id="webauthnCard">
463
708
  <h2>
464
709
  <span class="title-text">
465
- 硬件验证(指纹/人脸等) <span id="webauthnStatusIcon"></span>
710
+ 硬件认证(指纹/人脸等) <span id="webauthnStatusIcon"></span>
466
711
  </span>
467
- <button type="button" class="btn" id="toggleWebAuthnBtn">启用硬件验证</button>
712
+ <button type="button" class="btn" id="toggleWebAuthnBtn">启用硬件认证</button>
468
713
  </h2>
469
714
 
470
715
  <div id="webauthnMessage" class="message" hidden></div>
471
716
  <div id="manageWebAuthnPanel" class="webauthn-panel" hidden>
472
- <p id="noDeviceMsg" style="color: #666;">暂无硬件设备,点击上方「添加新设备」按钮添加</p>
473
717
  <ul id="webauthnDeviceList" class="device-list"></ul>
474
- <li id="deviceItemExample" class="device-item" style="display: none;">
718
+ <li id="deviceItemExample" class="device-item" hidden>
475
719
  <span class="device-name"></span>
476
720
  <button type="button" class="btn btn-danger btn-small">删除</button>
477
721
  </li>
478
- <div style="display: flex; gap: 12px; margin-top: 12px; flex-wrap: wrap;">
479
- <button type="button" class="btn btn-outline" id="manageAddDeviceBtn">➕ 添加新设备</button>
480
- </div>
481
- <div id="webauthnManageMessage" class="message" hidden></div>
722
+ <button type="button" class="btn btn-outline" id="manageAddDeviceBtn">➕ 添加新设备</button>
482
723
  </div>
483
724
  </div>
484
725
 
@@ -493,10 +734,10 @@
493
734
  <div id="deleteAccountForm" hidden style="margin-top:15px;">
494
735
  <form onsubmit="return false;">
495
736
  <div class="form-group">
496
- <input type="password" id="deleteAccountPassword" placeholder="输入当前密码以确认" maxlength="72"
737
+ <input type="password" id="deleteAccountPassword" placeholder="请输入登录密码" maxlength="72"
497
738
  autocomplete="off">
498
739
  </div>
499
- <div style="display: flex; gap: 10px;">
740
+ <div class="flex-buttons">
500
741
  <button type="button" class="btn btn-danger" id="deleteAccountBtn">永久注销账户</button>
501
742
  <button type="button" class="btn btn-secondary" id="cancelDeleteBtn">取消</button>
502
743
  </div>
@@ -505,7 +746,7 @@
505
746
  </div>
506
747
  </div>
507
748
  </div>
508
- <div id="printBackupContainer"></div>
749
+ <div id="printBackupContainer" hidden></div>
509
750
 
510
751
  <!-- 公共逻辑 -->
511
752
  <script src="/static/themeModule.js" defer></script><!-- 引入主题自适应模块 -->
@@ -514,46 +755,44 @@
514
755
  <script src="/static/topImg.js" defer></script> <!-- 引入返回顶部图标模块 -->
515
756
  <script src="/static/utils/browser.js"></script> <!-- 引入@flun前端硬件验证模块 -->
516
757
  <script>
517
- // ==================== 全局变量 ====================
758
+ // 全局变量
518
759
  let currentUser = null, targetEmail = null, emailCheckInterval = null, isEmailPollingActive = false,
519
760
  latestBackupCodes = [], currentWebAuthnCredentials = [];
520
761
 
521
- // ==================== DOM 元素获取 ====================
522
- const [
523
- usernameEl, emailEl, createdAtEl,
762
+ // DOM 元素常量
763
+ const [usernameEl, emailEl, emailVerifiedBadge, createdAtEl, editProfileBtn, showChangePasswordBtn, logoutBtn,
524
764
  editProfileCard, newUsernameInput, newEmailInput, profileCurrentPasswordInput,
525
- updateProfileMessage, editProfileBtn, cancelEditBtn, updateProfileBtn,
765
+ updateProfileBtn, cancelEditBtn, updateProfileMessage,
526
766
  changePasswordCard, currentPasswordInput, newPasswordInput, confirmPasswordInput,
527
- changePasswordMessage, showChangePasswordBtn, cancelChangePasswordBtn, changePasswordBtn,
528
- twofaCheckmark, toggle2faBtn, enable2faPanel, manage2faPanel, printBackupContainer,
529
- qrCodeImg, secretCode, verifyTokenInput, confirm2faBtn, confirmMessage, cancelEnable2faBtn,
530
- showBackupBtn, backupCodesPanel, backupCodesList, saveBackupFileBtn, printBackupCodesBtn,
531
- showDeleteAccountBtn, deleteAccountForm, deleteAccountPassword, deleteAccountMessage, cancelDeleteBtn,
532
- deleteAccountBtn, logoutBtn,
533
- webauthnStatusIcon, toggleWebAuthnBtn, manageWebAuthnPanel,
534
- webauthnMessage, webauthnManageMessage, manageAddDeviceBtn,
535
- noDeviceMsg, webauthnDeviceList, deviceItemExample,
536
- backupManageMessage, emailVerifiedBadge
767
+ changePasswordBtn, cancelChangePasswordBtn, changePasswordMessage,
768
+ manage2faPanel, toggleBackupPanelBtn, backupManageMessage, backupCodesPanel,
769
+ backupCodesList, saveBackupFileBtn, printBackupCodesBtn, deleteBackupCodesBtn,
770
+ twofaCheckmark, toggle2faBtn, enable2faPanel,
771
+ qrCodeImg, secretCode, verifyTokenInput, confirm2faBtn, cancelEnable2faBtn, confirmMessage,
772
+ webauthnStatusIcon, toggleWebAuthnBtn, webauthnMessage, manageWebAuthnPanel,
773
+ webauthnDeviceList, deviceItemExample, manageAddDeviceBtn,
774
+ showDeleteAccountBtn, deleteAccountForm, deleteAccountPassword,
775
+ deleteAccountBtn, cancelDeleteBtn, deleteAccountMessage, printBackupContainer
537
776
  ] = [
538
- 'username', 'email', 'createdAt',
777
+ 'username', 'email', 'emailVerifiedBadge', 'createdAt', 'editProfileBtn', 'showChangePasswordBtn', 'logoutBtn',
539
778
  'editProfileCard', 'newUsername', 'newEmail', 'profileCurrentPassword',
540
- 'updateProfileMessage', 'editProfileBtn', 'cancelEditBtn', 'updateProfileBtn',
779
+ 'updateProfileBtn', 'cancelEditBtn', 'updateProfileMessage',
541
780
  'changePasswordCard', 'currentPassword', 'newPassword', 'confirmPassword',
542
- 'changePasswordMessage', 'showChangePasswordBtn', 'cancelChangePasswordBtn', 'changePasswordBtn',
543
- 'twofaCheckmark', 'toggle2faBtn', 'enable2faPanel', 'manage2faPanel', 'printBackupContainer',
544
- 'qrCodeImg', 'secretCode', 'verifyToken', 'confirm2faBtn', 'confirmMessage', 'cancelEnable2faBtn',
545
- 'showBackupBtn', 'backupCodesPanel', 'backupCodesList', 'saveBackupFileBtn', 'printBackupCodesBtn',
546
- 'showDeleteAccountBtn', 'deleteAccountForm', 'deleteAccountPassword', 'deleteAccountMessage',
547
- 'cancelDeleteBtn', 'deleteAccountBtn', 'logoutBtn',
548
- 'webauthnStatusIcon', 'toggleWebAuthnBtn', 'manageWebAuthnPanel',
549
- 'webauthnMessage', 'webauthnManageMessage', 'manageAddDeviceBtn',
550
- 'noDeviceMsg', 'webauthnDeviceList', 'deviceItemExample',
551
- 'backupManageMessage', 'emailVerifiedBadge'
781
+ 'changePasswordBtn', 'cancelChangePasswordBtn', 'changePasswordMessage',
782
+ 'manage2faPanel', 'toggleBackupPanelBtn', 'backupManageMessage', 'backupCodesPanel',
783
+ 'backupCodesList', 'saveBackupFileBtn', 'printBackupCodesBtn', 'deleteBackupCodesBtn',
784
+ 'twofaCheckmark', 'toggle2faBtn', 'enable2faPanel',
785
+ 'qrCodeImg', 'secretCode', 'verifyToken', 'confirm2faBtn', 'cancelEnable2faBtn', 'confirmMessage',
786
+ 'webauthnStatusIcon', 'toggleWebAuthnBtn', 'webauthnMessage', 'manageWebAuthnPanel',
787
+ 'webauthnDeviceList', 'deviceItemExample', 'manageAddDeviceBtn',
788
+ 'showDeleteAccountBtn', 'deleteAccountForm', 'deleteAccountPassword',
789
+ 'deleteAccountBtn', 'cancelDeleteBtn', 'deleteAccountMessage', 'printBackupContainer'
552
790
  ].map(id => document.getElementById(id)),
553
- // ==================== 通用辅助函数 ====================
791
+ // 基础工具
554
792
  showMessage = (element, type, text) => {
555
793
  element.className = `message ${type}`, element.textContent = text, element.hidden = false;
556
- }, hideMessage = element => element.hidden = true,
794
+ },
795
+ hideMessage = element => (element.hidden = true, element.textContent = '', element.className = 'message'),
557
796
  requestApi = async (url, options, msgElement = null, btnElement = null, suppressSuccessMsg = false) => {
558
797
  if (btnElement && typeof btnElement === 'boolean') suppressSuccessMsg = btnElement, btnElement = null;
559
798
  if (btnElement) btnElement.disabled = true;
@@ -591,13 +830,124 @@
591
830
  if (isEmailPollingActive) stopEmailPolling(), timeoutCallback?.();
592
831
  }, 900000);
593
832
  },
833
+
834
+ // 全局 UI
835
+ refreshMFAUI = () => {
836
+ const is2faEnabled = currentUser?.twoFactorEnabled, isWebAuthnEnabled = currentUser?.webauthnEnabled;
837
+ twofaCheckmark.innerHTML = is2faEnabled ? '✅' : '';
838
+ toggle2faBtn.textContent = is2faEnabled ? '关闭2FA' : '启用2FA';
839
+ toggle2faBtn.classList.toggle('btn-danger', is2faEnabled);
840
+ webauthnStatusIcon.innerHTML = isWebAuthnEnabled ? '✅' : '';
841
+ toggleWebAuthnBtn.textContent = isWebAuthnEnabled ? '关闭硬件验证' : '启用硬件验证';
842
+ toggleWebAuthnBtn.classList.toggle('btn-danger', isWebAuthnEnabled);
843
+ const hasAnyMFA = is2faEnabled || isWebAuthnEnabled;
844
+ if (hasAnyMFA) manage2faPanel.hidden = false;
845
+ else {
846
+ manage2faPanel.hidden = true;
847
+ if (!backupCodesPanel.hidden && latestBackupCodes.length === 0) hideBackupPanel();
848
+ }
849
+ },
850
+ updateDeleteBackupButton = () => {
851
+ if (currentUser?.hasBackupCodes) deleteBackupCodesBtn.hidden = false;
852
+ else deleteBackupCodesBtn.hidden = true;
853
+ },
854
+ syncBackupButtonState = () => {
855
+ const isPanelVisible = !backupCodesPanel.hidden;
856
+ toggleBackupPanelBtn.disabled = false;
857
+ if (isPanelVisible) {
858
+ toggleBackupPanelBtn.textContent = '📴 关闭备份码显示', toggleBackupPanelBtn.classList.remove('btn-warning');
859
+ toggleBackupPanelBtn.classList.add('btn-secondary');
860
+ } else {
861
+ toggleBackupPanelBtn.textContent = '🔄 生成新的备份码', toggleBackupPanelBtn.classList.remove('btn-secondary');
862
+ toggleBackupPanelBtn.classList.add('btn-warning');
863
+ }
864
+ },
865
+ hideBackupPanel = () => {
866
+ backupCodesPanel.hidden = true, backupCodesList.innerHTML = '', latestBackupCodes = [], syncBackupButtonState();
867
+ const infoPara = document.querySelector('#backupCodesPanel .info');
868
+ if (infoPara) infoPara.classList.remove('info-border-flow');
869
+ },
870
+
871
+ // 页面入口
872
+ loadUser = async () => {
873
+ try {
874
+ const { ok, data } = await requestApi('/api/user', { method: 'GET' }, null, null, true);
875
+ if (!ok || [401, 404].includes(data?.status ?? data?.code)) return window.location.href = '/login';
876
+ currentUser = data;
877
+ const { username, email, emailVerified, createdAt } = currentUser;
878
+ usernameEl.textContent = username;
879
+ emailEl.textContent = email ?? '未设置';
880
+ emailVerifiedBadge.textContent = emailVerified ? '✅' : '';
881
+ createdAtEl.textContent = new Date(createdAt).toLocaleString();
882
+ await Promise.all([refreshMFAUI(), loadWebAuthnData()]), updateDeleteBackupButton();
883
+ } catch (err) {
884
+ alert('加载用户信息失败,请刷新页面重试');
885
+ }
886
+ },
887
+
888
+ // 个人资料模块
594
889
  resetEditProfileForm = () => {
595
890
  profileCurrentPasswordInput.value = '', newUsernameInput.value = currentUser?.username;
596
891
  newEmailInput.value = currentUser?.email;
597
892
  },
893
+ closeEditProfileCard = () => {
894
+ if (!editProfileCard.hidden) {
895
+ if (isEmailPollingActive) stopEmailPolling();
896
+ editProfileCard.hidden = true, updateProfileBtn.disabled = false, editProfileBtn.disabled = false;
897
+ updateProfileBtn.textContent = '保存修改', hideMessage(updateProfileMessage), resetEditProfileForm();
898
+ }
899
+ },
900
+ openEditProfileCard = () => {
901
+ closeChangePasswordCard(), editProfileCard.hidden = false;
902
+ if (currentUser) newUsernameInput.value = currentUser.username, newEmailInput.value = currentUser.email;
903
+ if (isEmailPollingActive) stopEmailPolling();
904
+ profileCurrentPasswordInput.value = '', editProfileBtn.disabled = true, newUsernameInput.focus();
905
+ updateProfileBtn.disabled = false, updateProfileBtn.textContent = '保存修改', hideMessage(updateProfileMessage);
906
+ },
907
+
908
+ // 密码修改模块
598
909
  resetPasswordForm = () => {
599
910
  currentPasswordInput.value = '', newPasswordInput.value = '', confirmPasswordInput.value = '';
600
911
  },
912
+ closeChangePasswordCard = () => {
913
+ if (!changePasswordCard.hidden) {
914
+ changePasswordCard.hidden = true, resetPasswordForm(), showChangePasswordBtn.disabled = false;
915
+ hideMessage(changePasswordMessage);
916
+ }
917
+ },
918
+ openChangePasswordCard = () => {
919
+ closeEditProfileCard(), changePasswordCard.hidden = false, showChangePasswordBtn.disabled = true;
920
+ resetPasswordForm(), setTimeout(() => currentPasswordInput.focus(), 50), hideMessage(changePasswordMessage);
921
+ },
922
+
923
+ // 备份码模块
924
+ renderBackupCodes = codes => {
925
+ if (codes?.length === 0) {
926
+ backupCodesPanel.hidden = true, latestBackupCodes = [];
927
+ if (currentUser) currentUser.hasBackupCodes = false;
928
+ updateDeleteBackupButton(), syncBackupButtonState();
929
+ return;
930
+ }
931
+ backupCodesList.innerHTML = '', latestBackupCodes = [...codes];
932
+ if (currentUser) currentUser.hasBackupCodes = true;
933
+ updateDeleteBackupButton();
934
+ codes.forEach(code => {
935
+ const span = document.createElement('span');
936
+ span.className = 'code-item', span.textContent = code, backupCodesList.append(span);
937
+ });
938
+ backupCodesPanel.hidden = false, syncBackupButtonState();
939
+ const infoPara = document.querySelector('#backupCodesPanel .info');
940
+ if (infoPara) infoPara.classList.add('info-border-flow');
941
+ scrollToBackupPanelAndFocus();
942
+ },
943
+ scrollToBackupPanelAndFocus = () => {
944
+ if (manage2faPanel.hidden || backupCodesPanel.hidden || latestBackupCodes.length === 0) return;
945
+ requestAnimationFrame(() => {
946
+ manage2faPanel.scrollIntoView({ behavior: 'smooth', block: 'start' });
947
+ if (!saveBackupFileBtn.disabled) saveBackupFileBtn.focus();
948
+ else if (!printBackupCodesBtn.disabled) printBackupCodesBtn.focus();
949
+ });
950
+ },
601
951
  saveBackupCodesToFile = (backupCodes, prefix = '我的账户备份码') => {
602
952
  if (backupCodes?.length === 0) return alert('没有可保存的备份码');
603
953
  const now = new Date(), time = now.toLocaleString(), fileTime = time.replace(/[\/:]/g, '-').replace(/ /g, '_');
@@ -615,56 +965,161 @@
615
965
  printBackupCodes = (backupCodes, title = '双因素认证备用码') => {
616
966
  if (!backupCodes || backupCodes.length === 0) return alert('没有可打印的备份码');
617
967
  printBackupContainer.innerHTML = `
618
- <div class="print-backup">
619
- <h1>🔐 ${title}</h1>
620
- <p class="timestamp">生成时间:${new Date().toLocaleString()}</p>
621
- <div class="codes-grid">
622
- ${backupCodes.map(code => `<div class="code-card">${code}</div>`).join('')}
623
- </div>
624
- <div class="warning">⚠️ 每个备用码仅能使用一次,请妥善保管;</div>
625
- <div class="footer">共计 ${backupCodes.length} 个备用码</div>
968
+ <div class="print-backup">
969
+ <h1>🔐 ${title}</h1>
970
+ <p class="timestamp">生成时间:${new Date().toLocaleString()}</p>
971
+ <div class="codes-grid">
972
+ ${backupCodes.map(code => `<div class="code-card">${code}</div>`).join('')}
626
973
  </div>
627
- `;
974
+ <div class="warning">⚠️ 每个备用码仅能使用一次,请妥善保管;</div>
975
+ <div class="footer">共计 ${backupCodes.length} 个备用码</div>
976
+ </div>`;
628
977
  printBackupCodesBtn.disabled = true, window.print(), setTimeout(() => printBackupContainer.innerHTML = '', 500);
629
978
  },
630
- syncBackupButtonState = () => {
631
- const isPanelVisible = !backupCodesPanel.hidden;
632
- if (isPanelVisible) {
633
- showBackupBtn.textContent = '关闭备份码显示', showBackupBtn.classList.remove('btn-warning');
634
- showBackupBtn.classList.add('btn-secondary'), showBackupBtn.disabled = false;
635
- } else {
636
- showBackupBtn.textContent = '生成新的备份码', showBackupBtn.classList.remove('btn-secondary');
637
- showBackupBtn.classList.add('btn-warning');
979
+ deleteBackupCodes = async () => {
980
+ if (!confirm('⚠️ 确定要删除所有备份码吗?删除后将无法使用备份码恢复登录')) return;
981
+
982
+ deleteBackupCodesBtn.disabled = true;
983
+ const result = await requestApi('/api/delete-backup-codes', { method: 'POST' }, backupManageMessage,
984
+ deleteBackupCodesBtn);
985
+ if (result.ok) {
986
+ if (currentUser) currentUser.hasBackupCodes = false;
987
+ updateDeleteBackupButton();
988
+
989
+ if (!backupCodesPanel.hidden) hideBackupPanel();
990
+ refreshMFAUI(), showMessage(backupManageMessage, 'success', '所有备份码已删除');
991
+ setTimeout(() => hideMessage(backupManageMessage), 3000);
638
992
  }
993
+ deleteBackupCodesBtn.disabled = false;
639
994
  },
640
- renderBackupCodes = codes => {
641
- if (codes?.length === 0) return backupCodesPanel.hidden = true, latestBackupCodes = [], syncBackupButtonState();
642
- backupCodesList.innerHTML = '', latestBackupCodes = [...codes];
643
- codes.forEach(code => {
644
- const span = document.createElement('span');
645
- span.className = 'code-item', span.textContent = code, backupCodesList.append(span);
995
+
996
+ // 2FA 认证模块
997
+ showEnable2faPanel = async () => {
998
+ verifyTokenInput.value = '', hideMessage(confirmMessage), qrCodeImg.src = '';
999
+ secretCode.textContent = '加载中...', enable2faPanel.hidden = false, confirm2faBtn.disabled = false;
1000
+ const { ok, data } = await requestApi('/api/enable-2fa', { method: 'POST' }, confirmMessage, toggle2faBtn),
1001
+ { qrCode, secret } = data;
1002
+ if (ok && qrCode && secret) qrCodeImg.src = qrCode, secretCode.textContent = secret, verifyTokenInput.focus();
1003
+ else enable2faPanel.hidden = true;
1004
+ },
1005
+ hideEnable2faPanel = () => {
1006
+ toggle2faBtn.disabled = false, enable2faPanel.hidden = true;
1007
+ verifyTokenInput.value = '', hideMessage(confirmMessage);
1008
+ },
1009
+
1010
+ // WebAuthn 硬件认证模块
1011
+ loadWebAuthnData = async () => {
1012
+ try {
1013
+ const { ok, data } = await requestApi('/api/webauthn/credentials', { method: 'GET' }, null, null, true);
1014
+ if (ok) currentWebAuthnCredentials = data.credentials || [];
1015
+ else throw new Error(data.message);
1016
+ } catch (err) {
1017
+ currentWebAuthnCredentials = [], showMessage(webauthnMessage, 'error', '加载硬件凭证失败,请刷新页面重试');
1018
+ } finally { refreshWebAuthnUI() }
1019
+ },
1020
+ addWebAuthnDevice = async () => {
1021
+ try {
1022
+ const beginResult = await requestApi('/api/webauthn/register/begin', { method: 'POST' }, webauthnMessage, null,
1023
+ true);
1024
+ if (!beginResult.ok) return false;
1025
+
1026
+ showMessage(webauthnMessage, null, "认证硬件添加中...");
1027
+ const options = beginResult.data,
1028
+ attResp = await flunWebAuthnBrowser.startRegistration({ optionsJSON: options }),
1029
+ completeResult = await requestApi('/api/webauthn/register/complete', {
1030
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1031
+ body: JSON.stringify({ attestationResponse: attResp })
1032
+ }, webauthnMessage);
1033
+
1034
+ if (!completeResult.ok) return false;
1035
+ if (completeResult.data.backupCodes) renderBackupCodes(completeResult.data.backupCodes);
1036
+ setTimeout(() => hideMessage(webauthnMessage), 3600), await loadUser(), await loadWebAuthnData();
1037
+ return true;
1038
+ } catch (err) {
1039
+ if (err.name === 'AbortError' || err.name === 'NotAllowedError') {
1040
+ hideMessage(webauthnMessage);
1041
+ return false;
1042
+ }
1043
+ showMessage(webauthnMessage, 'error', '添加失败:' + (err.message));
1044
+ return false;
1045
+ }
1046
+ },
1047
+ renderCredentialsList = () => {
1048
+ if (currentWebAuthnCredentials.length === 0) return;
1049
+
1050
+ webauthnDeviceList.innerHTML = '';
1051
+ currentWebAuthnCredentials.forEach((cred, idx) => {
1052
+ const li = deviceItemExample.cloneNode(true), nameSpan = li.querySelector('.device-name'),
1053
+ delBtn = li.querySelector('button'), time = new Date(cred.createdAt).toLocaleString();
1054
+
1055
+ nameSpan.textContent = `🔑 ${cred.deviceName} 硬件${idx + 1}(添加于${time})`;
1056
+ li.hidden = false, webauthnDeviceList.append(li);
1057
+ delBtn.addEventListener('click', async e => {
1058
+ e.preventDefault();
1059
+ if (!confirm('确定删除此设备吗?删除后将无法用于登录验证;')) return;
1060
+ const { ok } = await requestApi('/api/webauthn/credentials/delete',
1061
+ {
1062
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1063
+ body: JSON.stringify({ credentialId: cred.id })
1064
+ }, webauthnMessage, delBtn);
1065
+ if (ok) await loadUser(), await loadWebAuthnData();
1066
+ });
646
1067
  });
647
- backupCodesPanel.hidden = false, syncBackupButtonState();
648
1068
  },
649
- hideBackupPanel = () => {
650
- backupCodesPanel.hidden = true, backupCodesList.innerHTML = '', latestBackupCodes = [], syncBackupButtonState();
1069
+ refreshWebAuthnUI = () => {
1070
+ hideMessage(webauthnMessage);
1071
+ const isEnabled = currentUser?.webauthnEnabled === true;
1072
+ webauthnStatusIcon.innerHTML = isEnabled ? '✅' : '';
1073
+ toggleWebAuthnBtn.textContent = isEnabled ? '关闭硬件验证' : '启用硬件验证';
1074
+ toggleWebAuthnBtn.classList.toggle('btn-danger', isEnabled);
1075
+ toggleWebAuthnBtn.disabled = false, manageAddDeviceBtn.disabled = false;
1076
+ if (isEnabled) webauthnMessage.hidden = true, manageWebAuthnPanel.hidden = false, renderCredentialsList();
1077
+ else webauthnMessage.hidden = false, manageWebAuthnPanel.hidden = true, webauthnDeviceList.innerHTML = '';
651
1078
  },
652
- // ==================== 个人资料修改模块 ====================
653
- closeEditProfileCard = () => {
654
- if (!editProfileCard.hidden) {
655
- if (isEmailPollingActive) stopEmailPolling();
656
- editProfileCard.hidden = true, updateProfileBtn.disabled = false, editProfileBtn.disabled = false;
657
- updateProfileBtn.textContent = '保存修改', hideMessage(updateProfileMessage), resetEditProfileForm();
1079
+ toggleWebAuthn = async () => {
1080
+ await loadWebAuthnData();
1081
+ const targetState = !currentUser?.webauthnEnabled;
1082
+ if (!targetState) {
1083
+ if (!confirm('确定关闭硬件验证吗?关闭后登录时不再需要硬件验证;')) return;
1084
+ const oldUser = { ...currentUser };
1085
+ if (currentUser) currentUser.webauthnEnabled = false;
1086
+ refreshWebAuthnUI();
1087
+ const { ok, data } = await requestApi('/api/webauthn/toggle', { method: 'POST' }, null, toggleWebAuthnBtn);
1088
+ if (ok) await loadUser();
1089
+ else {
1090
+ if (currentUser) currentUser.webauthnEnabled = oldUser?.webauthnEnabled;
1091
+ refreshWebAuthnUI(), alert(data.message);
1092
+ }
1093
+ return;
1094
+ }
1095
+ if (currentWebAuthnCredentials.length === 0) {
1096
+ toggleWebAuthnBtn.disabled = true;
1097
+ try {
1098
+ await addWebAuthnDevice();
1099
+ } finally { toggleWebAuthnBtn.disabled = false }
1100
+ } else {
1101
+ const { ok, data } = await requestApi('/api/webauthn/toggle', { method: 'POST' }, null, toggleWebAuthnBtn);
1102
+ if (ok) {
1103
+ await loadUser(), await loadWebAuthnData();
1104
+ if (data.backupCodes?.length) renderBackupCodes(data.backupCodes);
1105
+ }
658
1106
  }
659
1107
  },
660
- openEditProfileCard = () => {
661
- closeChangePasswordCard(), editProfileCard.hidden = false;
662
- if (currentUser) newUsernameInput.value = currentUser.username, newEmailInput.value = currentUser.email;
663
- if (isEmailPollingActive) stopEmailPolling();
664
- profileCurrentPasswordInput.value = '', editProfileBtn.disabled = true, setTimeout(() => newUsernameInput.focus(), 50);
665
- updateProfileBtn.disabled = false, updateProfileBtn.textContent = '保存修改', hideMessage(updateProfileMessage);
1108
+
1109
+ // 注销账户模块
1110
+ toggleDeleteForm = show => {
1111
+ hideMessage(deleteAccountMessage);
1112
+ if (show) {
1113
+ showDeleteAccountBtn.hidden = true, deleteAccountForm.hidden = false;
1114
+ setTimeout(() => deleteAccountPassword.focus(), 50);
1115
+ } else {
1116
+ showDeleteAccountBtn.hidden = false, deleteAccountForm.hidden = true, deleteAccountPassword.value = '';
1117
+ showDeleteAccountBtn.focus();
1118
+ }
666
1119
  };
667
1120
 
1121
+ // ==================== 事件绑定 ====================
1122
+ // 1. 个人资料
668
1123
  editProfileBtn.addEventListener('click', openEditProfileCard);
669
1124
  cancelEditBtn.addEventListener('click', () => { closeEditProfileCard(), editProfileBtn.focus() });
670
1125
  updateProfileBtn.addEventListener('click', async () => {
@@ -679,8 +1134,7 @@
679
1134
  let skipFinallyRestore = false;
680
1135
  try {
681
1136
  const { ok } = await requestApi('/api/update-profile', {
682
- method: 'POST',
683
- headers: { 'Content-Type': 'application/json' },
1137
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
684
1138
  body: JSON.stringify({ username, email, currentPassword: pwd })
685
1139
  }, updateProfileMessage, updateProfileBtn);
686
1140
  if (ok) {
@@ -700,18 +1154,7 @@
700
1154
  }
701
1155
  });
702
1156
 
703
- // ==================== 密码修改模块 ====================
704
- const closeChangePasswordCard = () => {
705
- if (!changePasswordCard.hidden) {
706
- changePasswordCard.hidden = true, resetPasswordForm(), showChangePasswordBtn.disabled = false;
707
- hideMessage(changePasswordMessage);
708
- }
709
- },
710
- openChangePasswordCard = () => {
711
- closeEditProfileCard(), changePasswordCard.hidden = false, showChangePasswordBtn.disabled = true;
712
- resetPasswordForm(), setTimeout(() => currentPasswordInput.focus(), 50), hideMessage(changePasswordMessage);
713
- };
714
-
1157
+ // 2. 修改密码
715
1158
  showChangePasswordBtn.addEventListener('click', openChangePasswordCard);
716
1159
  cancelChangePasswordBtn.addEventListener('click', () => {
717
1160
  closeChangePasswordCard(), showChangePasswordBtn.focus();
@@ -721,37 +1164,45 @@
721
1164
  .map(input => input.value.trim());
722
1165
 
723
1166
  if (!current || !newPwd || !confirm) return showMessage(changePasswordMessage, 'error', '请填写所有字段 ');
724
- if (newPwd.length < 6) return showMessage(changePasswordMessage, 'error', '新密码至少6位');
725
- if (newPwd !== confirm) return showMessage(changePasswordMessage, 'error', '两次密码不一致');
726
- const { ok } = await requestApi('/api/change-password', {
727
- method: 'POST',
728
- headers: { 'Content-Type': 'application/json' },
729
- body: JSON.stringify({ currentPassword: current, newPassword: newPwd })
730
- }, changePasswordMessage, changePasswordBtn);
1167
+ if (newPwd.length < 6) return showMessage(changePasswordMessage, 'error', '新密码至少6位'); if (newPwd !== confirm)
1168
+ return showMessage(changePasswordMessage, 'error', '两次密码不一致'); const { ok } = await
1169
+ requestApi('/api/change-password', {
1170
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body:
1171
+ JSON.stringify({ currentPassword: current, newPassword: newPwd })
1172
+ }, changePasswordMessage, changePasswordBtn);
731
1173
  if (ok) setTimeout(() => (closeChangePasswordCard(), window.location.href = '/login'), 1000);
732
1174
  });
733
1175
 
734
- // ==================== 2FA 功能模块 ====================
735
- const showEnable2faPanel = async () => {
736
- verifyTokenInput.value = '', hideMessage(confirmMessage), qrCodeImg.src = '';
737
- secretCode.textContent = '加载中...', enable2faPanel.hidden = false, confirm2faBtn.disabled = false;
738
- const { ok, data } = await requestApi('/api/enable-2fa', { method: 'POST' }, confirmMessage, toggle2faBtn),
739
- { qrCode, secret } = data;
740
- if (ok && qrCode && secret) qrCodeImg.src = qrCode, secretCode.textContent = secret, verifyTokenInput.focus();
741
- else enable2faPanel.hidden = true;
742
- },
743
- hideEnable2faPanel = () => {
744
- toggle2faBtn.disabled = false, enable2faPanel.hidden = true, verifyTokenInput.value = '', hideMessage(confirmMessage);
745
- },
746
- refreshTwofaUI = () => {
747
- toggle2faBtn.disabled = false;
748
- const isEnabled = currentUser?.twoFactorEnabled;
749
- twofaCheckmark.innerHTML = isEnabled ? '✅' : '', twofaCheckmark.title = isEnabled ? '已启用双因素认证' : '';
750
- toggle2faBtn.textContent = isEnabled ? '关闭2FA' : '启用2FA', toggle2faBtn.classList.toggle('btn-danger', isEnabled);
751
- if (isEnabled) manage2faPanel.hidden = false, hideBackupPanel();
752
- else manage2faPanel.hidden = true, hideMessage(confirmMessage);
753
- };
1176
+ // 3. 备份码管理
1177
+ toggleBackupPanelBtn.addEventListener('click', async () => {
1178
+ saveBackupFileBtn.disabled = false, printBackupCodesBtn.disabled = false, toggleBackupPanelBtn.disabled = true;
1179
+ toggleBackupPanelBtn.textContent = '加载中...';
1180
+
1181
+ try {
1182
+ const isPanelVisible = !backupCodesPanel.hidden;
1183
+ if (isPanelVisible) return hideBackupPanel();
1184
+ if (!confirm('重新生成备用码,将会使旧备用码失效,是否继续?')) return toggleBackupPanelBtn.disabled = false;
1185
+
1186
+ const result = await requestApi('/api/regenerate-backup-codes', { method: 'POST' }, backupManageMessage,
1187
+ toggleBackupPanelBtn);
1188
+ if (result.ok && result.data.backupCodes) renderBackupCodes(result.data.backupCodes),
1189
+ hideMessage(backupManageMessage);
1190
+ else backupCodesPanel.hidden = true, backupCodesList.innerHTML = '', syncBackupButtonState();
1191
+ } catch (err) {
1192
+ backupCodesPanel.hidden = true, backupCodesList.innerHTML = '', syncBackupButtonState();
1193
+ }
1194
+ });
1195
+ saveBackupFileBtn.addEventListener('click', () => {
1196
+ if (latestBackupCodes.length) saveBackupCodesToFile(latestBackupCodes, `${currentUser?.username}*备用码`);
1197
+ else alert('没有备份码可保存');
1198
+ });
1199
+ printBackupCodesBtn.addEventListener('click', () => {
1200
+ if (latestBackupCodes.length) printBackupCodes(latestBackupCodes, `${currentUser?.username}*备用码`);
1201
+ else alert('没有备份码可打印');
1202
+ });
1203
+ deleteBackupCodesBtn.addEventListener('click', deleteBackupCodes);
754
1204
 
1205
+ // 4. 2FA认证
755
1206
  toggle2faBtn.addEventListener('click', async () => {
756
1207
  const isEnabled = currentUser?.twoFactorEnabled;
757
1208
  if (!isEnabled) {
@@ -759,9 +1210,14 @@
759
1210
  else hideEnable2faPanel();
760
1211
  } else {
761
1212
  if (!confirm('确定关闭双因素认证吗?关闭后账户安全性将降低;')) return;
762
- const { ok, data } = await requestApi('/api/disable-2fa', { method: 'POST' }, null, toggle2faBtn);
763
- if (ok) await loadUser();
764
- else alert(data.message);
1213
+ toggle2faBtn.disabled = true;
1214
+ try {
1215
+ const { ok, data } = await requestApi('/api/disable-2fa', { method: 'POST' }, null, null, true);
1216
+ if (ok) await loadUser();
1217
+ else alert(data.message);
1218
+ } catch (err) {
1219
+ alert(`关闭失败:${err.message}`);
1220
+ } finally { toggle2faBtn.disabled = false }
765
1221
  }
766
1222
  });
767
1223
  cancelEnable2faBtn.addEventListener('click', hideEnable2faPanel);
@@ -769,149 +1225,25 @@
769
1225
  const token = verifyTokenInput.value.trim();
770
1226
  if (!token) return showMessage(confirmMessage, 'error', '请输入6位验证码');
771
1227
  const { ok, data } = await requestApi('/api/confirm-2fa', {
772
- method: 'POST',
773
- headers: { 'Content-Type': 'application/json' },
1228
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
774
1229
  body: JSON.stringify({ token })
775
1230
  }, confirmMessage, confirm2faBtn);
1231
+
776
1232
  if (ok && data.backupCodes) {
777
1233
  await loadUser(), renderBackupCodes(data.backupCodes), hideEnable2faPanel();
778
1234
  setTimeout(() => hideMessage(confirmMessage), 3000);
779
- } else verifyTokenInput.value = '', verifyTokenInput.focus();
780
- });
781
- showBackupBtn.addEventListener('click', async () => {
782
- saveBackupFileBtn.disabled = false, printBackupCodesBtn.disabled = false;
783
- showBackupBtn.disabled = true, showBackupBtn.textContent = '加载中...', hideMessage(backupManageMessage);
784
- try {
785
- const isPanelVisible = !backupCodesPanel.hidden;
786
- if (isPanelVisible) return hideBackupPanel(), showBackupBtn.disabled = false;
787
- if (!confirm('重新生成备用码,将会使旧备用码失效,是否继续?')) return showBackupBtn.disabled = false;
788
- backupCodesPanel.hidden = false;
789
- const result = await requestApi('/api/regenerate-backup-codes', { method: 'POST' }, backupManageMessage, showBackupBtn);
790
- if (result.ok && result.data.backupCodes) renderBackupCodes(result.data.backupCodes);
791
- else backupCodesPanel.hidden = true, backupCodesList.innerHTML = '', syncBackupButtonState();
792
- } catch (err) {
793
- backupCodesPanel.hidden = true, backupCodesList.innerHTML = '', syncBackupButtonState();
1235
+ } else if (ok) {
1236
+ await loadUser(), hideEnable2faPanel(), showMessage(confirmMessage, 'success', '2FA 已启用');
1237
+ setTimeout(() => hideMessage(confirmMessage), 3000);
794
1238
  }
1239
+ else verifyTokenInput.value = '', verifyTokenInput.focus();
795
1240
  });
796
- saveBackupFileBtn.addEventListener('click', () => {
797
- if (latestBackupCodes.length) saveBackupCodesToFile(latestBackupCodes, `${currentUser?.username}*2FA备用码`);
798
- else alert('没有备份码可保存');
799
- });
800
- printBackupCodesBtn.addEventListener('click', () => {
801
- if (latestBackupCodes.length) printBackupCodes(latestBackupCodes, `${currentUser?.username}*2FA备用码`);
802
- else alert('没有备份码可打印');
803
- });
804
-
805
- // ==================== WebAuthn 硬件验证模块 ====================
806
- const loadWebAuthnData = async () => {
807
- try {
808
- const { ok, data } = await requestApi('/api/webauthn/credentials', { method: 'GET' }, null, null, true);
809
- if (ok) currentWebAuthnCredentials = data.credentials || [];
810
- else throw new Error(data.message);
811
- } catch (err) {
812
- currentWebAuthnCredentials = [];
813
- const targetMsg = manageWebAuthnPanel.hidden ? webauthnMessage : webauthnManageMessage;
814
- showMessage(targetMsg, 'error', '加载硬件凭证失败,请刷新页面重试');
815
- } finally { refreshWebAuthnUI(); }
816
- },
817
- renderCredentialsList = () => {
818
- if (currentWebAuthnCredentials.length === 0) return noDeviceMsg.style.display = 'block';
819
1241
 
820
- webauthnDeviceList.innerHTML = '', noDeviceMsg.style.display = 'none';
821
- currentWebAuthnCredentials.forEach((cred, idx) => {
822
- const li = deviceItemExample.cloneNode(true), nameSpan = li.querySelector('.device-name'),
823
- delBtn = li.querySelector('button'), time = new Date(cred.createdAt).toLocaleString();
824
-
825
- nameSpan.textContent = `🔑 ${cred.deviceName} 硬件${idx + 1}(添加于${time})`;
826
- li.style.display = 'flex', webauthnDeviceList.append(li);
827
- delBtn.addEventListener('click', async e => {
828
- e.preventDefault();
829
- if (!confirm('确定删除此设备吗?删除后将无法用于登录验证;')) return;
830
- const { ok } = await requestApi('/api/webauthn/credentials/delete',
831
- {
832
- method: 'POST',
833
- headers: { 'Content-Type': 'application/json' },
834
- body: JSON.stringify({ credentialId: cred.id })
835
- }, webauthnManageMessage, delBtn);
836
- if (ok) await loadUser(), await loadWebAuthnData();
837
- });
838
- });
839
- },
840
- refreshWebAuthnUI = () => {
841
- hideMessage(webauthnMessage), hideMessage(webauthnManageMessage);
842
- const isEnabled = currentUser?.webauthnEnabled === true;
843
- webauthnStatusIcon.innerHTML = isEnabled ? '✅' : '';
844
- toggleWebAuthnBtn.textContent = isEnabled ? '关闭硬件验证' : '启用硬件验证';
845
- toggleWebAuthnBtn.classList.toggle('btn-danger', isEnabled);
846
- toggleWebAuthnBtn.disabled = false, manageAddDeviceBtn.disabled = false;
847
- if (isEnabled) webauthnMessage.hidden = true, manageWebAuthnPanel.hidden = false, renderCredentialsList();
848
- else {
849
- manageWebAuthnPanel.hidden = true, webauthnMessage.hidden = false;
850
- webauthnDeviceList.innerHTML = '', noDeviceMsg.style.display = 'block';
851
- }
852
- },
853
- startAddDeviceAndEnableIfNeeded = async () => {
854
- hideMessage(webauthnMessage), hideMessage(webauthnManageMessage);
855
- try {
856
- const beginResult = await requestApi('/api/webauthn/register/begin', { method: 'POST' }, webauthnMessage, null, true);
857
- if (!beginResult.ok) return;
858
- const options = beginResult.data, attResp = await flunWebAuthnBrowser.startRegistration({ optionsJSON: options }),
859
- completeResult = await requestApi('/api/webauthn/register/complete', {
860
- method: 'POST',
861
- headers: { 'Content-Type': 'application/json' },
862
- body: JSON.stringify({ attestationResponse: attResp })
863
- }, webauthnMessage);
864
- if (!completeResult.ok) return;
865
- await loadUser(), await loadWebAuthnData();
866
- if (!currentUser?.webauthnEnabled) {
867
- const { ok, data } = await requestApi('/api/webauthn/toggle', { method: 'POST' }, webauthnMessage);
868
- if (ok) await loadUser();
869
- }
870
- } catch (err) {
871
- if (err.name === 'AbortError' || err.name === 'NotAllowedError') return;
872
- let errorMsg = '添加失败:';
873
- if (err.name === 'NotAllowedError') errorMsg += '操作被拒绝或超时,请确保使用 HTTPS 且设备已配置';
874
- else errorMsg += '未知错误';
875
- showMessage(webauthnMessage, 'error', errorMsg);
876
- }
877
- },
878
- toggleWebAuthn = async () => {
879
- const targetState = !currentUser?.webauthnEnabled;
880
- if (!targetState) {
881
- if (!confirm('确定关闭硬件验证吗?关闭后登录时不再需要硬件验证;')) return;
882
- const oldUser = { ...currentUser };
883
- if (currentUser) currentUser.webauthnEnabled = false;
884
- refreshWebAuthnUI();
885
- const { ok, data } = await requestApi('/api/webauthn/toggle', { method: 'POST' }, null, toggleWebAuthnBtn);
886
- if (ok) await loadUser();
887
- else {
888
- if (currentUser) currentUser.webauthnEnabled = oldUser?.webauthnEnabled;
889
- refreshWebAuthnUI(), alert(data.message);
890
- }
891
- return;
892
- }
893
- if (currentWebAuthnCredentials.length > 0) {
894
- const { ok } = await requestApi('/api/webauthn/toggle', { method: 'POST' }, webauthnMessage, toggleWebAuthnBtn);
895
- if (ok) await loadUser();
896
- } else {
897
- try { await startAddDeviceAndEnableIfNeeded() }
898
- finally { toggleWebAuthnBtn.disabled = false }
899
- }
900
- };
1242
+ // 5. WebAuthn 硬件验证
901
1243
  toggleWebAuthnBtn.addEventListener('click', toggleWebAuthn);
902
- manageAddDeviceBtn.addEventListener('click', startAddDeviceAndEnableIfNeeded);
903
-
904
- // ==================== 注销账户模块 ====================
905
- const toggleDeleteForm = show => {
906
- if (show) {
907
- showDeleteAccountBtn.hidden = true, deleteAccountForm.hidden = false;
908
- setTimeout(() => deleteAccountPassword.focus(), 50), hideMessage(deleteAccountMessage);
909
- } else {
910
- showDeleteAccountBtn.hidden = false, deleteAccountForm.hidden = true, deleteAccountPassword.value = '';
911
- hideMessage(deleteAccountMessage), showDeleteAccountBtn.focus();
912
- }
913
- };
1244
+ manageAddDeviceBtn.addEventListener('click', () => addWebAuthnDevice());
914
1245
 
1246
+ // 6. 注销账户
915
1247
  showDeleteAccountBtn.addEventListener('click', () => toggleDeleteForm(true));
916
1248
  cancelDeleteBtn.addEventListener('click', () => toggleDeleteForm(false));
917
1249
  deleteAccountBtn.addEventListener('click', async () => {
@@ -920,36 +1252,26 @@
920
1252
  if (!confirm('您确定要永久注销账户吗?此操作不可撤销,所有数据将被删除!!!')) return;
921
1253
 
922
1254
  const { ok } = await requestApi('/api/delete-account', {
923
- method: 'POST',
924
- headers: { 'Content-Type': 'application/json' },
1255
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
925
1256
  body: JSON.stringify({ password: pwd })
926
1257
  }, deleteAccountMessage, deleteAccountBtn);
927
1258
  if (ok) setTimeout(() => window.location.href = '/login', 2000);
928
1259
  });
1260
+
1261
+ // 7. 退出登录
929
1262
  logoutBtn.addEventListener('click', async () => {
930
1263
  const { ok } = await requestApi('/api/logout', { method: 'POST' }, null, logoutBtn);
931
1264
  if (ok) window.location.href = '/login';
932
1265
  else alert('退出失败,请重试');
933
1266
  });
934
1267
 
935
- // ==================== 初始化及加载 ====================
936
- const loadUser = async () => {
937
- try {
938
- const { ok, data } = await requestApi('/api/user', { method: 'GET' }, null, null, true);
939
- if (!ok || [401, 404].includes(data?.status ?? data?.code)) return window.location.href = '/login';
940
- currentUser = data;
941
- const { username, email, emailVerified, createdAt } = currentUser;
942
- usernameEl.textContent = username, emailEl.textContent = email ?? '未设置';
943
- emailVerifiedBadge.textContent = emailVerified ? '✅' : '';
944
- createdAtEl.textContent = new Date(createdAt).toLocaleString(), await Promise.all([refreshTwofaUI(), loadWebAuthnData()]);
945
- } catch (err) { alert('加载用户信息失败,请刷新页面重试'); }
946
- },
947
- enterHandlers = [
948
- { input: profileCurrentPasswordInput, button: updateProfileBtn },
949
- { input: confirmPasswordInput, button: changePasswordBtn },
950
- { input: deleteAccountPassword, button: deleteAccountBtn },
951
- { input: verifyTokenInput, button: confirm2faBtn }
952
- ],
1268
+ // ==================== 辅助:回车提交 & 自动清除消息 ====================
1269
+ const enterHandlers = [
1270
+ { input: profileCurrentPasswordInput, button: updateProfileBtn },
1271
+ { input: confirmPasswordInput, button: changePasswordBtn },
1272
+ { input: deleteAccountPassword, button: deleteAccountBtn },
1273
+ { input: verifyTokenInput, button: confirm2faBtn }
1274
+ ],
953
1275
  clearMessageMappings = [
954
1276
  { messageEl: updateProfileMessage, inputs: [newUsernameInput, newEmailInput, profileCurrentPasswordInput] },
955
1277
  { messageEl: changePasswordMessage, inputs: [currentPasswordInput, newPasswordInput, confirmPasswordInput] },
@@ -970,8 +1292,7 @@
970
1292
  if (e.key === 'Enter') e.preventDefault(), button.click();
971
1293
  });
972
1294
  });
973
-
974
- window.addEventListener('beforeunload', stopEmailPolling), setupInputClearMessage(), loadUser();
1295
+ setupInputClearMessage(), window.addEventListener('beforeunload', stopEmailPolling), loadUser();
975
1296
  </script>
976
1297
  </body>
977
1298