@flun/html-template 4.4.3 → 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.
@@ -29,6 +29,7 @@
29
29
  .card {
30
30
  background: var(--container-bg);
31
31
  border-radius: 12px;
32
+ border: 1px solid #2e3135;
32
33
  box-shadow: 0 2px 10px var(--content-shadow);
33
34
  padding: 30px;
34
35
  margin-bottom: 20px;
@@ -69,23 +70,28 @@
69
70
  /* 信息行 */
70
71
  .info-row {
71
72
  display: flex;
73
+ flex-direction: column;
74
+ align-items: center;
75
+ text-align: center;
72
76
  padding: 12px 0;
73
77
  border-bottom: 1px solid var(--content-border);
74
78
  }
75
79
 
76
80
  .info-label {
77
- width: 120px;
78
- color: var(--text-color);
79
- opacity: 0.75;
81
+ margin-bottom: 6px;
82
+ font-size: 13px;
80
83
  font-weight: 500;
84
+ opacity: 0.65;
85
+ color: var(--text-color);
81
86
  }
82
87
 
83
88
  .info-value {
84
- flex: 1;
85
- color: var(--text-color);
86
- word-break: break-word;
89
+ font-size: 16px;
90
+ font-weight: 600;
91
+ color: #4a5568;
87
92
  }
88
93
 
94
+
89
95
  /* 通用按钮 */
90
96
  .btn {
91
97
  padding: 10px 20px;
@@ -109,7 +115,6 @@
109
115
  background: var(--btn-hover);
110
116
  }
111
117
 
112
- /* 功能按钮颜色保留原语义(不随主题变化) */
113
118
  .btn-danger {
114
119
  background: #e53e3e;
115
120
  }
@@ -181,7 +186,6 @@
181
186
  color: var(--text-color);
182
187
  }
183
188
 
184
- /* 消息提示(语义色保留) */
185
189
  .message {
186
190
  margin-top: 15px;
187
191
  padding: 10px;
@@ -198,9 +202,54 @@
198
202
  color: #742a2a;
199
203
  }
200
204
 
201
- .info {
202
- background: #bee3f8;
203
- 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
+ }
204
253
  }
205
254
 
206
255
  .warning {
@@ -208,7 +257,6 @@
208
257
  color: #7b341e;
209
258
  }
210
259
 
211
- /* 表单 */
212
260
  .form-group {
213
261
  margin-bottom: 15px;
214
262
  }
@@ -230,26 +278,14 @@
230
278
  outline: none;
231
279
  }
232
280
 
233
- /* 操作按钮组 */
234
281
  .action-buttons {
235
282
  display: flex;
236
283
  gap: 10px;
237
284
  margin-top: 20px;
238
285
  flex-wrap: wrap;
286
+ justify-content: center;
239
287
  }
240
288
 
241
- .backup-actions {
242
- margin-top: 15px;
243
- display: flex;
244
- gap: 12px;
245
- justify-content: flex-start;
246
- }
247
-
248
- .backup-actions .btn {
249
- margin-right: 0;
250
- }
251
-
252
- /* 二维码 */
253
289
  .qr-placeholder {
254
290
  text-align: center;
255
291
  margin: 15px 0;
@@ -263,11 +299,11 @@
263
299
  background: var(--container-bg);
264
300
  }
265
301
 
266
- /* 其他辅助样式 */
267
302
  .flex-buttons {
268
303
  display: flex;
269
304
  gap: 10px;
270
305
  margin-top: 8px;
306
+ justify-content: center;
271
307
  }
272
308
 
273
309
  .webauthn-panel {
@@ -305,18 +341,14 @@
305
341
  display: none !important;
306
342
  }
307
343
 
308
- #printBackupContainer {
309
- display: none;
310
- }
311
-
312
344
  @media print {
313
345
  body> :not(#printBackupContainer) {
314
346
  display: none !important;
315
347
  }
316
348
 
317
349
  #printBackupContainer {
318
- position: static !important;
319
350
  display: block !important;
351
+ position: static !important;
320
352
  margin: 20px;
321
353
  }
322
354
 
@@ -386,25 +418,21 @@
386
418
  white-space: nowrap;
387
419
  }
388
420
 
389
- /* 信息行改为垂直布局,更易阅读 */
390
421
  .info-row {
391
- flex-direction: column;
392
- padding: 12px 0;
422
+ align-items: flex-start;
423
+ text-align: left;
424
+ padding: 14px 0;
393
425
  }
394
426
 
395
427
  .info-label {
396
- width: auto;
428
+ font-size: 12px;
397
429
  margin-bottom: 6px;
398
- font-weight: 600;
399
- opacity: 0.8;
400
430
  }
401
431
 
402
432
  .info-value {
403
- width: 100%;
404
433
  font-size: 15px;
405
434
  }
406
435
 
407
- /* 主要操作按钮组 垂直排列 */
408
436
  .action-buttons {
409
437
  flex-direction: column;
410
438
  gap: 12px;
@@ -418,7 +446,6 @@
418
446
  font-size: 15px;
419
447
  }
420
448
 
421
- /* 所有内联 flex 按钮组(修改资料/修改密码/删除账户)强制垂直 + 全宽 */
422
449
  #editProfileCard>form>div:first-of-type,
423
450
  #changePasswordCard>form>div:first-of-type,
424
451
  #deleteAccountForm>div:first-of-type {
@@ -436,7 +463,6 @@
436
463
  box-sizing: border-box;
437
464
  }
438
465
 
439
- /* 双因素启用面板的按钮组 (flex-buttons) */
440
466
  .flex-buttons {
441
467
  flex-direction: column;
442
468
  gap: 12px;
@@ -447,18 +473,6 @@
447
473
  margin-right: 0;
448
474
  }
449
475
 
450
- /* 备用码管理面板按钮组垂直排列 */
451
- .backup-actions {
452
- flex-direction: column;
453
- gap: 12px;
454
- }
455
-
456
- .backup-actions .btn {
457
- width: 100%;
458
- margin-right: 0;
459
- }
460
-
461
- /* 硬件验证设备列表 手机端改为垂直紧凑 */
462
476
  .device-item {
463
477
  flex-direction: column;
464
478
  align-items: flex-start;
@@ -477,14 +491,12 @@
477
491
  min-width: 80px;
478
492
  }
479
493
 
480
- /* 二维码适配移动屏幕 */
481
494
  .qr-placeholder img {
482
495
  max-width: 160px;
483
496
  width: 100%;
484
497
  height: auto;
485
498
  }
486
499
 
487
- /* 备用码列表展示优化 */
488
500
  .backup-codes {
489
501
  padding: 12px;
490
502
  text-align: center;
@@ -498,7 +510,6 @@
498
510
  letter-spacing: 0.5px;
499
511
  }
500
512
 
501
- /* 表单输入框提升字体 ≥16px 避免 IOS 缩放 */
502
513
  .form-group input,
503
514
  input[type="text"],
504
515
  input[type="email"],
@@ -507,40 +518,33 @@
507
518
  padding: 12px 12px;
508
519
  }
509
520
 
510
- /* 加宽可点击区域 */
511
521
  .btn {
512
522
  min-height: 44px;
513
523
  padding: 10px 16px;
514
524
  }
515
525
 
516
- /* 双因素认证 h2 内部按钮优化宽度 */
517
526
  #twofaCard h2 .btn {
518
527
  white-space: normal;
519
528
  word-break: keep-all;
520
529
  }
521
530
 
522
- /* 管理面板内添加设备按钮自适应 */
523
531
  #manageAddDeviceBtn {
524
532
  width: 100%;
525
533
  justify-content: center;
526
534
  }
527
535
 
528
- /* 注销账户区块调整间距 */
529
536
  #deleteAccountForm {
530
537
  margin-top: 4px;
531
538
  }
532
539
 
533
- /* 硬件验证消息容器内边距 */
534
540
  .webauthn-panel {
535
541
  margin-top: 12px;
536
542
  }
537
543
 
538
- /* 备用码生成区域文案 */
539
544
  #backupCodesPanel p.info {
540
545
  font-size: 13px;
541
546
  }
542
547
 
543
- /* secret 密钥区域长文本换行 */
544
548
  code#secretCode {
545
549
  word-break: break-all;
546
550
  display: inline-block;
@@ -604,7 +608,7 @@
604
608
  </span>
605
609
  </div>
606
610
  <div class="info-row"><span class="info-label">注册时间</span>
607
- <span class="info-value" id="createdAt">-</span>
611
+ <span class="info-value" id="createdAt">加载中...</span>
608
612
  </div>
609
613
  <div class="action-buttons">
610
614
  <button type="button" class="btn btn-outline" id="editProfileBtn">编辑资料</button>
@@ -627,7 +631,7 @@
627
631
  <input type="password" id="profileCurrentPassword" placeholder="请输入密码" maxlength="72"
628
632
  autocomplete="off">
629
633
  </div>
630
- <div style="display: flex; gap: 10px;">
634
+ <div class="flex-buttons">
631
635
  <button type="button" class="btn" id="updateProfileBtn">保存修改</button>
632
636
  <button type="button" class="btn btn-danger" id="cancelEditBtn">取消</button>
633
637
  </div>
@@ -646,9 +650,9 @@
646
650
  <input type="password" id="newPassword" placeholder="新密码(至少6位)" maxlength="72" autocomplete="off">
647
651
  </div>
648
652
  <div class="form-group">
649
- <input type="password" id="confirmPassword" placeholder="确认新密码" maxlength="72" autocomplete="off">
653
+ <input type="password" id="confirmPassword" placeholder="再次输入新密码" maxlength="72" autocomplete="off">
650
654
  </div>
651
- <div style="display: flex; gap: 10px;">
655
+ <div class="flex-buttons">
652
656
  <button type="button" class="btn" id="changePasswordBtn">确认提交</button>
653
657
  <button type="button" class="btn btn-secondary" id="cancelChangePasswordBtn">取消</button>
654
658
  </div>
@@ -656,7 +660,24 @@
656
660
  </form>
657
661
  </div>
658
662
 
659
- <!-- 双因素认证区域 -->
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认证卡片 -->
660
681
  <div class="card" id="twofaCard">
661
682
  <h2>
662
683
  <span class="title-text">
@@ -666,14 +687,13 @@
666
687
  </h2>
667
688
 
668
689
  <div id="enable2faPanel" hidden style="margin-top: 16px;">
669
- <form onsubmit="return false;">
690
+ <form onsubmit="return false;" class="form-group">
670
691
  <p>请使用 Google Authenticator 或类似应用扫描二维码添加认证:</p>
671
692
  <div class="qr-placeholder">
672
693
  <img id="qrCodeImg" alt="2FA QR Code">
673
694
  </div>
674
- <p>或手动输入密钥:<code id="secretCode">-</code></p>
675
- <input type="text" id="verifyToken" placeholder="输入6位验证码" maxlength="6"
676
- 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">
677
697
  <div class="flex-buttons">
678
698
  <button type="button" class="btn" id="confirm2faBtn">验证并启用</button>
679
699
  <button type="button" class="btn btn-secondary" id="cancelEnable2faBtn">取消</button>
@@ -681,42 +701,25 @@
681
701
  <div id="confirmMessage" class="message" hidden></div>
682
702
  </form>
683
703
  </div>
684
-
685
- <div id="manage2faPanel" hidden style="margin-top: 16px;">
686
- <button type="button" class="btn btn-warning" id="showBackupBtn">生成新的备份码</button>
687
- <div id="backupManageMessage" class="message" hidden></div>
688
- <div id="backupCodesPanel" hidden style="margin-top: 15px;">
689
- <p class="info">备用码(仅显示一次,请妥善保存):</p>
690
- <div id="backupCodesList" class="backup-codes"></div>
691
- <div class="backup-actions">
692
- <button type="button" class="btn btn-outline" id="saveBackupFileBtn">💾 保存到文件</button>
693
- <button type="button" class="btn btn-outline" id="printBackupCodesBtn">🖨️ 打印备份码</button>
694
- </div>
695
- </div>
696
- </div>
697
704
  </div>
698
705
 
699
- <!-- 硬件验证卡片 -->
706
+ <!-- 硬件认证卡片 -->
700
707
  <div class="card" id="webauthnCard">
701
708
  <h2>
702
709
  <span class="title-text">
703
- 硬件验证(指纹/人脸等) <span id="webauthnStatusIcon"></span>
710
+ 硬件认证(指纹/人脸等) <span id="webauthnStatusIcon"></span>
704
711
  </span>
705
- <button type="button" class="btn" id="toggleWebAuthnBtn">启用硬件验证</button>
712
+ <button type="button" class="btn" id="toggleWebAuthnBtn">启用硬件认证</button>
706
713
  </h2>
707
714
 
708
715
  <div id="webauthnMessage" class="message" hidden></div>
709
716
  <div id="manageWebAuthnPanel" class="webauthn-panel" hidden>
710
- <p id="noDeviceMsg" style="color: #666;">暂无硬件设备,点击上方「添加新设备」按钮添加</p>
711
717
  <ul id="webauthnDeviceList" class="device-list"></ul>
712
- <li id="deviceItemExample" class="device-item" style="display: none;">
718
+ <li id="deviceItemExample" class="device-item" hidden>
713
719
  <span class="device-name"></span>
714
720
  <button type="button" class="btn btn-danger btn-small">删除</button>
715
721
  </li>
716
- <div style="display: flex; gap: 12px; margin-top: 12px; flex-wrap: wrap;">
717
- <button type="button" class="btn btn-outline" id="manageAddDeviceBtn">➕ 添加新设备</button>
718
- </div>
719
- <div id="webauthnManageMessage" class="message" hidden></div>
722
+ <button type="button" class="btn btn-outline" id="manageAddDeviceBtn">➕ 添加新设备</button>
720
723
  </div>
721
724
  </div>
722
725
 
@@ -731,10 +734,10 @@
731
734
  <div id="deleteAccountForm" hidden style="margin-top:15px;">
732
735
  <form onsubmit="return false;">
733
736
  <div class="form-group">
734
- <input type="password" id="deleteAccountPassword" placeholder="输入当前密码以确认" maxlength="72"
737
+ <input type="password" id="deleteAccountPassword" placeholder="请输入登录密码" maxlength="72"
735
738
  autocomplete="off">
736
739
  </div>
737
- <div style="display: flex; gap: 10px;">
740
+ <div class="flex-buttons">
738
741
  <button type="button" class="btn btn-danger" id="deleteAccountBtn">永久注销账户</button>
739
742
  <button type="button" class="btn btn-secondary" id="cancelDeleteBtn">取消</button>
740
743
  </div>
@@ -743,7 +746,7 @@
743
746
  </div>
744
747
  </div>
745
748
  </div>
746
- <div id="printBackupContainer"></div>
749
+ <div id="printBackupContainer" hidden></div>
747
750
 
748
751
  <!-- 公共逻辑 -->
749
752
  <script src="/static/themeModule.js" defer></script><!-- 引入主题自适应模块 -->
@@ -752,46 +755,44 @@
752
755
  <script src="/static/topImg.js" defer></script> <!-- 引入返回顶部图标模块 -->
753
756
  <script src="/static/utils/browser.js"></script> <!-- 引入@flun前端硬件验证模块 -->
754
757
  <script>
755
- // ==================== 全局变量 ====================
758
+ // 全局变量
756
759
  let currentUser = null, targetEmail = null, emailCheckInterval = null, isEmailPollingActive = false,
757
760
  latestBackupCodes = [], currentWebAuthnCredentials = [];
758
761
 
759
- // ==================== DOM 元素获取 ====================
760
- const [
761
- usernameEl, emailEl, createdAtEl,
762
+ // DOM 元素常量
763
+ const [usernameEl, emailEl, emailVerifiedBadge, createdAtEl, editProfileBtn, showChangePasswordBtn, logoutBtn,
762
764
  editProfileCard, newUsernameInput, newEmailInput, profileCurrentPasswordInput,
763
- updateProfileMessage, editProfileBtn, cancelEditBtn, updateProfileBtn,
765
+ updateProfileBtn, cancelEditBtn, updateProfileMessage,
764
766
  changePasswordCard, currentPasswordInput, newPasswordInput, confirmPasswordInput,
765
- changePasswordMessage, showChangePasswordBtn, cancelChangePasswordBtn, changePasswordBtn,
766
- twofaCheckmark, toggle2faBtn, enable2faPanel, manage2faPanel, printBackupContainer,
767
- qrCodeImg, secretCode, verifyTokenInput, confirm2faBtn, confirmMessage, cancelEnable2faBtn,
768
- showBackupBtn, backupCodesPanel, backupCodesList, saveBackupFileBtn, printBackupCodesBtn,
769
- showDeleteAccountBtn, deleteAccountForm, deleteAccountPassword, deleteAccountMessage, cancelDeleteBtn,
770
- deleteAccountBtn, logoutBtn,
771
- webauthnStatusIcon, toggleWebAuthnBtn, manageWebAuthnPanel,
772
- webauthnMessage, webauthnManageMessage, manageAddDeviceBtn,
773
- noDeviceMsg, webauthnDeviceList, deviceItemExample,
774
- 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
775
776
  ] = [
776
- 'username', 'email', 'createdAt',
777
+ 'username', 'email', 'emailVerifiedBadge', 'createdAt', 'editProfileBtn', 'showChangePasswordBtn', 'logoutBtn',
777
778
  'editProfileCard', 'newUsername', 'newEmail', 'profileCurrentPassword',
778
- 'updateProfileMessage', 'editProfileBtn', 'cancelEditBtn', 'updateProfileBtn',
779
+ 'updateProfileBtn', 'cancelEditBtn', 'updateProfileMessage',
779
780
  'changePasswordCard', 'currentPassword', 'newPassword', 'confirmPassword',
780
- 'changePasswordMessage', 'showChangePasswordBtn', 'cancelChangePasswordBtn', 'changePasswordBtn',
781
- 'twofaCheckmark', 'toggle2faBtn', 'enable2faPanel', 'manage2faPanel', 'printBackupContainer',
782
- 'qrCodeImg', 'secretCode', 'verifyToken', 'confirm2faBtn', 'confirmMessage', 'cancelEnable2faBtn',
783
- 'showBackupBtn', 'backupCodesPanel', 'backupCodesList', 'saveBackupFileBtn', 'printBackupCodesBtn',
784
- 'showDeleteAccountBtn', 'deleteAccountForm', 'deleteAccountPassword', 'deleteAccountMessage',
785
- 'cancelDeleteBtn', 'deleteAccountBtn', 'logoutBtn',
786
- 'webauthnStatusIcon', 'toggleWebAuthnBtn', 'manageWebAuthnPanel',
787
- 'webauthnMessage', 'webauthnManageMessage', 'manageAddDeviceBtn',
788
- 'noDeviceMsg', 'webauthnDeviceList', 'deviceItemExample',
789
- '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'
790
790
  ].map(id => document.getElementById(id)),
791
- // ==================== 通用辅助函数 ====================
791
+ // 基础工具
792
792
  showMessage = (element, type, text) => {
793
793
  element.className = `message ${type}`, element.textContent = text, element.hidden = false;
794
- }, hideMessage = element => element.hidden = true,
794
+ },
795
+ hideMessage = element => (element.hidden = true, element.textContent = '', element.className = 'message'),
795
796
  requestApi = async (url, options, msgElement = null, btnElement = null, suppressSuccessMsg = false) => {
796
797
  if (btnElement && typeof btnElement === 'boolean') suppressSuccessMsg = btnElement, btnElement = null;
797
798
  if (btnElement) btnElement.disabled = true;
@@ -829,13 +830,124 @@
829
830
  if (isEmailPollingActive) stopEmailPolling(), timeoutCallback?.();
830
831
  }, 900000);
831
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
+ // 个人资料模块
832
889
  resetEditProfileForm = () => {
833
890
  profileCurrentPasswordInput.value = '', newUsernameInput.value = currentUser?.username;
834
891
  newEmailInput.value = currentUser?.email;
835
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
+ // 密码修改模块
836
909
  resetPasswordForm = () => {
837
910
  currentPasswordInput.value = '', newPasswordInput.value = '', confirmPasswordInput.value = '';
838
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
+ },
839
951
  saveBackupCodesToFile = (backupCodes, prefix = '我的账户备份码') => {
840
952
  if (backupCodes?.length === 0) return alert('没有可保存的备份码');
841
953
  const now = new Date(), time = now.toLocaleString(), fileTime = time.replace(/[\/:]/g, '-').replace(/ /g, '_');
@@ -853,56 +965,161 @@
853
965
  printBackupCodes = (backupCodes, title = '双因素认证备用码') => {
854
966
  if (!backupCodes || backupCodes.length === 0) return alert('没有可打印的备份码');
855
967
  printBackupContainer.innerHTML = `
856
- <div class="print-backup">
857
- <h1>🔐 ${title}</h1>
858
- <p class="timestamp">生成时间:${new Date().toLocaleString()}</p>
859
- <div class="codes-grid">
860
- ${backupCodes.map(code => `<div class="code-card">${code}</div>`).join('')}
861
- </div>
862
- <div class="warning">⚠️ 每个备用码仅能使用一次,请妥善保管;</div>
863
- <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('')}
864
973
  </div>
865
- `;
974
+ <div class="warning">⚠️ 每个备用码仅能使用一次,请妥善保管;</div>
975
+ <div class="footer">共计 ${backupCodes.length} 个备用码</div>
976
+ </div>`;
866
977
  printBackupCodesBtn.disabled = true, window.print(), setTimeout(() => printBackupContainer.innerHTML = '', 500);
867
978
  },
868
- syncBackupButtonState = () => {
869
- const isPanelVisible = !backupCodesPanel.hidden;
870
- if (isPanelVisible) {
871
- showBackupBtn.textContent = '关闭备份码显示', showBackupBtn.classList.remove('btn-warning');
872
- showBackupBtn.classList.add('btn-secondary'), showBackupBtn.disabled = false;
873
- } else {
874
- showBackupBtn.textContent = '生成新的备份码', showBackupBtn.classList.remove('btn-secondary');
875
- 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);
876
992
  }
993
+ deleteBackupCodesBtn.disabled = false;
877
994
  },
878
- renderBackupCodes = codes => {
879
- if (codes?.length === 0) return backupCodesPanel.hidden = true, latestBackupCodes = [], syncBackupButtonState();
880
- backupCodesList.innerHTML = '', latestBackupCodes = [...codes];
881
- codes.forEach(code => {
882
- const span = document.createElement('span');
883
- 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
+ });
884
1067
  });
885
- backupCodesPanel.hidden = false, syncBackupButtonState();
886
1068
  },
887
- hideBackupPanel = () => {
888
- 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 = '';
889
1078
  },
890
- // ==================== 个人资料修改模块 ====================
891
- closeEditProfileCard = () => {
892
- if (!editProfileCard.hidden) {
893
- if (isEmailPollingActive) stopEmailPolling();
894
- editProfileCard.hidden = true, updateProfileBtn.disabled = false, editProfileBtn.disabled = false;
895
- 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
+ }
896
1106
  }
897
1107
  },
898
- openEditProfileCard = () => {
899
- closeChangePasswordCard(), editProfileCard.hidden = false;
900
- if (currentUser) newUsernameInput.value = currentUser.username, newEmailInput.value = currentUser.email;
901
- if (isEmailPollingActive) stopEmailPolling();
902
- profileCurrentPasswordInput.value = '', editProfileBtn.disabled = true, setTimeout(() => newUsernameInput.focus(), 50);
903
- 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
+ }
904
1119
  };
905
1120
 
1121
+ // ==================== 事件绑定 ====================
1122
+ // 1. 个人资料
906
1123
  editProfileBtn.addEventListener('click', openEditProfileCard);
907
1124
  cancelEditBtn.addEventListener('click', () => { closeEditProfileCard(), editProfileBtn.focus() });
908
1125
  updateProfileBtn.addEventListener('click', async () => {
@@ -917,8 +1134,7 @@
917
1134
  let skipFinallyRestore = false;
918
1135
  try {
919
1136
  const { ok } = await requestApi('/api/update-profile', {
920
- method: 'POST',
921
- headers: { 'Content-Type': 'application/json' },
1137
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
922
1138
  body: JSON.stringify({ username, email, currentPassword: pwd })
923
1139
  }, updateProfileMessage, updateProfileBtn);
924
1140
  if (ok) {
@@ -938,18 +1154,7 @@
938
1154
  }
939
1155
  });
940
1156
 
941
- // ==================== 密码修改模块 ====================
942
- const closeChangePasswordCard = () => {
943
- if (!changePasswordCard.hidden) {
944
- changePasswordCard.hidden = true, resetPasswordForm(), showChangePasswordBtn.disabled = false;
945
- hideMessage(changePasswordMessage);
946
- }
947
- },
948
- openChangePasswordCard = () => {
949
- closeEditProfileCard(), changePasswordCard.hidden = false, showChangePasswordBtn.disabled = true;
950
- resetPasswordForm(), setTimeout(() => currentPasswordInput.focus(), 50), hideMessage(changePasswordMessage);
951
- };
952
-
1157
+ // 2. 修改密码
953
1158
  showChangePasswordBtn.addEventListener('click', openChangePasswordCard);
954
1159
  cancelChangePasswordBtn.addEventListener('click', () => {
955
1160
  closeChangePasswordCard(), showChangePasswordBtn.focus();
@@ -959,37 +1164,45 @@
959
1164
  .map(input => input.value.trim());
960
1165
 
961
1166
  if (!current || !newPwd || !confirm) return showMessage(changePasswordMessage, 'error', '请填写所有字段 ');
962
- if (newPwd.length < 6) return showMessage(changePasswordMessage, 'error', '新密码至少6位');
963
- if (newPwd !== confirm) return showMessage(changePasswordMessage, 'error', '两次密码不一致');
964
- const { ok } = await requestApi('/api/change-password', {
965
- method: 'POST',
966
- headers: { 'Content-Type': 'application/json' },
967
- body: JSON.stringify({ currentPassword: current, newPassword: newPwd })
968
- }, 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);
969
1173
  if (ok) setTimeout(() => (closeChangePasswordCard(), window.location.href = '/login'), 1000);
970
1174
  });
971
1175
 
972
- // ==================== 2FA 功能模块 ====================
973
- const showEnable2faPanel = async () => {
974
- verifyTokenInput.value = '', hideMessage(confirmMessage), qrCodeImg.src = '';
975
- secretCode.textContent = '加载中...', enable2faPanel.hidden = false, confirm2faBtn.disabled = false;
976
- const { ok, data } = await requestApi('/api/enable-2fa', { method: 'POST' }, confirmMessage, toggle2faBtn),
977
- { qrCode, secret } = data;
978
- if (ok && qrCode && secret) qrCodeImg.src = qrCode, secretCode.textContent = secret, verifyTokenInput.focus();
979
- else enable2faPanel.hidden = true;
980
- },
981
- hideEnable2faPanel = () => {
982
- toggle2faBtn.disabled = false, enable2faPanel.hidden = true, verifyTokenInput.value = '', hideMessage(confirmMessage);
983
- },
984
- refreshTwofaUI = () => {
985
- toggle2faBtn.disabled = false;
986
- const isEnabled = currentUser?.twoFactorEnabled;
987
- twofaCheckmark.innerHTML = isEnabled ? '✅' : '', twofaCheckmark.title = isEnabled ? '已启用双因素认证' : '';
988
- toggle2faBtn.textContent = isEnabled ? '关闭2FA' : '启用2FA', toggle2faBtn.classList.toggle('btn-danger', isEnabled);
989
- if (isEnabled) manage2faPanel.hidden = false, hideBackupPanel();
990
- else manage2faPanel.hidden = true, hideMessage(confirmMessage);
991
- };
1176
+ // 3. 备份码管理
1177
+ toggleBackupPanelBtn.addEventListener('click', async () => {
1178
+ saveBackupFileBtn.disabled = false, printBackupCodesBtn.disabled = false, toggleBackupPanelBtn.disabled = true;
1179
+ toggleBackupPanelBtn.textContent = '加载中...';
992
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);
1204
+
1205
+ // 4. 2FA认证
993
1206
  toggle2faBtn.addEventListener('click', async () => {
994
1207
  const isEnabled = currentUser?.twoFactorEnabled;
995
1208
  if (!isEnabled) {
@@ -997,9 +1210,14 @@
997
1210
  else hideEnable2faPanel();
998
1211
  } else {
999
1212
  if (!confirm('确定关闭双因素认证吗?关闭后账户安全性将降低;')) return;
1000
- const { ok, data } = await requestApi('/api/disable-2fa', { method: 'POST' }, null, toggle2faBtn);
1001
- if (ok) await loadUser();
1002
- 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 }
1003
1221
  }
1004
1222
  });
1005
1223
  cancelEnable2faBtn.addEventListener('click', hideEnable2faPanel);
@@ -1007,149 +1225,25 @@
1007
1225
  const token = verifyTokenInput.value.trim();
1008
1226
  if (!token) return showMessage(confirmMessage, 'error', '请输入6位验证码');
1009
1227
  const { ok, data } = await requestApi('/api/confirm-2fa', {
1010
- method: 'POST',
1011
- headers: { 'Content-Type': 'application/json' },
1228
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1012
1229
  body: JSON.stringify({ token })
1013
1230
  }, confirmMessage, confirm2faBtn);
1231
+
1014
1232
  if (ok && data.backupCodes) {
1015
1233
  await loadUser(), renderBackupCodes(data.backupCodes), hideEnable2faPanel();
1016
1234
  setTimeout(() => hideMessage(confirmMessage), 3000);
1017
- } else verifyTokenInput.value = '', verifyTokenInput.focus();
1018
- });
1019
- showBackupBtn.addEventListener('click', async () => {
1020
- saveBackupFileBtn.disabled = false, printBackupCodesBtn.disabled = false;
1021
- showBackupBtn.disabled = true, showBackupBtn.textContent = '加载中...', hideMessage(backupManageMessage);
1022
- try {
1023
- const isPanelVisible = !backupCodesPanel.hidden;
1024
- if (isPanelVisible) return hideBackupPanel(), showBackupBtn.disabled = false;
1025
- if (!confirm('重新生成备用码,将会使旧备用码失效,是否继续?')) return showBackupBtn.disabled = false;
1026
- backupCodesPanel.hidden = false;
1027
- const result = await requestApi('/api/regenerate-backup-codes', { method: 'POST' }, backupManageMessage, showBackupBtn);
1028
- if (result.ok && result.data.backupCodes) renderBackupCodes(result.data.backupCodes);
1029
- else backupCodesPanel.hidden = true, backupCodesList.innerHTML = '', syncBackupButtonState();
1030
- } catch (err) {
1031
- backupCodesPanel.hidden = true, backupCodesList.innerHTML = '', syncBackupButtonState();
1235
+ } else if (ok) {
1236
+ await loadUser(), hideEnable2faPanel(), showMessage(confirmMessage, 'success', '2FA 已启用');
1237
+ setTimeout(() => hideMessage(confirmMessage), 3000);
1032
1238
  }
1239
+ else verifyTokenInput.value = '', verifyTokenInput.focus();
1033
1240
  });
1034
- saveBackupFileBtn.addEventListener('click', () => {
1035
- if (latestBackupCodes.length) saveBackupCodesToFile(latestBackupCodes, `${currentUser?.username}*2FA备用码`);
1036
- else alert('没有备份码可保存');
1037
- });
1038
- printBackupCodesBtn.addEventListener('click', () => {
1039
- if (latestBackupCodes.length) printBackupCodes(latestBackupCodes, `${currentUser?.username}*2FA备用码`);
1040
- else alert('没有备份码可打印');
1041
- });
1042
-
1043
- // ==================== WebAuthn 硬件验证模块 ====================
1044
- const loadWebAuthnData = async () => {
1045
- try {
1046
- const { ok, data } = await requestApi('/api/webauthn/credentials', { method: 'GET' }, null, null, true);
1047
- if (ok) currentWebAuthnCredentials = data.credentials || [];
1048
- else throw new Error(data.message);
1049
- } catch (err) {
1050
- currentWebAuthnCredentials = [];
1051
- const targetMsg = manageWebAuthnPanel.hidden ? webauthnMessage : webauthnManageMessage;
1052
- showMessage(targetMsg, 'error', '加载硬件凭证失败,请刷新页面重试');
1053
- } finally { refreshWebAuthnUI(); }
1054
- },
1055
- renderCredentialsList = () => {
1056
- if (currentWebAuthnCredentials.length === 0) return noDeviceMsg.style.display = 'block';
1057
-
1058
- webauthnDeviceList.innerHTML = '', noDeviceMsg.style.display = 'none';
1059
- currentWebAuthnCredentials.forEach((cred, idx) => {
1060
- const li = deviceItemExample.cloneNode(true), nameSpan = li.querySelector('.device-name'),
1061
- delBtn = li.querySelector('button'), time = new Date(cred.createdAt).toLocaleString();
1062
1241
 
1063
- nameSpan.textContent = `🔑 ${cred.deviceName} 硬件${idx + 1}(添加于${time})`;
1064
- li.style.display = 'flex', webauthnDeviceList.append(li);
1065
- delBtn.addEventListener('click', async e => {
1066
- e.preventDefault();
1067
- if (!confirm('确定删除此设备吗?删除后将无法用于登录验证;')) return;
1068
- const { ok } = await requestApi('/api/webauthn/credentials/delete',
1069
- {
1070
- method: 'POST',
1071
- headers: { 'Content-Type': 'application/json' },
1072
- body: JSON.stringify({ credentialId: cred.id })
1073
- }, webauthnManageMessage, delBtn);
1074
- if (ok) await loadUser(), await loadWebAuthnData();
1075
- });
1076
- });
1077
- },
1078
- refreshWebAuthnUI = () => {
1079
- hideMessage(webauthnMessage), hideMessage(webauthnManageMessage);
1080
- const isEnabled = currentUser?.webauthnEnabled === true;
1081
- webauthnStatusIcon.innerHTML = isEnabled ? '✅' : '';
1082
- toggleWebAuthnBtn.textContent = isEnabled ? '关闭硬件验证' : '启用硬件验证';
1083
- toggleWebAuthnBtn.classList.toggle('btn-danger', isEnabled);
1084
- toggleWebAuthnBtn.disabled = false, manageAddDeviceBtn.disabled = false;
1085
- if (isEnabled) webauthnMessage.hidden = true, manageWebAuthnPanel.hidden = false, renderCredentialsList();
1086
- else {
1087
- manageWebAuthnPanel.hidden = true, webauthnMessage.hidden = false;
1088
- webauthnDeviceList.innerHTML = '', noDeviceMsg.style.display = 'block';
1089
- }
1090
- },
1091
- startAddDeviceAndEnableIfNeeded = async () => {
1092
- hideMessage(webauthnMessage), hideMessage(webauthnManageMessage);
1093
- try {
1094
- const beginResult = await requestApi('/api/webauthn/register/begin', { method: 'POST' }, webauthnMessage, null, true);
1095
- if (!beginResult.ok) return;
1096
- const options = beginResult.data, attResp = await flunWebAuthnBrowser.startRegistration({ optionsJSON: options }),
1097
- completeResult = await requestApi('/api/webauthn/register/complete', {
1098
- method: 'POST',
1099
- headers: { 'Content-Type': 'application/json' },
1100
- body: JSON.stringify({ attestationResponse: attResp })
1101
- }, webauthnMessage);
1102
- if (!completeResult.ok) return;
1103
- await loadUser(), await loadWebAuthnData();
1104
- if (!currentUser?.webauthnEnabled) {
1105
- const { ok, data } = await requestApi('/api/webauthn/toggle', { method: 'POST' }, webauthnMessage);
1106
- if (ok) await loadUser();
1107
- }
1108
- } catch (err) {
1109
- if (err.name === 'AbortError' || err.name === 'NotAllowedError') return;
1110
- let errorMsg = '添加失败:';
1111
- if (err.name === 'NotAllowedError') errorMsg += '操作被拒绝或超时,请确保使用 HTTPS 且设备已配置';
1112
- else errorMsg += '未知错误';
1113
- showMessage(webauthnMessage, 'error', errorMsg);
1114
- }
1115
- },
1116
- toggleWebAuthn = async () => {
1117
- const targetState = !currentUser?.webauthnEnabled;
1118
- if (!targetState) {
1119
- if (!confirm('确定关闭硬件验证吗?关闭后登录时不再需要硬件验证;')) return;
1120
- const oldUser = { ...currentUser };
1121
- if (currentUser) currentUser.webauthnEnabled = false;
1122
- refreshWebAuthnUI();
1123
- const { ok, data } = await requestApi('/api/webauthn/toggle', { method: 'POST' }, null, toggleWebAuthnBtn);
1124
- if (ok) await loadUser();
1125
- else {
1126
- if (currentUser) currentUser.webauthnEnabled = oldUser?.webauthnEnabled;
1127
- refreshWebAuthnUI(), alert(data.message);
1128
- }
1129
- return;
1130
- }
1131
- if (currentWebAuthnCredentials.length > 0) {
1132
- const { ok } = await requestApi('/api/webauthn/toggle', { method: 'POST' }, webauthnMessage, toggleWebAuthnBtn);
1133
- if (ok) await loadUser();
1134
- } else {
1135
- try { await startAddDeviceAndEnableIfNeeded() }
1136
- finally { toggleWebAuthnBtn.disabled = false }
1137
- }
1138
- };
1242
+ // 5. WebAuthn 硬件验证
1139
1243
  toggleWebAuthnBtn.addEventListener('click', toggleWebAuthn);
1140
- manageAddDeviceBtn.addEventListener('click', startAddDeviceAndEnableIfNeeded);
1141
-
1142
- // ==================== 注销账户模块 ====================
1143
- const toggleDeleteForm = show => {
1144
- if (show) {
1145
- showDeleteAccountBtn.hidden = true, deleteAccountForm.hidden = false;
1146
- setTimeout(() => deleteAccountPassword.focus(), 50), hideMessage(deleteAccountMessage);
1147
- } else {
1148
- showDeleteAccountBtn.hidden = false, deleteAccountForm.hidden = true, deleteAccountPassword.value = '';
1149
- hideMessage(deleteAccountMessage), showDeleteAccountBtn.focus();
1150
- }
1151
- };
1244
+ manageAddDeviceBtn.addEventListener('click', () => addWebAuthnDevice());
1152
1245
 
1246
+ // 6. 注销账户
1153
1247
  showDeleteAccountBtn.addEventListener('click', () => toggleDeleteForm(true));
1154
1248
  cancelDeleteBtn.addEventListener('click', () => toggleDeleteForm(false));
1155
1249
  deleteAccountBtn.addEventListener('click', async () => {
@@ -1158,36 +1252,26 @@
1158
1252
  if (!confirm('您确定要永久注销账户吗?此操作不可撤销,所有数据将被删除!!!')) return;
1159
1253
 
1160
1254
  const { ok } = await requestApi('/api/delete-account', {
1161
- method: 'POST',
1162
- headers: { 'Content-Type': 'application/json' },
1255
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1163
1256
  body: JSON.stringify({ password: pwd })
1164
1257
  }, deleteAccountMessage, deleteAccountBtn);
1165
1258
  if (ok) setTimeout(() => window.location.href = '/login', 2000);
1166
1259
  });
1260
+
1261
+ // 7. 退出登录
1167
1262
  logoutBtn.addEventListener('click', async () => {
1168
1263
  const { ok } = await requestApi('/api/logout', { method: 'POST' }, null, logoutBtn);
1169
1264
  if (ok) window.location.href = '/login';
1170
1265
  else alert('退出失败,请重试');
1171
1266
  });
1172
1267
 
1173
- // ==================== 初始化及加载 ====================
1174
- const loadUser = async () => {
1175
- try {
1176
- const { ok, data } = await requestApi('/api/user', { method: 'GET' }, null, null, true);
1177
- if (!ok || [401, 404].includes(data?.status ?? data?.code)) return window.location.href = '/login';
1178
- currentUser = data;
1179
- const { username, email, emailVerified, createdAt } = currentUser;
1180
- usernameEl.textContent = username, emailEl.textContent = email ?? '未设置';
1181
- emailVerifiedBadge.textContent = emailVerified ? '✅' : '';
1182
- createdAtEl.textContent = new Date(createdAt).toLocaleString(), await Promise.all([refreshTwofaUI(), loadWebAuthnData()]);
1183
- } catch (err) { alert('加载用户信息失败,请刷新页面重试'); }
1184
- },
1185
- enterHandlers = [
1186
- { input: profileCurrentPasswordInput, button: updateProfileBtn },
1187
- { input: confirmPasswordInput, button: changePasswordBtn },
1188
- { input: deleteAccountPassword, button: deleteAccountBtn },
1189
- { input: verifyTokenInput, button: confirm2faBtn }
1190
- ],
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
+ ],
1191
1275
  clearMessageMappings = [
1192
1276
  { messageEl: updateProfileMessage, inputs: [newUsernameInput, newEmailInput, profileCurrentPasswordInput] },
1193
1277
  { messageEl: changePasswordMessage, inputs: [currentPasswordInput, newPasswordInput, confirmPasswordInput] },
@@ -1208,8 +1292,7 @@
1208
1292
  if (e.key === 'Enter') e.preventDefault(), button.click();
1209
1293
  });
1210
1294
  });
1211
-
1212
- window.addEventListener('beforeunload', stopEmailPolling), setupInputClearMessage(), loadUser();
1295
+ setupInputClearMessage(), window.addEventListener('beforeunload', stopEmailPolling), loadUser();
1213
1296
  </script>
1214
1297
  </body>
1215
1298