vk-ssl-auto-deploy 0.7.3 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/views/admin.ejs CHANGED
@@ -1,1318 +1,1373 @@
1
- <!DOCTYPE html>
2
- <html lang="zh-CN">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title><%= title %></title>
7
- <style>
8
- * {
9
- margin: 0;
10
- padding: 0;
11
- box-sizing: border-box;
12
- }
13
-
14
- body {
15
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
16
- background: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%);
17
- color: #e0e0e0;
18
- min-height: 100vh;
19
- padding: 20px;
20
- }
21
-
22
- .container {
23
- max-width: 1400px;
24
- margin: 0 auto;
25
- }
26
-
27
- header {
28
- text-align: center;
29
- margin-bottom: 40px;
30
- padding: 30px 0;
31
- }
32
-
33
- h1 {
34
- font-size: 2.5em;
35
- font-weight: 700;
36
- background: linear-gradient(135deg, #00ff88 0%, #00cc70 100%);
37
- -webkit-background-clip: text;
38
- -webkit-text-fill-color: transparent;
39
- background-clip: text;
40
- margin-bottom: 10px;
41
- }
42
-
43
- .subtitle {
44
- color: #888;
45
- font-size: 1.1em;
46
- }
47
-
48
- .grid {
49
- display: grid;
50
- grid-template-columns: 1fr 1fr;
51
- gap: 20px;
52
- margin-bottom: 20px;
53
- }
54
-
55
- .card {
56
- background: rgba(30, 30, 30, 0.95);
57
- border-radius: 12px;
58
- padding: 25px;
59
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
60
- border: 1px solid rgba(255, 255, 255, 0.05);
61
- transition: transform 0.2s, box-shadow 0.2s;
62
- }
63
-
64
- .card:hover {
65
- transform: translateY(-2px);
66
- box-shadow: 0 12px 40px rgba(0, 255, 136, 0.1);
67
- }
68
-
69
- .card-title {
70
- font-size: 1.5em;
71
- margin-bottom: 20px;
72
- color: #00ff88;
73
- display: flex;
74
- align-items: center;
75
- gap: 10px;
76
- }
77
-
78
- .card-title::before {
79
- content: '▶';
80
- font-size: 0.8em;
81
- }
82
-
83
- .form-group {
84
- margin-bottom: 20px;
85
- }
86
-
87
- label {
88
- display: block;
89
- margin-bottom: 8px;
90
- color: #aaa;
91
- font-size: 0.95em;
92
- font-weight: 500;
93
- }
94
-
95
- input[type="text"],
96
- textarea {
97
- width: 100%;
98
- padding: 12px 15px;
99
- background: rgba(20, 20, 20, 0.8);
100
- border: 1px solid rgba(255, 255, 255, 0.1);
101
- border-radius: 8px;
102
- color: #e0e0e0;
103
- font-size: 0.95em;
104
- transition: all 0.3s;
105
- font-family: 'Consolas', 'Monaco', monospace;
106
- }
107
-
108
- input[type="text"]:focus,
109
- textarea:focus {
110
- outline: none;
111
- border-color: #00ff88;
112
- box-shadow: 0 0 0 3px rgba(0, 255, 136, 0.1);
113
- }
114
-
115
- textarea {
116
- min-height: 100px;
117
- resize: vertical;
118
- }
119
-
120
- .hint {
121
- font-size: 0.85em;
122
- color: #666;
123
- margin-top: 5px;
124
- }
125
-
126
- .button-group {
127
- display: flex;
128
- gap: 10px;
129
- margin-top: 25px;
130
- }
131
-
132
- button {
133
- flex: 1;
134
- padding: 14px 24px;
135
- background: linear-gradient(135deg, #00ff88 0%, #00cc70 100%);
136
- color: #000;
137
- border: none;
138
- border-radius: 8px;
139
- font-size: 1em;
140
- font-weight: 600;
141
- cursor: pointer;
142
- transition: all 0.3s;
143
- box-shadow: 0 4px 15px rgba(0, 255, 136, 0.3);
144
- }
145
-
146
- button:hover {
147
- transform: translateY(-2px);
148
- box-shadow: 0 6px 20px rgba(0, 255, 136, 0.4);
149
- }
150
-
151
- button:active {
152
- transform: translateY(0);
153
- }
154
-
155
- button:disabled {
156
- background: #333;
157
- color: #666;
158
- cursor: not-allowed;
159
- box-shadow: none;
160
- }
161
-
162
- .btn-secondary {
163
- background: linear-gradient(135deg, #444 0%, #333 100%);
164
- color: #e0e0e0;
165
- box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
166
- }
167
-
168
- .btn-secondary:hover {
169
- box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
170
- }
171
-
172
- .full-width {
173
- grid-column: 1 / -1;
174
- }
175
-
176
- /* 证书列表容器添加滚动 */
177
- #certListContainer {
178
- max-height: 600px;
179
- overflow-y: auto;
180
- }
181
-
182
- #certListContainer::-webkit-scrollbar {
183
- width: 8px;
184
- }
185
-
186
- #certListContainer::-webkit-scrollbar-track {
187
- background: rgba(255, 255, 255, 0.05);
188
- border-radius: 4px;
189
- }
190
-
191
- #certListContainer::-webkit-scrollbar-thumb {
192
- background: rgba(0, 255, 136, 0.3);
193
- border-radius: 4px;
194
- }
195
-
196
- #certListContainer::-webkit-scrollbar-thumb:hover {
197
- background: rgba(0, 255, 136, 0.5);
198
- }
199
-
200
- table {
201
- width: 100%;
202
- border-collapse: collapse;
203
- margin-top: 15px;
204
- }
205
-
206
- thead {
207
- background: rgba(0, 255, 136, 0.1);
208
- }
209
-
210
- th, td {
211
- padding: 12px 15px;
212
- text-align: left;
213
- border-bottom: 1px solid rgba(255, 255, 255, 0.05);
214
- }
215
-
216
- th {
217
- color: #00ff88;
218
- font-weight: 600;
219
- font-size: 0.95em;
220
- }
221
-
222
- td {
223
- color: #ccc;
224
- font-size: 0.9em;
225
- }
226
-
227
- tbody tr:hover {
228
- background: rgba(255, 255, 255, 0.03);
229
- }
230
-
231
- .status-badge {
232
- display: inline-block;
233
- padding: 4px 12px;
234
- border-radius: 12px;
235
- font-size: 0.85em;
236
- font-weight: 600;
237
- }
238
-
239
- .status-valid {
240
- background: rgba(0, 255, 136, 0.2);
241
- color: #00ff88;
242
- }
243
-
244
- .status-warning {
245
- background: rgba(255, 193, 7, 0.2);
246
- color: #ffc107;
247
- }
248
-
249
- .status-expired {
250
- background: rgba(244, 67, 54, 0.2);
251
- color: #f44336;
252
- }
253
-
254
- .log-container {
255
- background: #0a0a0a;
256
- border-radius: 12px;
257
- padding: 20px;
258
- height: 500px;
259
- overflow-y: auto;
260
- font-family: 'Consolas', 'Monaco', monospace;
261
- font-size: 0.9em;
262
- line-height: 1.6;
263
- border: 1px solid rgba(0, 255, 136, 0.2);
264
- box-shadow: inset 0 2px 10px rgba(0, 0, 0, 0.5);
265
- }
266
-
267
- .log-container::-webkit-scrollbar {
268
- width: 8px;
269
- }
270
-
271
- .log-container::-webkit-scrollbar-track {
272
- background: rgba(255, 255, 255, 0.05);
273
- border-radius: 4px;
274
- }
275
-
276
- .log-container::-webkit-scrollbar-thumb {
277
- background: rgba(0, 255, 136, 0.3);
278
- border-radius: 4px;
279
- }
280
-
281
- .log-container::-webkit-scrollbar-thumb:hover {
282
- background: rgba(0, 255, 136, 0.5);
283
- }
284
-
285
- .log-line {
286
- margin-bottom: 4px;
287
- white-space: pre-wrap;
288
- word-break: break-all;
289
- }
290
-
291
- .log-info { color: #e0e0e0; }
292
- .log-success { color: #00ff88; }
293
- .log-warning { color: #ffc107; }
294
- .log-error { color: #f44336; }
295
- .log-dim { color: #666; }
296
-
297
- .status-indicator {
298
- display: inline-block;
299
- width: 10px;
300
- height: 10px;
301
- border-radius: 50%;
302
- margin-right: 8px;
303
- animation: pulse 2s infinite;
304
- }
305
-
306
- .status-connected {
307
- background: #00ff88;
308
- }
309
-
310
- .status-disconnected {
311
- background: #f44336;
312
- }
313
-
314
- @keyframes pulse {
315
- 0%, 100% { opacity: 1; }
316
- 50% { opacity: 0.5; }
317
- }
318
-
319
- .empty-state {
320
- text-align: center;
321
- padding: 40px;
322
- color: #666;
323
- }
324
-
325
- .loading {
326
- text-align: center;
327
- padding: 20px;
328
- color: #888;
329
- }
330
-
331
- .action-buttons {
332
- display: flex;
333
- gap: 10px;
334
- justify-content: flex-end;
335
- margin-bottom: 15px;
336
- }
337
-
338
- .action-buttons button {
339
- flex: none;
340
- padding: 10px 20px;
341
- }
342
-
343
- /* 操作面板按钮组 */
344
- .operation-buttons {
345
- display: grid;
346
- grid-template-columns: 1fr 1fr;
347
- gap: 15px;
348
- margin-top: 20px;
349
- }
350
-
351
- .operation-buttons button {
352
- width: 100%;
353
- height: 60px;
354
- font-size: 1em;
355
- }
356
-
357
- /* 弹窗样式 */
358
- .modal {
359
- display: none;
360
- position: fixed;
361
- z-index: 1000;
362
- left: 0;
363
- top: 0;
364
- width: 100%;
365
- height: 100%;
366
- background: rgba(0, 0, 0, 0.8);
367
- backdrop-filter: blur(5px);
368
- }
369
-
370
- .modal.show {
371
- display: flex;
372
- align-items: center;
373
- justify-content: center;
374
- }
375
-
376
- .modal-content {
377
- background: rgba(30, 30, 30, 0.98);
378
- border-radius: 16px;
379
- padding: 30px;
380
- max-width: 600px;
381
- width: 90%;
382
- max-height: 90vh;
383
- overflow-y: auto;
384
- box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
385
- border: 1px solid rgba(0, 255, 136, 0.2);
386
- }
387
-
388
- .modal-header {
389
- display: flex;
390
- justify-content: space-between;
391
- align-items: center;
392
- margin-bottom: 25px;
393
- }
394
-
395
- .modal-title {
396
- font-size: 1.8em;
397
- color: #00ff88;
398
- }
399
-
400
- .close-btn {
401
- background: none;
402
- border: none;
403
- color: #888;
404
- font-size: 2em;
405
- cursor: pointer;
406
- padding: 0;
407
- width: 40px;
408
- height: 40px;
409
- display: flex;
410
- align-items: center;
411
- justify-content: center;
412
- border-radius: 8px;
413
- transition: all 0.3s;
414
- }
415
-
416
- .close-btn:hover {
417
- background: rgba(255, 255, 255, 0.1);
418
- color: #fff;
419
- transform: none;
420
- }
421
-
422
- /* 搜索框样式 */
423
- .search-box {
424
- margin-bottom: 15px;
425
- }
426
-
427
- .search-box input {
428
- width: 100%;
429
- padding: 12px 15px;
430
- background: rgba(20, 20, 20, 0.8);
431
- border: 1px solid rgba(255, 255, 255, 0.1);
432
- border-radius: 8px;
433
- color: #e0e0e0;
434
- font-size: 0.95em;
435
- }
436
-
437
- .search-box input:focus {
438
- outline: none;
439
- border-color: #00ff88;
440
- box-shadow: 0 0 0 3px rgba(0, 255, 136, 0.1);
441
- }
442
-
443
- .info-item {
444
- display: flex;
445
- align-items: center;
446
- gap: 10px;
447
- padding: 12px 0;
448
- border-bottom: 1px solid rgba(255, 255, 255, 0.05);
449
- }
450
-
451
- .info-label {
452
- color: #888;
453
- min-width: 120px;
454
- }
455
-
456
- .info-value {
457
- color: #00ff88;
458
- font-weight: 500;
459
- font-family: 'Consolas', 'Monaco', monospace;
460
- }
461
-
462
- .countdown {
463
- font-size: 1.5em;
464
- color: #00ff88;
465
- font-weight: 600;
466
- font-family: 'Consolas', 'Monaco', monospace;
467
- }
468
-
469
- /* 自定义确认对话框 */
470
- .custom-dialog {
471
- display: none;
472
- position: fixed;
473
- z-index: 2000;
474
- left: 0;
475
- top: 0;
476
- width: 100%;
477
- height: 100%;
478
- background: rgba(0, 0, 0, 0.85);
479
- backdrop-filter: blur(8px);
480
- align-items: center;
481
- justify-content: center;
482
- }
483
-
484
- .custom-dialog.show {
485
- display: flex;
486
- }
487
-
488
- .dialog-content {
489
- background: rgba(30, 30, 30, 0.98);
490
- border-radius: 16px;
491
- padding: 30px;
492
- max-width: 450px;
493
- width: 90%;
494
- box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
495
- border: 1px solid rgba(0, 255, 136, 0.2);
496
- }
497
-
498
- @keyframes dialogSlideIn {
499
- from {
500
- opacity: 0;
501
- transform: translateY(-20px) scale(0.95);
502
- }
503
- to {
504
- opacity: 1;
505
- transform: translateY(0) scale(1);
506
- }
507
- }
508
-
509
- .dialog-title {
510
- font-size: 1.5em;
511
- color: #00ff88;
512
- margin-bottom: 15px;
513
- display: flex;
514
- align-items: center;
515
- gap: 10px;
516
- }
517
-
518
- .dialog-message {
519
- color: #ccc;
520
- line-height: 1.6;
521
- margin-bottom: 25px;
522
- font-size: 1.05em;
523
- }
524
-
525
- .dialog-buttons {
526
- display: flex;
527
- gap: 10px;
528
- justify-content: flex-end;
529
- }
530
-
531
- .dialog-buttons button {
532
- padding: 12px 30px;
533
- min-width: 100px;
534
- }
535
-
536
- @media (max-width: 1024px) {
537
- .grid {
538
- grid-template-columns: 1fr;
539
- }
540
- }
541
- </style>
542
- </head>
543
- <body>
544
- <div class="container">
545
- <header>
546
- <h1><%= title %></h1>
547
- <p class="subtitle">SSL证书自动部署管理系统</p>
548
- <p class="subtitle" style="font-size: 12px;margin-top: 6px;">如有问题,请联系QQ:370725567</p>
549
- </header>
550
-
551
- <div class="grid">
552
- <!-- 操作面板 -->
553
- <div class="card">
554
- <h2 class="card-title">操作面板</h2>
555
- <div class="operation-buttons">
556
- <button onclick="openConfigModal()">
557
- 配置管理
558
- </button>
559
- <button onclick="executeUpdate()">
560
- 更新证书
561
- </button>
562
- </div>
563
-
564
- <div style="margin-top: 30px; padding-top: 30px; border-top: 1px solid rgba(255, 255, 255, 0.05);">
565
- <!-- 这里显示服务器,不是WebSocket 是为了方便用户理解 -->
566
- <h3 style="color: #00ff88; margin-bottom: 15px; font-size: 1.2em;">服务器 连接状态</h3>
567
- <p style="display: flex; align-items: center; font-size: 1em;">
568
- <span class="status-indicator" id="wsStatus"></span>
569
- <span id="wsStatusText">正在连接...</span>
570
- </p>
571
- </div>
572
- </div>
573
-
574
- <!-- 定时任务状态 -->
575
- <div class="card">
576
- <h2 class="card-title">定时任务状态</h2>
577
- <div style="margin-top: 20px;">
578
- <div class="info-item">
579
- <span class="info-label">执行时间:</span>
580
- <span class="info-value" id="scheduleTime">--:--</span>
581
- </div>
582
- <div class="info-item">
583
- <span class="info-label">下次执行:</span>
584
- <span class="countdown" id="countdown">--</span>
585
- </div>
586
- <div class="info-item" style="border-bottom: none;">
587
- <span class="info-label">任务状态:</span>
588
- <span id="taskStatus" style="color: #888;">空闲中</span>
589
- </div>
590
- </div>
591
- </div>
592
-
593
- <!-- 本地证书列表 -->
594
- <div class="card full-width">
595
- <h2 class="card-title">本地证书列表</h2>
596
- <div class="search-box">
597
- <input type="text" id="certSearch" placeholder="🔍 搜索证书域名...">
598
- </div>
599
- <div class="action-buttons">
600
- <button class="btn-secondary" onclick="loadCertList()"> 刷新证书列表</button>
601
- </div>
602
- <div id="certListContainer">
603
- <div class="loading">加载中...</div>
604
- </div>
605
- </div>
606
-
607
- <!-- 实时日志 -->
608
- <div class="card full-width">
609
- <h2 class="card-title">运行日志</h2>
610
- <div class="action-buttons">
611
- <button class="btn-secondary" onclick="clearLogs()">清空运行日志</button>
612
- </div>
613
- <div class="log-container" id="logContainer">
614
- <div class="log-line log-dim">等待日志输出...</div>
615
- </div>
616
- </div>
617
- </div>
618
- </div>
619
-
620
- <!-- 自定义确认对话框 -->
621
- <div id="customDialog" class="custom-dialog">
622
- <div class="dialog-content">
623
- <div class="dialog-title" id="dialogTitle">
624
- <span id="dialogIcon">ℹ️</span>
625
- <span id="dialogTitleText">提示</span>
626
- </div>
627
- <div class="dialog-message" id="dialogMessage">消息内容</div>
628
- <div class="dialog-buttons" id="dialogButtons"></div>
629
- </div>
630
- </div>
631
-
632
- <!-- 密码输入弹窗 -->
633
- <div id="passwordModal" class="modal" style="z-index: 3000;">
634
- <div class="modal-content" style="max-width: 400px;">
635
- <div class="modal-header">
636
- <div>
637
- <h2 class="modal-title">🔐 身份验证</h2>
638
- </div>
639
- </div>
640
- <form id="passwordForm">
641
- <div class="form-group">
642
- <label for="passwordInput">请输入访问口令</label>
643
- <input type="text" id="passwordInput" name="password" required autocomplete="off" placeholder="请输入口令">
644
- <div class="hint" style="color: #f44336;" id="passwordError"></div>
645
- </div>
646
- <div class="button-group">
647
- <button type="submit">确定</button>
648
- </div>
649
- </form>
650
- </div>
651
- </div>
652
-
653
- <!-- 配置管理弹窗 -->
654
- <div id="configModal" class="modal">
655
- <div class="modal-content">
656
- <div class="modal-header">
657
- <div>
658
- <h2 class="modal-title">配置管理</h2>
659
- </div>
660
- <div>
661
- <button class="close-btn" onclick="closeConfigModal()">&times;</button>
662
- </div>
663
- </div>
664
- <form id="configForm">
665
- <div class="form-group">
666
- <label for="key">API Key *</label>
667
- <input type="text" id="key" name="key" required>
668
- <div class="hint">无忧SSL的API密钥</div>
669
- </div>
670
- <div class="form-group">
671
- <label for="certSaveDir">证书保存目录 *</label>
672
- <input type="text" id="certSaveDir" name="certSaveDir" required>
673
- <div class="hint">证书文件保存的本地路径</div>
674
- </div>
675
- <div class="form-group">
676
- <label for="nginxDir">Nginx路径</label>
677
- <input type="text" id="nginxDir" name="nginxDir">
678
- <div class="hint">Nginx可执行文件路径(留空则不重载)</div>
679
- </div>
680
- <div class="form-group">
681
- <label for="domains">域名过滤(每行一个)</label>
682
- <textarea id="domains" name="domains"></textarea>
683
- <div class="hint">只处理这些域名的证书,留空则处理所有</div>
684
- </div>
685
- <div class="form-group">
686
- <label for="callbackCommand">回调命令(每行一个)</label>
687
- <textarea id="callbackCommand" name="callbackCommand"></textarea>
688
- <div class="hint">证书更新后执行的命令</div>
689
- </div>
690
- <div class="button-group">
691
- <button type="button" class="btn-secondary" onclick="closeConfigModal()">取消</button>
692
- <button type="submit">保存配置</button>
693
- </div>
694
- </form>
695
- </div>
696
- </div>
697
-
698
- <script>
699
- // 配置常量
700
- const MAX_LOG_LINES = 500; // 日志最大条数
701
- const PASSWORD_KEY = 'wuyoussl-password'; // localStorage 中的密码键名
702
-
703
- let ws = null;
704
- let reconnectTimer = null;
705
- let scheduleInfoTimer = null;
706
- let allCerts = []; // 存储所有证书数据
707
- let serverTimeOffset = 0; // 服务器时间偏移(毫秒)
708
- let nextExecutionTime = null; // 下次执行时间
709
- let isAuthenticated = false; // 是否已通过身份验证
710
-
711
- // 获取存储的密码
712
- function getStoredPassword() {
713
- return localStorage.getItem(PASSWORD_KEY);
714
- }
715
-
716
- // 保存密码到本地存储
717
- function savePassword(password) {
718
- localStorage.setItem(PASSWORD_KEY, password);
719
- }
720
-
721
- // 清除存储的密码
722
- function clearPassword() {
723
- localStorage.removeItem(PASSWORD_KEY);
724
- isAuthenticated = false;
725
- }
726
-
727
- // 显示密码输入框
728
- function showPasswordModal() {
729
- const modal = document.getElementById('passwordModal');
730
- modal.classList.add('show');
731
- document.getElementById('passwordInput').value = '';
732
- document.getElementById('passwordError').textContent = '';
733
- document.getElementById('passwordInput').focus();
734
- }
735
-
736
- // 隐藏密码输入框
737
- function hidePasswordModal() {
738
- document.getElementById('passwordModal').classList.remove('show');
739
- }
740
-
741
- // 验证密码
742
- async function verifyPassword(password) {
743
- try {
744
- const response = await fetch('/admin/api/verify-password', {
745
- method: 'POST',
746
- headers: {
747
- 'Content-Type': 'application/json',
748
- 'X-Password': password
749
- },
750
- body: JSON.stringify({ password })
751
- });
752
- const result = await response.json();
753
- return result.code === 0;
754
- } catch (error) {
755
- console.error('密码验证失败:', error);
756
- return false;
757
- }
758
- }
759
-
760
- // 处理密码提交
761
- document.addEventListener('DOMContentLoaded', function() {
762
- document.getElementById('passwordForm').addEventListener('submit', async function(e) {
763
- e.preventDefault();
764
- const password = document.getElementById('passwordInput').value;
765
- const errorEl = document.getElementById('passwordError');
766
-
767
- const isValid = await verifyPassword(password);
768
- if (isValid) {
769
- savePassword(password);
770
- isAuthenticated = true;
771
- hidePasswordModal();
772
- initializeApp();
773
- } else {
774
- errorEl.textContent = '口令错误,请重新输入';
775
- document.getElementById('passwordInput').value = '';
776
- document.getElementById('passwordInput').focus();
777
- }
778
- });
779
- });
780
-
781
- // 封装 fetch 请求,自动添加密码头
782
- async function authenticatedFetch(url, options = {}) {
783
- const password = getStoredPassword();
784
- if (!password) {
785
- showPasswordModal();
786
- throw new Error('未设置密码');
787
- }
788
-
789
- const headers = {
790
- ...options.headers,
791
- 'X-Password': password
792
- };
793
-
794
- const response = await fetch(url, { ...options, headers });
795
-
796
- // 如果返回 401,说明密码错误
797
- if (response.status === 401) {
798
- clearPassword();
799
- showPasswordModal();
800
- addLog('✗ 口令验证失败,请重新输入', 'error');
801
- throw new Error('密码验证失败');
802
- }
803
-
804
- return response;
805
- }
806
-
807
- // 初始化应用
808
- function initializeApp() {
809
- loadCertList();
810
- connectWebSocket();
811
- startScheduleInfoSync();
812
-
813
- // 搜索框事件
814
- document.getElementById('certSearch').addEventListener('input', filterCerts);
815
- }
816
-
817
- // 初始化
818
- document.addEventListener('DOMContentLoaded', function() {
819
- const storedPassword = getStoredPassword();
820
- if (storedPassword) {
821
- // 验证存储的密码是否仍然有效
822
- verifyPassword(storedPassword).then(isValid => {
823
- if (isValid) {
824
- isAuthenticated = true;
825
- initializeApp();
826
- } else {
827
- clearPassword();
828
- showPasswordModal();
829
- }
830
- });
831
- } else {
832
- showPasswordModal();
833
- }
834
- });
835
-
836
- // 打开配置弹窗
837
- function openConfigModal() {
838
- document.getElementById('configModal').classList.add('show');
839
- loadConfig();
840
- }
841
-
842
- // 关闭配置弹窗
843
- function closeConfigModal() {
844
- document.getElementById('configModal').classList.remove('show');
845
- }
846
-
847
- // 加载配置
848
- async function loadConfig() {
849
- try {
850
- const response = await authenticatedFetch('/admin/api/config');
851
- const result = await response.json();
852
- if (result.code === 0) {
853
- document.getElementById('key').value = result.data.key || '';
854
- document.getElementById('certSaveDir').value = result.data.certSaveDir || '';
855
- document.getElementById('nginxDir').value = result.data.nginxDir || '';
856
- document.getElementById('domains').value = (result.data.domains || []).join('\n');
857
- document.getElementById('callbackCommand').value = (result.data.callbackCommand || []).join('\n');
858
- }
859
- } catch (error) {
860
- if (error.message !== '未设置密码' && error.message !== '密码验证失败') {
861
- addLog('加载配置失败: ' + error.message, 'error');
862
- }
863
- }
864
- }
865
-
866
- // 保存配置
867
- document.getElementById('configForm').addEventListener('submit', async function(e) {
868
- e.preventDefault();
869
-
870
- const config = {
871
- key: document.getElementById('key').value.trim(),
872
- certSaveDir: document.getElementById('certSaveDir').value.trim(),
873
- nginxDir: document.getElementById('nginxDir').value.trim(),
874
- domains: document.getElementById('domains').value.split('\n').map(s => s.trim()).filter(Boolean),
875
- callbackCommand: document.getElementById('callbackCommand').value.split('\n').map(s => s.trim()).filter(Boolean)
876
- };
877
-
878
- try {
879
- const response = await authenticatedFetch('/admin/api/config', {
880
- method: 'POST',
881
- headers: { 'Content-Type': 'application/json' },
882
- body: JSON.stringify(config)
883
- });
884
- const result = await response.json();
885
-
886
- if (result.code === 0) {
887
- addLog('✓ 配置保存成功', 'success');
888
- showAlert('保存成功', '配置已成功保存!', 'success');
889
- closeConfigModal();
890
- } else {
891
- addLog('✗ 配置保存失败: ' + result.msg, 'error');
892
- showAlert('保存失败', result.msg, 'error');
893
- }
894
- } catch (error) {
895
- if (error.message !== '未设置密码' && error.message !== '密码验证失败') {
896
- addLog('✗ 配置保存失败: ' + error.message, 'error');
897
- showAlert('保存失败', error.message, 'error');
898
- }
899
- }
900
- });
901
-
902
- // 加载证书列表
903
- async function loadCertList() {
904
- const container = document.getElementById('certListContainer');
905
- container.innerHTML = '<div class="loading">加载中...</div>';
906
-
907
- try {
908
- const response = await authenticatedFetch('/admin/api/certs/local');
909
- const result = await response.json();
910
-
911
- if (result.code === 0) {
912
- allCerts = result.data;
913
- renderCerts(allCerts);
914
- addLog(`✓ 证书列表加载完成,共 ${allCerts.length} 个证书`, 'success');
915
- } else {
916
- container.innerHTML = '<div class="empty-state">加载失败: ' + escapeHtml(result.msg) + '</div>';
917
- }
918
- } catch (error) {
919
- if (error.message !== '未设置密码' && error.message !== '密码验证失败') {
920
- container.innerHTML = '<div class="empty-state">加载失败: ' + escapeHtml(error.message) + '</div>';
921
- addLog(' 加载证书列表失败: ' + error.message, 'error');
922
- }
923
- }
924
- }
925
-
926
- // 渲染证书列表
927
- function renderCerts(certs) {
928
- const container = document.getElementById('certListContainer');
929
-
930
- if (certs.length === 0) {
931
- container.innerHTML = '<div class="empty-state">暂无证书文件</div>';
932
- return;
933
- }
934
-
935
- // 按剩余天数排序(升序,越小越靠前)
936
- const sortedCerts = [...certs].sort((a, b) => a.remainingDays - b.remainingDays);
937
-
938
- let html = '<table><thead><tr>';
939
- html += '<th>域名</th>';
940
- html += '<th>过期时间</th>';
941
- html += '<th>剩余天数</th>';
942
- html += '<th>状态</th>';
943
- html += '</tr></thead><tbody>';
944
-
945
- sortedCerts.forEach(cert => {
946
- const statusClass = cert.status === 'valid' ? 'status-valid' :
947
- cert.status === 'warning' ? 'status-warning' :
948
- 'status-expired';
949
- const statusText = cert.status === 'valid' ? '正常' :
950
- cert.status === 'warning' ? '即将过期' :
951
- '已过期';
952
-
953
- html += '<tr>';
954
- html += `<td>${escapeHtml(cert.domain)}</td>`;
955
- html += `<td>${formatDate(cert.notAfter)}</td>`;
956
- html += `<td>${cert.remainingDays} 天</td>`;
957
- html += `<td><span class="status-badge ${statusClass}">${statusText}</span></td>`;
958
- html += '</tr>';
959
- });
960
-
961
- html += '</tbody></table>';
962
- container.innerHTML = html;
963
- }
964
-
965
- // 过滤证书
966
- function filterCerts() {
967
- const keyword = document.getElementById('certSearch').value.toLowerCase();
968
- if (!keyword) {
969
- renderCerts(allCerts);
970
- return;
971
- }
972
-
973
- const filtered = allCerts.filter(cert =>
974
- cert.fileName.toLowerCase().includes(keyword) ||
975
- cert.domain.toLowerCase().includes(keyword)
976
- );
977
-
978
- renderCerts(filtered);
979
- }
980
-
981
- // 执行证书更新
982
- async function executeUpdate() {
983
- const confirmed = await showConfirm('确定要立即更新所有证书吗?', '此操作将检查所有证书并更新即将过期的证书');
984
- if (!confirmed) {
985
- return;
986
- }
987
-
988
- addLog('→ 开始执行证书更新任务...', 'info');
989
-
990
- try {
991
- const response = await authenticatedFetch('/api/cert/execute');
992
- const result = await response.json();
993
-
994
- if (result.code === 0) {
995
- addLog('✓ 证书更新任务已启动', 'success');
996
- // 页面自动滚动到运行日志
997
- document.getElementById('logContainer').scrollIntoView({ behavior: 'smooth' });
998
- } else {
999
- addLog('✗ 启动失败: ' + result.msg, 'error');
1000
- showAlert('启动失败', result.msg, 'error');
1001
- }
1002
- } catch (error) {
1003
- if (error.message !== '未设置密码' && error.message !== '密码验证失败') {
1004
- addLog('✗ 执行失败: ' + error.message, 'error');
1005
- showAlert('执行失败', error.message, 'error');
1006
- }
1007
- }
1008
- }
1009
-
1010
- // 同步定时任务信息
1011
- function startScheduleInfoSync() {
1012
- // 立即同步一次
1013
- syncScheduleInfo();
1014
-
1015
- // 每分钟同步一次
1016
- scheduleInfoTimer = setInterval(syncScheduleInfo, 60000);
1017
-
1018
- // 每秒更新倒计时显示
1019
- setInterval(updateCountdown, 1000);
1020
- }
1021
-
1022
- // 同步服务器时间和定时任务信息
1023
- async function syncScheduleInfo() {
1024
- try {
1025
- const response = await authenticatedFetch('/api/cert/schedule-info');
1026
- const result = await response.json();
1027
-
1028
- if (result.code === 0) {
1029
- const data = result.data;
1030
-
1031
- // 计算时间偏移
1032
- const serverTime = new Date(data.serverTime).getTime();
1033
- const localTime = Date.now();
1034
- serverTimeOffset = serverTime - localTime;
1035
-
1036
- // 更新定时任务信息
1037
- if (data.scheduledExecutionTime) {
1038
- document.getElementById('scheduleTime').textContent = data.scheduledExecutionTime.formatted;
1039
- }
1040
-
1041
- // 保存下次执行时间
1042
- if (data.nextExecutionTime) {
1043
- nextExecutionTime = new Date(data.nextExecutionTime).getTime();
1044
- }
1045
-
1046
- // 更新任务状态
1047
- const taskStatus = document.getElementById('taskStatus');
1048
- if (data.isTaskRunning) {
1049
- taskStatus.textContent = '执行中...';
1050
- taskStatus.style.color = '#ffc107';
1051
- } else {
1052
- taskStatus.textContent = '空闲中';
1053
- taskStatus.style.color = '#888';
1054
- }
1055
- }
1056
- } catch (error) {
1057
- if (error.message !== '未设置密码' && error.message !== '密码验证失败') {
1058
- console.error('同步定时任务信息失败:', error);
1059
- }
1060
- }
1061
- }
1062
-
1063
- // 更新倒计时显示(始终显示到秒)
1064
- function updateCountdown() {
1065
- if (!nextExecutionTime) {
1066
- document.getElementById('countdown').textContent = '--';
1067
- return;
1068
- }
1069
-
1070
- // 使用校正后的本地时间
1071
- const now = Date.now() + serverTimeOffset;
1072
- const remaining = nextExecutionTime - now;
1073
-
1074
- if (remaining <= 0) {
1075
- document.getElementById('countdown').textContent = '即将执行';
1076
- return;
1077
- }
1078
-
1079
- const totalSeconds = Math.floor(remaining / 1000);
1080
- const hours = Math.floor(totalSeconds / 3600);
1081
- const minutes = Math.floor((totalSeconds % 3600) / 60);
1082
- const seconds = totalSeconds % 60;
1083
-
1084
- // 始终显示到秒
1085
- let text = '';
1086
- if (hours > 0) {
1087
- text = `${hours}小时 ${minutes}分钟 ${seconds}秒`;
1088
- } else if (minutes > 0) {
1089
- text = `${minutes}分钟 ${seconds}秒`;
1090
- } else {
1091
- text = `${seconds}秒`;
1092
- }
1093
-
1094
- document.getElementById('countdown').textContent = text;
1095
- }
1096
-
1097
- // WebSocket 连接
1098
- function connectWebSocket() {
1099
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
1100
- const wsUrl = `${protocol}//${window.location.host}/ws`;
1101
-
1102
- ws = new WebSocket(wsUrl);
1103
-
1104
- ws.onopen = function() {
1105
- updateWSStatus(true);
1106
- // 这里要显示 服务器,而不是WebSocket,因为客户看不懂WebSocket
1107
- addLog('✓ 服务器 连接已建立', 'success');
1108
- };
1109
-
1110
- ws.onmessage = function(event) {
1111
- try {
1112
- const data = JSON.parse(event.data);
1113
- const message = data.message || '';
1114
-
1115
- // 检查是否是定时任务触发执行的消息
1116
- if (message.includes('[定时任务] 触发执行')) {
1117
- const taskStatus = document.getElementById('taskStatus');
1118
- taskStatus.textContent = '执行中...';
1119
- taskStatus.style.color = '#ffc107';
1120
- addLog(message, data.type || 'info');
1121
- }
1122
- // 检查是否是定时任务运行结束的消息
1123
- else if (message.includes('[定时任务] 运行结束')) {
1124
- const taskStatus = document.getElementById('taskStatus');
1125
- taskStatus.textContent = '空闲中';
1126
- taskStatus.style.color = '#888';
1127
- syncScheduleInfo();
1128
- addLog(message, data.type || 'info');
1129
- // 刷新证书列表
1130
- loadCertList();
1131
- }
1132
- // 检查是否是证书更新完成的消息
1133
- else if ((data.type === 'cert_update' && message === 'CERT_UPDATE_COMPLETE') || message.includes('任务完成')) {
1134
- loadCertList();
1135
- } else {
1136
- addLog(message, data.type || 'info');
1137
- }
1138
- } catch (e) {
1139
- addLog(event.data, 'info');
1140
- }
1141
- };
1142
-
1143
- ws.onerror = function(error) {
1144
- // 这里要显示 服务器,而不是WebSocket,因为客户看不懂WebSocket
1145
- addLog('✗ 服务器 连接错误', 'error');
1146
- };
1147
-
1148
- ws.onclose = function() {
1149
- updateWSStatus(false);
1150
- // 这里要显示 服务器,而不是WebSocket,因为客户看不懂WebSocket
1151
- addLog('✗ 服务器 连接已断开,5秒后重连...', 'warning');
1152
- reconnectTimer = setTimeout(connectWebSocket, 5000);
1153
- };
1154
- }
1155
-
1156
- // 更新WebSocket状态
1157
- function updateWSStatus(connected) {
1158
- const indicator = document.getElementById('wsStatus');
1159
- const text = document.getElementById('wsStatusText');
1160
- if (connected) {
1161
- indicator.className = 'status-indicator status-connected';
1162
- text.textContent = '已连接';
1163
- text.style.color = '#00ff88';
1164
- } else {
1165
- indicator.className = 'status-indicator status-disconnected';
1166
- text.textContent = '未连接';
1167
- text.style.color = '#f44336';
1168
- }
1169
- }
1170
-
1171
- // 添加日志
1172
- function addLog(message, type = 'info') {
1173
- const container = document.getElementById('logContainer');
1174
- const line = document.createElement('div');
1175
- line.className = `log-line log-${type}`;
1176
-
1177
- const timestamp = new Date().toLocaleTimeString('zh-CN', { hour12: false });
1178
- line.textContent = `[${timestamp}] ${message}`;
1179
-
1180
- // 如果是第一条日志,清空"等待日志输出..."
1181
- if (container.children.length === 1 && container.children[0].textContent.includes('等待日志输出')) {
1182
- container.innerHTML = '';
1183
- }
1184
-
1185
- container.appendChild(line);
1186
- container.scrollTop = container.scrollHeight;
1187
-
1188
- // 限制日志数量(使用配置的最大条数)
1189
- while (container.children.length > MAX_LOG_LINES) {
1190
- container.removeChild(container.children[0]);
1191
- }
1192
- }
1193
-
1194
- // 清空日志
1195
- function clearLogs() {
1196
- const container = document.getElementById('logContainer');
1197
- container.innerHTML = '<div class="log-line log-dim">日志已清空</div>';
1198
- }
1199
-
1200
- // 工具函数
1201
- function escapeHtml(text) {
1202
- const div = document.createElement('div');
1203
- div.textContent = text;
1204
- return div.innerHTML;
1205
- }
1206
-
1207
- function formatDate(dateString) {
1208
- const date = new Date(dateString);
1209
- return date.toLocaleString('zh-CN', {
1210
- year: 'numeric',
1211
- month: '2-digit',
1212
- day: '2-digit',
1213
- hour: '2-digit',
1214
- minute: '2-digit'
1215
- });
1216
- }
1217
-
1218
- // 自定义Alert对话框
1219
- function showAlert(title, message, type = 'info') {
1220
- return new Promise((resolve) => {
1221
- const dialog = document.getElementById('customDialog');
1222
- const dialogTitle = document.getElementById('dialogTitleText');
1223
- const dialogMessage = document.getElementById('dialogMessage');
1224
- const dialogButtons = document.getElementById('dialogButtons');
1225
- const dialogIcon = document.getElementById('dialogIcon');
1226
-
1227
- // 设置图标
1228
- const icons = {
1229
- 'success': '',
1230
- 'error': '✗',
1231
- 'warning': '⚠',
1232
- 'info': 'ℹ️'
1233
- };
1234
- dialogIcon.textContent = icons[type] || icons.info;
1235
-
1236
- dialogTitle.textContent = title;
1237
- dialogMessage.textContent = message;
1238
-
1239
- // 创建确定按钮
1240
- dialogButtons.innerHTML = '';
1241
- const okBtn = document.createElement('button');
1242
- okBtn.textContent = '确定';
1243
- okBtn.onclick = () => {
1244
- dialog.classList.remove('show');
1245
- resolve(true);
1246
- };
1247
- dialogButtons.appendChild(okBtn);
1248
-
1249
- dialog.classList.add('show');
1250
- });
1251
- }
1252
-
1253
- // 自定义Confirm对话框
1254
- function showConfirm(title, message) {
1255
- return new Promise((resolve) => {
1256
- const dialog = document.getElementById('customDialog');
1257
- const dialogTitle = document.getElementById('dialogTitleText');
1258
- const dialogMessage = document.getElementById('dialogMessage');
1259
- const dialogButtons = document.getElementById('dialogButtons');
1260
- const dialogIcon = document.getElementById('dialogIcon');
1261
-
1262
- dialogIcon.textContent = '';
1263
- dialogTitle.textContent = title;
1264
- dialogMessage.textContent = message;
1265
-
1266
- // 创建按钮
1267
- dialogButtons.innerHTML = '';
1268
-
1269
- const cancelBtn = document.createElement('button');
1270
- cancelBtn.textContent = '取消';
1271
- cancelBtn.className = 'btn-secondary';
1272
- cancelBtn.onclick = () => {
1273
- dialog.classList.remove('show');
1274
- resolve(false);
1275
- };
1276
-
1277
- const okBtn = document.createElement('button');
1278
- okBtn.textContent = '确定';
1279
- okBtn.onclick = () => {
1280
- dialog.classList.remove('show');
1281
- resolve(true);
1282
- };
1283
-
1284
- dialogButtons.appendChild(cancelBtn);
1285
- dialogButtons.appendChild(okBtn);
1286
-
1287
- dialog.classList.add('show');
1288
- });
1289
- }
1290
-
1291
- // 点击弹窗背景关闭(密码弹窗不允许点击背景关闭)
1292
- document.getElementById('configModal').addEventListener('click', function(e) {
1293
- if (e.target === this) {
1294
- closeConfigModal();
1295
- }
1296
- });
1297
-
1298
- document.getElementById('customDialog').addEventListener('click', function(e) {
1299
- if (e.target === this) {
1300
- this.classList.remove('show');
1301
- }
1302
- });
1303
-
1304
- // 页面卸载时清理
1305
- window.addEventListener('beforeunload', function() {
1306
- if (ws) {
1307
- ws.close();
1308
- }
1309
- if (reconnectTimer) {
1310
- clearTimeout(reconnectTimer);
1311
- }
1312
- if (scheduleInfoTimer) {
1313
- clearInterval(scheduleInfoTimer);
1314
- }
1315
- });
1316
- </script>
1317
- </body>
1318
- </html>
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title><%= title %></title>
7
+ <style>
8
+ * {
9
+ margin: 0;
10
+ padding: 0;
11
+ box-sizing: border-box;
12
+ }
13
+
14
+ body {
15
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
16
+ background: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%);
17
+ color: #e0e0e0;
18
+ min-height: 100vh;
19
+ padding: 20px;
20
+ }
21
+
22
+ .container {
23
+ max-width: 1400px;
24
+ margin: 0 auto;
25
+ }
26
+
27
+ header {
28
+ text-align: center;
29
+ margin-bottom: 40px;
30
+ padding: 30px 0;
31
+ }
32
+
33
+ h1 {
34
+ font-size: 2.5em;
35
+ font-weight: 700;
36
+ background: linear-gradient(135deg, #00ff88 0%, #00cc70 100%);
37
+ -webkit-background-clip: text;
38
+ -webkit-text-fill-color: transparent;
39
+ background-clip: text;
40
+ margin-bottom: 10px;
41
+ }
42
+
43
+ .subtitle {
44
+ color: #888;
45
+ font-size: 1.1em;
46
+ }
47
+
48
+ .grid {
49
+ display: grid;
50
+ grid-template-columns: 1fr 1fr;
51
+ gap: 20px;
52
+ margin-bottom: 20px;
53
+ }
54
+
55
+ .card {
56
+ background: rgba(30, 30, 30, 0.95);
57
+ border-radius: 12px;
58
+ padding: 25px;
59
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
60
+ border: 1px solid rgba(255, 255, 255, 0.05);
61
+ transition: transform 0.2s, box-shadow 0.2s;
62
+ }
63
+
64
+ .card:hover {
65
+ transform: translateY(-2px);
66
+ box-shadow: 0 12px 40px rgba(0, 255, 136, 0.1);
67
+ }
68
+
69
+ .card-title {
70
+ font-size: 1.5em;
71
+ margin-bottom: 20px;
72
+ color: #00ff88;
73
+ display: flex;
74
+ align-items: center;
75
+ gap: 10px;
76
+ }
77
+
78
+ .card-title::before {
79
+ content: '▶';
80
+ font-size: 0.8em;
81
+ }
82
+
83
+ .form-group {
84
+ margin-bottom: 20px;
85
+ }
86
+
87
+ label {
88
+ display: block;
89
+ margin-bottom: 8px;
90
+ color: #aaa;
91
+ font-size: 0.95em;
92
+ font-weight: 500;
93
+ }
94
+
95
+ input[type="text"],
96
+ textarea {
97
+ width: 100%;
98
+ padding: 12px 15px;
99
+ background: rgba(20, 20, 20, 0.8);
100
+ border: 1px solid rgba(255, 255, 255, 0.1);
101
+ border-radius: 8px;
102
+ color: #e0e0e0;
103
+ font-size: 0.95em;
104
+ transition: all 0.3s;
105
+ font-family: 'Consolas', 'Monaco', monospace;
106
+ }
107
+
108
+ input[type="text"]:focus,
109
+ textarea:focus {
110
+ outline: none;
111
+ border-color: #00ff88;
112
+ box-shadow: 0 0 0 3px rgba(0, 255, 136, 0.1);
113
+ }
114
+
115
+ textarea {
116
+ min-height: 100px;
117
+ resize: vertical;
118
+ }
119
+
120
+ /* textarea 滚动条样式 */
121
+ textarea::-webkit-scrollbar {
122
+ width: 8px;
123
+ }
124
+
125
+ textarea::-webkit-scrollbar-track {
126
+ background: rgba(255, 255, 255, 0.05);
127
+ border-radius: 4px;
128
+ }
129
+
130
+ textarea::-webkit-scrollbar-thumb {
131
+ background: rgba(0, 255, 136, 0.3);
132
+ border-radius: 4px;
133
+ }
134
+
135
+ textarea::-webkit-scrollbar-thumb:hover {
136
+ background: rgba(0, 255, 136, 0.5);
137
+ }
138
+
139
+ .hint {
140
+ font-size: 0.85em;
141
+ color: #666;
142
+ margin-top: 5px;
143
+ }
144
+
145
+ .button-group {
146
+ display: flex;
147
+ gap: 10px;
148
+ margin-top: 25px;
149
+ }
150
+
151
+ button {
152
+ flex: 1;
153
+ padding: 14px 24px;
154
+ background: linear-gradient(135deg, #00ff88 0%, #00cc70 100%);
155
+ color: #000;
156
+ border: none;
157
+ border-radius: 8px;
158
+ font-size: 1em;
159
+ font-weight: 600;
160
+ cursor: pointer;
161
+ transition: all 0.3s;
162
+ box-shadow: 0 4px 15px rgba(0, 255, 136, 0.3);
163
+ }
164
+
165
+ button:hover {
166
+ transform: translateY(-2px);
167
+ box-shadow: 0 6px 20px rgba(0, 255, 136, 0.4);
168
+ }
169
+
170
+ button:active {
171
+ transform: translateY(0);
172
+ }
173
+
174
+ button:disabled {
175
+ background: #333;
176
+ color: #666;
177
+ cursor: not-allowed;
178
+ box-shadow: none;
179
+ }
180
+
181
+ .btn-secondary {
182
+ background: linear-gradient(135deg, #444 0%, #333 100%);
183
+ color: #e0e0e0;
184
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
185
+ }
186
+
187
+ .btn-secondary:hover {
188
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
189
+ }
190
+
191
+ .full-width {
192
+ grid-column: 1 / -1;
193
+ }
194
+
195
+ /* 证书列表容器添加滚动 */
196
+ #certListContainer {
197
+ max-height: 600px;
198
+ overflow-y: auto;
199
+ }
200
+
201
+ #certListContainer::-webkit-scrollbar {
202
+ width: 8px;
203
+ }
204
+
205
+ #certListContainer::-webkit-scrollbar-track {
206
+ background: rgba(255, 255, 255, 0.05);
207
+ border-radius: 4px;
208
+ }
209
+
210
+ #certListContainer::-webkit-scrollbar-thumb {
211
+ background: rgba(0, 255, 136, 0.3);
212
+ border-radius: 4px;
213
+ }
214
+
215
+ #certListContainer::-webkit-scrollbar-thumb:hover {
216
+ background: rgba(0, 255, 136, 0.5);
217
+ }
218
+
219
+ table {
220
+ width: 100%;
221
+ border-collapse: collapse;
222
+ margin-top: 15px;
223
+ }
224
+
225
+ thead {
226
+ background: rgba(0, 255, 136, 0.1);
227
+ }
228
+
229
+ th, td {
230
+ padding: 12px 15px;
231
+ text-align: left;
232
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
233
+ }
234
+
235
+ th {
236
+ color: #00ff88;
237
+ font-weight: 600;
238
+ font-size: 0.95em;
239
+ }
240
+
241
+ td {
242
+ color: #ccc;
243
+ font-size: 0.9em;
244
+ }
245
+
246
+ tbody tr:hover {
247
+ background: rgba(255, 255, 255, 0.03);
248
+ }
249
+
250
+ .status-badge {
251
+ display: inline-block;
252
+ padding: 4px 12px;
253
+ border-radius: 12px;
254
+ font-size: 0.85em;
255
+ font-weight: 600;
256
+ }
257
+
258
+ .status-valid {
259
+ background: rgba(0, 255, 136, 0.2);
260
+ color: #00ff88;
261
+ }
262
+
263
+ .status-warning {
264
+ background: rgba(255, 193, 7, 0.2);
265
+ color: #ffc107;
266
+ }
267
+
268
+ .status-expired {
269
+ background: rgba(244, 67, 54, 0.2);
270
+ color: #f44336;
271
+ }
272
+
273
+ .log-container {
274
+ background: #0a0a0a;
275
+ border-radius: 12px;
276
+ padding: 20px;
277
+ height: 500px;
278
+ overflow-y: auto;
279
+ font-family: 'Consolas', 'Monaco', monospace;
280
+ font-size: 0.9em;
281
+ line-height: 1.6;
282
+ border: 1px solid rgba(0, 255, 136, 0.2);
283
+ box-shadow: inset 0 2px 10px rgba(0, 0, 0, 0.5);
284
+ }
285
+
286
+ .log-container::-webkit-scrollbar {
287
+ width: 8px;
288
+ }
289
+
290
+ .log-container::-webkit-scrollbar-track {
291
+ background: rgba(255, 255, 255, 0.05);
292
+ border-radius: 4px;
293
+ }
294
+
295
+ .log-container::-webkit-scrollbar-thumb {
296
+ background: rgba(0, 255, 136, 0.3);
297
+ border-radius: 4px;
298
+ }
299
+
300
+ .log-container::-webkit-scrollbar-thumb:hover {
301
+ background: rgba(0, 255, 136, 0.5);
302
+ }
303
+
304
+ .log-line {
305
+ margin-bottom: 4px;
306
+ white-space: pre-wrap;
307
+ word-break: break-all;
308
+ }
309
+
310
+ .log-info { color: #e0e0e0; }
311
+ .log-success { color: #00ff88; }
312
+ .log-warning { color: #ffc107; }
313
+ .log-error { color: #f44336; }
314
+ .log-dim { color: #666; }
315
+
316
+ .status-indicator {
317
+ display: inline-block;
318
+ width: 10px;
319
+ height: 10px;
320
+ border-radius: 50%;
321
+ margin-right: 8px;
322
+ animation: pulse 2s infinite;
323
+ }
324
+
325
+ .status-connected {
326
+ background: #00ff88;
327
+ }
328
+
329
+ .status-disconnected {
330
+ background: #f44336;
331
+ }
332
+
333
+ @keyframes pulse {
334
+ 0%, 100% { opacity: 1; }
335
+ 50% { opacity: 0.5; }
336
+ }
337
+
338
+ .empty-state {
339
+ text-align: center;
340
+ padding: 40px;
341
+ color: #666;
342
+ }
343
+
344
+ .loading {
345
+ text-align: center;
346
+ padding: 20px;
347
+ color: #888;
348
+ }
349
+
350
+ .action-buttons {
351
+ display: flex;
352
+ gap: 10px;
353
+ justify-content: flex-end;
354
+ margin-bottom: 15px;
355
+ }
356
+
357
+ .action-buttons button {
358
+ flex: none;
359
+ padding: 10px 20px;
360
+ }
361
+
362
+ /* 操作面板按钮组 */
363
+ .operation-buttons {
364
+ display: grid;
365
+ grid-template-columns: 1fr 1fr;
366
+ gap: 15px;
367
+ margin-top: 20px;
368
+ }
369
+
370
+ .operation-buttons button {
371
+ width: 100%;
372
+ height: 60px;
373
+ font-size: 1em;
374
+ }
375
+
376
+ /* 弹窗样式 */
377
+ .modal {
378
+ display: none;
379
+ position: fixed;
380
+ z-index: 1000;
381
+ left: 0;
382
+ top: 0;
383
+ width: 100%;
384
+ height: 100%;
385
+ background: rgba(0, 0, 0, 0.8);
386
+ backdrop-filter: blur(5px);
387
+ }
388
+
389
+ .modal.show {
390
+ display: flex;
391
+ align-items: center;
392
+ justify-content: center;
393
+ }
394
+
395
+ .modal-content {
396
+ background: rgba(30, 30, 30, 0.98);
397
+ border-radius: 16px;
398
+ padding: 30px;
399
+ max-width: 600px;
400
+ width: 90%;
401
+ max-height: 90vh;
402
+ overflow-y: auto;
403
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
404
+ border: 1px solid rgba(0, 255, 136, 0.2);
405
+ }
406
+
407
+ /* 弹窗内容滚动条样式 */
408
+ .modal-content::-webkit-scrollbar {
409
+ width: 10px;
410
+ }
411
+
412
+ .modal-content::-webkit-scrollbar-track {
413
+ background: rgba(255, 255, 255, 0.05);
414
+ border-radius: 5px;
415
+ }
416
+
417
+ .modal-content::-webkit-scrollbar-thumb {
418
+ background: rgba(0, 255, 136, 0.3);
419
+ border-radius: 5px;
420
+ }
421
+
422
+ .modal-content::-webkit-scrollbar-thumb:hover {
423
+ background: rgba(0, 255, 136, 0.5);
424
+ }
425
+
426
+ .modal-header {
427
+ display: flex;
428
+ justify-content: space-between;
429
+ align-items: center;
430
+ margin-bottom: 25px;
431
+ }
432
+
433
+ .modal-title {
434
+ font-size: 1.8em;
435
+ color: #00ff88;
436
+ }
437
+
438
+ .close-btn {
439
+ background: none;
440
+ border: none;
441
+ color: #888;
442
+ font-size: 2em;
443
+ cursor: pointer;
444
+ padding: 0;
445
+ width: 40px;
446
+ height: 40px;
447
+ display: flex;
448
+ align-items: center;
449
+ justify-content: center;
450
+ border-radius: 8px;
451
+ transition: all 0.3s;
452
+ }
453
+
454
+ .close-btn:hover {
455
+ background: rgba(255, 255, 255, 0.1);
456
+ color: #fff;
457
+ transform: none;
458
+ }
459
+
460
+ /* 搜索框样式 */
461
+ .search-box {
462
+ margin-bottom: 15px;
463
+ }
464
+
465
+ .search-box input {
466
+ width: 100%;
467
+ padding: 12px 15px;
468
+ background: rgba(20, 20, 20, 0.8);
469
+ border: 1px solid rgba(255, 255, 255, 0.1);
470
+ border-radius: 8px;
471
+ color: #e0e0e0;
472
+ font-size: 0.95em;
473
+ }
474
+
475
+ .search-box input:focus {
476
+ outline: none;
477
+ border-color: #00ff88;
478
+ box-shadow: 0 0 0 3px rgba(0, 255, 136, 0.1);
479
+ }
480
+
481
+ .info-item {
482
+ display: flex;
483
+ align-items: center;
484
+ gap: 10px;
485
+ padding: 12px 0;
486
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
487
+ }
488
+
489
+ .info-label {
490
+ color: #888;
491
+ min-width: 120px;
492
+ }
493
+
494
+ .info-value {
495
+ color: #00ff88;
496
+ font-weight: 500;
497
+ font-family: 'Consolas', 'Monaco', monospace;
498
+ }
499
+
500
+ .countdown {
501
+ font-size: 1.5em;
502
+ color: #00ff88;
503
+ font-weight: 600;
504
+ font-family: 'Consolas', 'Monaco', monospace;
505
+ }
506
+
507
+ /* 自定义确认对话框 */
508
+ .custom-dialog {
509
+ display: none;
510
+ position: fixed;
511
+ z-index: 2000;
512
+ left: 0;
513
+ top: 0;
514
+ width: 100%;
515
+ height: 100%;
516
+ background: rgba(0, 0, 0, 0.85);
517
+ backdrop-filter: blur(8px);
518
+ align-items: center;
519
+ justify-content: center;
520
+ }
521
+
522
+ .custom-dialog.show {
523
+ display: flex;
524
+ }
525
+
526
+ .dialog-content {
527
+ background: rgba(30, 30, 30, 0.98);
528
+ border-radius: 16px;
529
+ padding: 30px;
530
+ max-width: 450px;
531
+ width: 90%;
532
+ max-height: 90vh;
533
+ overflow-y: auto;
534
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
535
+ border: 1px solid rgba(0, 255, 136, 0.2);
536
+ }
537
+
538
+ /* 对话框滚动条样式 */
539
+ .dialog-content::-webkit-scrollbar {
540
+ width: 8px;
541
+ }
542
+
543
+ .dialog-content::-webkit-scrollbar-track {
544
+ background: rgba(255, 255, 255, 0.05);
545
+ border-radius: 4px;
546
+ }
547
+
548
+ .dialog-content::-webkit-scrollbar-thumb {
549
+ background: rgba(0, 255, 136, 0.3);
550
+ border-radius: 4px;
551
+ }
552
+
553
+ .dialog-content::-webkit-scrollbar-thumb:hover {
554
+ background: rgba(0, 255, 136, 0.5);
555
+ }
556
+
557
+ @keyframes dialogSlideIn {
558
+ from {
559
+ opacity: 0;
560
+ transform: translateY(-20px) scale(0.95);
561
+ }
562
+ to {
563
+ opacity: 1;
564
+ transform: translateY(0) scale(1);
565
+ }
566
+ }
567
+
568
+ .dialog-title {
569
+ font-size: 1.5em;
570
+ color: #00ff88;
571
+ margin-bottom: 15px;
572
+ display: flex;
573
+ align-items: center;
574
+ gap: 10px;
575
+ }
576
+
577
+ .dialog-message {
578
+ color: #ccc;
579
+ line-height: 1.6;
580
+ margin-bottom: 25px;
581
+ font-size: 1.05em;
582
+ }
583
+
584
+ .dialog-buttons {
585
+ display: flex;
586
+ gap: 10px;
587
+ justify-content: flex-end;
588
+ }
589
+
590
+ .dialog-buttons button {
591
+ padding: 12px 30px;
592
+ min-width: 100px;
593
+ }
594
+
595
+ @media (max-width: 1024px) {
596
+ .grid {
597
+ grid-template-columns: 1fr;
598
+ }
599
+ }
600
+ </style>
601
+ </head>
602
+ <body>
603
+ <div class="container">
604
+ <header>
605
+ <h1><%= title %></h1>
606
+ <p class="subtitle">SSL证书自动部署管理系统</p>
607
+ <p class="subtitle" style="font-size: 12px;margin-top: 6px;">如有问题,请联系QQ:370725567</p>
608
+ </header>
609
+
610
+ <div class="grid">
611
+ <!-- 操作面板 -->
612
+ <div class="card">
613
+ <h2 class="card-title">操作面板</h2>
614
+ <div class="operation-buttons">
615
+ <button onclick="openConfigModal()">
616
+ 配置管理
617
+ </button>
618
+ <button onclick="executeUpdate()">
619
+ 更新证书
620
+ </button>
621
+ </div>
622
+
623
+ <div style="margin-top: 30px; padding-top: 30px; border-top: 1px solid rgba(255, 255, 255, 0.05);">
624
+ <!-- 这里显示服务器,不是WebSocket 是为了方便用户理解 -->
625
+ <h3 style="color: #00ff88; margin-bottom: 15px; font-size: 1.2em;">服务器 连接状态</h3>
626
+ <p style="display: flex; align-items: center; font-size: 1em;">
627
+ <span class="status-indicator" id="wsStatus"></span>
628
+ <span id="wsStatusText">正在连接...</span>
629
+ </p>
630
+ </div>
631
+ </div>
632
+
633
+ <!-- 定时任务状态 -->
634
+ <div class="card">
635
+ <h2 class="card-title">定时任务状态</h2>
636
+ <div style="margin-top: 20px;">
637
+ <div class="info-item">
638
+ <span class="info-label">执行时间:</span>
639
+ <span class="info-value" id="scheduleTime">--:--</span>
640
+ </div>
641
+ <div class="info-item">
642
+ <span class="info-label">下次执行:</span>
643
+ <span class="countdown" id="countdown">--</span>
644
+ </div>
645
+ <div class="info-item" style="border-bottom: none;">
646
+ <span class="info-label">任务状态:</span>
647
+ <span id="taskStatus" style="color: #888;">空闲中</span>
648
+ </div>
649
+ </div>
650
+ </div>
651
+
652
+ <!-- 本地证书列表 -->
653
+ <div class="card full-width">
654
+ <h2 class="card-title">本地证书列表</h2>
655
+ <div class="search-box">
656
+ <input type="text" id="certSearch" placeholder="🔍 搜索证书域名...">
657
+ </div>
658
+ <div class="action-buttons">
659
+ <button class="btn-secondary" onclick="loadCertList()"> 刷新证书列表</button>
660
+ </div>
661
+ <div id="certListContainer">
662
+ <div class="loading">加载中...</div>
663
+ </div>
664
+ </div>
665
+
666
+ <!-- 实时日志 -->
667
+ <div class="card full-width">
668
+ <h2 class="card-title">运行日志</h2>
669
+ <div class="action-buttons">
670
+ <button class="btn-secondary" onclick="clearLogs()">清空运行日志</button>
671
+ </div>
672
+ <div class="log-container" id="logContainer">
673
+ <div class="log-line log-dim">等待日志输出...</div>
674
+ </div>
675
+ </div>
676
+ </div>
677
+ </div>
678
+
679
+ <!-- 自定义确认对话框 -->
680
+ <div id="customDialog" class="custom-dialog">
681
+ <div class="dialog-content">
682
+ <div class="dialog-title" id="dialogTitle">
683
+ <span id="dialogIcon">ℹ️</span>
684
+ <span id="dialogTitleText">提示</span>
685
+ </div>
686
+ <div class="dialog-message" id="dialogMessage">消息内容</div>
687
+ <div class="dialog-buttons" id="dialogButtons"></div>
688
+ </div>
689
+ </div>
690
+
691
+ <!-- 密码输入弹窗 -->
692
+ <div id="passwordModal" class="modal" style="z-index: 3000;">
693
+ <div class="modal-content" style="max-width: 400px;">
694
+ <div class="modal-header">
695
+ <div>
696
+ <h2 class="modal-title">🔐 身份验证</h2>
697
+ </div>
698
+ </div>
699
+ <form id="passwordForm">
700
+ <div class="form-group">
701
+ <label for="passwordInput">请输入访问口令</label>
702
+ <input type="text" id="passwordInput" name="password" required autocomplete="off" placeholder="请输入口令">
703
+ <div class="hint" style="color: #f44336;" id="passwordError"></div>
704
+ </div>
705
+ <div class="button-group">
706
+ <button type="submit">确定</button>
707
+ </div>
708
+ </form>
709
+ </div>
710
+ </div>
711
+
712
+ <!-- 配置管理弹窗 -->
713
+ <div id="configModal" class="modal">
714
+ <div class="modal-content">
715
+ <div class="modal-header">
716
+ <div>
717
+ <h2 class="modal-title">配置管理</h2>
718
+ </div>
719
+ <div>
720
+ <button class="close-btn" onclick="closeConfigModal()">&times;</button>
721
+ </div>
722
+ </div>
723
+ <form id="configForm">
724
+ <div class="form-group">
725
+ <label for="key">API Key *</label>
726
+ <input type="text" id="key" name="key" placeholder="请输入API Key" required>
727
+ <div class="hint">无忧SSL的API密钥</div>
728
+ </div>
729
+ <div class="form-group">
730
+ <label for="certSaveDir">证书保存目录 *</label>
731
+ <input type="text" id="certSaveDir" name="certSaveDir" placeholder="请输入证书保存目录" required>
732
+ <div class="hint">证书文件保存的本地路径</div>
733
+ </div>
734
+ <div class="form-group">
735
+ <label for="nginxDir">Nginx路径</label>
736
+ <input type="text" id="nginxDir" name="nginxDir" placeholder="请输入Nginx路径">
737
+ <div class="hint">Nginx可执行文件路径(留空则不重载)</div>
738
+ </div>
739
+ <div class="form-group">
740
+ <label for="domains">域名过滤(每行一个)</label>
741
+ <textarea id="domains" name="domains"></textarea>
742
+ <div class="hint">只处理这些域名的证书,留空则处理所有</div>
743
+ </div>
744
+ <div class="form-group">
745
+ <label for="callbackCommand">回调命令(每行一个)</label>
746
+ <textarea id="callbackCommand" name="callbackCommand"></textarea>
747
+ <div class="hint">证书更新后执行的命令</div>
748
+ </div>
749
+ <div class="form-group">
750
+ <label for="callbackIpWhitelist">回调接口IP白名单(每行一个)</label>
751
+ <textarea id="callbackIpWhitelist" name="callbackIpWhitelist"></textarea>
752
+ <div class="hint">允许调用回调接口的IP地址,支持单IP(如1.2.3.4)或CIDR格式(如1.2.3.0/24),留空则允许所有IP</div>
753
+ </div>
754
+ <div class="button-group">
755
+ <button type="button" class="btn-secondary" onclick="closeConfigModal()">取消</button>
756
+ <button type="submit">保存配置</button>
757
+ </div>
758
+ </form>
759
+ </div>
760
+ </div>
761
+
762
+ <script>
763
+ // 配置常量
764
+ const MAX_LOG_LINES = 500; // 日志最大条数
765
+ const PASSWORD_KEY = 'wuyoussl-password'; // localStorage 中的密码键名
766
+
767
+ let ws = null;
768
+ let reconnectTimer = null;
769
+ let scheduleInfoTimer = null;
770
+ let allCerts = []; // 存储所有证书数据
771
+ let serverTimeOffset = 0; // 服务器时间偏移(毫秒)
772
+ let nextExecutionTime = null; // 下次执行时间
773
+ let isAuthenticated = false; // 是否已通过身份验证
774
+
775
+ // 获取存储的密码
776
+ function getStoredPassword() {
777
+ return localStorage.getItem(PASSWORD_KEY);
778
+ }
779
+
780
+ // 保存密码到本地存储
781
+ function savePassword(password) {
782
+ localStorage.setItem(PASSWORD_KEY, password);
783
+ }
784
+
785
+ // 清除存储的密码
786
+ function clearPassword() {
787
+ localStorage.removeItem(PASSWORD_KEY);
788
+ isAuthenticated = false;
789
+ }
790
+
791
+ // 显示密码输入框
792
+ function showPasswordModal() {
793
+ const modal = document.getElementById('passwordModal');
794
+ modal.classList.add('show');
795
+ document.getElementById('passwordInput').value = '';
796
+ document.getElementById('passwordError').textContent = '';
797
+ document.getElementById('passwordInput').focus();
798
+ }
799
+
800
+ // 隐藏密码输入框
801
+ function hidePasswordModal() {
802
+ document.getElementById('passwordModal').classList.remove('show');
803
+ }
804
+
805
+ // 验证密码
806
+ async function verifyPassword(password) {
807
+ try {
808
+ const response = await fetch('/admin/api/verify-password', {
809
+ method: 'POST',
810
+ headers: {
811
+ 'Content-Type': 'application/json',
812
+ 'X-Password': password
813
+ },
814
+ body: JSON.stringify({ password })
815
+ });
816
+ const result = await response.json();
817
+ return result.code === 0;
818
+ } catch (error) {
819
+ console.error('密码验证失败:', error);
820
+ return false;
821
+ }
822
+ }
823
+
824
+ // 处理密码提交
825
+ document.addEventListener('DOMContentLoaded', function() {
826
+ document.getElementById('passwordForm').addEventListener('submit', async function(e) {
827
+ e.preventDefault();
828
+ const password = document.getElementById('passwordInput').value;
829
+ const errorEl = document.getElementById('passwordError');
830
+
831
+ const isValid = await verifyPassword(password);
832
+ if (isValid) {
833
+ savePassword(password);
834
+ isAuthenticated = true;
835
+ hidePasswordModal();
836
+ initializeApp();
837
+ } else {
838
+ errorEl.textContent = '口令错误,请重新输入';
839
+ document.getElementById('passwordInput').value = '';
840
+ document.getElementById('passwordInput').focus();
841
+ }
842
+ });
843
+ });
844
+
845
+ // 封装 fetch 请求,自动添加密码头
846
+ async function authenticatedFetch(url, options = {}) {
847
+ const password = getStoredPassword();
848
+ if (!password) {
849
+ showPasswordModal();
850
+ throw new Error('未设置密码');
851
+ }
852
+
853
+ const headers = {
854
+ ...options.headers,
855
+ 'X-Password': password
856
+ };
857
+
858
+ const response = await fetch(url, { ...options, headers });
859
+
860
+ // 如果返回 401,说明密码错误
861
+ if (response.status === 401) {
862
+ clearPassword();
863
+ showPasswordModal();
864
+ addLog('✗ 口令验证失败,请重新输入', 'error');
865
+ throw new Error('密码验证失败');
866
+ }
867
+
868
+ return response;
869
+ }
870
+
871
+ // 初始化应用
872
+ function initializeApp() {
873
+ loadCertList();
874
+ connectWebSocket();
875
+ startScheduleInfoSync();
876
+
877
+ // 搜索框事件
878
+ document.getElementById('certSearch').addEventListener('input', filterCerts);
879
+ }
880
+
881
+ // 初始化
882
+ document.addEventListener('DOMContentLoaded', function() {
883
+ const storedPassword = getStoredPassword();
884
+ if (storedPassword) {
885
+ // 验证存储的密码是否仍然有效
886
+ verifyPassword(storedPassword).then(isValid => {
887
+ if (isValid) {
888
+ isAuthenticated = true;
889
+ initializeApp();
890
+ } else {
891
+ clearPassword();
892
+ showPasswordModal();
893
+ }
894
+ });
895
+ } else {
896
+ showPasswordModal();
897
+ }
898
+ });
899
+
900
+ // 打开配置弹窗
901
+ function openConfigModal() {
902
+ document.getElementById('configModal').classList.add('show');
903
+ loadConfig();
904
+ }
905
+
906
+ // 关闭配置弹窗
907
+ function closeConfigModal() {
908
+ document.getElementById('configModal').classList.remove('show');
909
+ }
910
+
911
+ // 加载配置
912
+ async function loadConfig() {
913
+ try {
914
+ const response = await authenticatedFetch('/admin/api/config');
915
+ const result = await response.json();
916
+ if (result.code === 0) {
917
+ document.getElementById('key').value = result.data.key || '';
918
+ document.getElementById('certSaveDir').value = result.data.certSaveDir || '';
919
+ document.getElementById('nginxDir').value = result.data.nginxDir || '';
920
+ document.getElementById('domains').value = (result.data.domains || []).join('\n');
921
+ document.getElementById('callbackCommand').value = (result.data.callbackCommand || []).join('\n');
922
+ document.getElementById('callbackIpWhitelist').value = (result.data.callbackIpWhitelist || []).join('\n');
923
+ }
924
+ } catch (error) {
925
+ if (error.message !== '未设置密码' && error.message !== '密码验证失败') {
926
+ addLog('加载配置失败: ' + error.message, 'error');
927
+ }
928
+ }
929
+ }
930
+
931
+ // 保存配置
932
+ document.getElementById('configForm').addEventListener('submit', async function(e) {
933
+ e.preventDefault();
934
+
935
+ const config = {
936
+ key: document.getElementById('key').value.trim(),
937
+ certSaveDir: document.getElementById('certSaveDir').value.trim(),
938
+ nginxDir: document.getElementById('nginxDir').value.trim(),
939
+ domains: document.getElementById('domains').value.split('\n').map(s => s.trim()).filter(Boolean),
940
+ callbackCommand: document.getElementById('callbackCommand').value.split('\n').map(s => s.trim()).filter(Boolean),
941
+ callbackIpWhitelist: document.getElementById('callbackIpWhitelist').value.split('\n').map(s => s.trim()).filter(Boolean)
942
+ };
943
+
944
+ try {
945
+ const response = await authenticatedFetch('/admin/api/config', {
946
+ method: 'POST',
947
+ headers: { 'Content-Type': 'application/json' },
948
+ body: JSON.stringify(config)
949
+ });
950
+ const result = await response.json();
951
+
952
+ if (result.code === 0) {
953
+ addLog('✓ 配置保存成功', 'success');
954
+ showAlert('保存成功', '配置已成功保存!', 'success');
955
+ closeConfigModal();
956
+ } else {
957
+ addLog('✗ 配置保存失败: ' + result.msg, 'error');
958
+ showAlert('保存失败', result.msg, 'error');
959
+ }
960
+ } catch (error) {
961
+ if (error.message !== '未设置密码' && error.message !== '密码验证失败') {
962
+ addLog('✗ 配置保存失败: ' + error.message, 'error');
963
+ showAlert('保存失败', error.message, 'error');
964
+ }
965
+ }
966
+ });
967
+
968
+ // 加载证书列表
969
+ async function loadCertList() {
970
+ const container = document.getElementById('certListContainer');
971
+ container.innerHTML = '<div class="loading">加载中...</div>';
972
+
973
+ try {
974
+ const response = await authenticatedFetch('/admin/api/certs/local');
975
+ const result = await response.json();
976
+
977
+ if (result.code === 0) {
978
+ allCerts = result.data;
979
+ renderCerts(allCerts);
980
+ addLog(`✓ 证书列表加载完成,共 ${allCerts.length} 个证书`, 'success');
981
+ } else {
982
+ container.innerHTML = '<div class="empty-state">加载失败: ' + escapeHtml(result.msg) + '</div>';
983
+ }
984
+ } catch (error) {
985
+ if (error.message !== '未设置密码' && error.message !== '密码验证失败') {
986
+ container.innerHTML = '<div class="empty-state">加载失败: ' + escapeHtml(error.message) + '</div>';
987
+ addLog('✗ 加载证书列表失败: ' + error.message, 'error');
988
+ }
989
+ }
990
+ }
991
+
992
+ // 渲染证书列表
993
+ function renderCerts(certs) {
994
+ const container = document.getElementById('certListContainer');
995
+
996
+ if (certs.length === 0) {
997
+ container.innerHTML = '<div class="empty-state">暂无证书文件</div>';
998
+ return;
999
+ }
1000
+
1001
+ // 按剩余天数排序(升序,越小越靠前)
1002
+ const sortedCerts = [...certs].sort((a, b) => a.remainingDays - b.remainingDays);
1003
+
1004
+ let html = '<table><thead><tr>';
1005
+ html += '<th>域名</th>';
1006
+ html += '<th>过期时间</th>';
1007
+ html += '<th>剩余天数</th>';
1008
+ html += '<th>状态</th>';
1009
+ html += '</tr></thead><tbody>';
1010
+
1011
+ sortedCerts.forEach(cert => {
1012
+ const statusClass = cert.status === 'valid' ? 'status-valid' :
1013
+ cert.status === 'warning' ? 'status-warning' :
1014
+ 'status-expired';
1015
+ const statusText = cert.status === 'valid' ? '正常' :
1016
+ cert.status === 'warning' ? '即将过期' :
1017
+ '已过期';
1018
+
1019
+ html += '<tr>';
1020
+ html += `<td>${escapeHtml(cert.domain)}</td>`;
1021
+ html += `<td>${formatDate(cert.notAfter)}</td>`;
1022
+ html += `<td>${cert.remainingDays} 天</td>`;
1023
+ html += `<td><span class="status-badge ${statusClass}">${statusText}</span></td>`;
1024
+ html += '</tr>';
1025
+ });
1026
+
1027
+ html += '</tbody></table>';
1028
+ container.innerHTML = html;
1029
+ }
1030
+
1031
+ // 过滤证书
1032
+ function filterCerts() {
1033
+ const keyword = document.getElementById('certSearch').value.toLowerCase();
1034
+ if (!keyword) {
1035
+ renderCerts(allCerts);
1036
+ return;
1037
+ }
1038
+
1039
+ const filtered = allCerts.filter(cert =>
1040
+ cert.fileName.toLowerCase().includes(keyword) ||
1041
+ cert.domain.toLowerCase().includes(keyword)
1042
+ );
1043
+
1044
+ renderCerts(filtered);
1045
+ }
1046
+
1047
+ // 执行证书更新
1048
+ async function executeUpdate() {
1049
+ const confirmed = await showConfirm('确定要立即更新所有证书吗?', '此操作将检查所有证书并更新即将过期的证书');
1050
+ if (!confirmed) {
1051
+ return;
1052
+ }
1053
+
1054
+ addLog('→ 开始执行证书更新任务...', 'info');
1055
+
1056
+ try {
1057
+ const response = await authenticatedFetch('/api/cert/execute');
1058
+ const result = await response.json();
1059
+
1060
+ if (result.code === 0) {
1061
+ addLog('✓ 证书更新任务已启动', 'success');
1062
+ // 页面自动滚动到运行日志
1063
+ document.getElementById('logContainer').scrollIntoView({ behavior: 'smooth' });
1064
+ } else {
1065
+ addLog('✗ 启动失败: ' + result.msg, 'error');
1066
+ showAlert('启动失败', result.msg, 'error');
1067
+ }
1068
+ } catch (error) {
1069
+ if (error.message !== '未设置密码' && error.message !== '密码验证失败') {
1070
+ addLog('✗ 执行失败: ' + error.message, 'error');
1071
+ showAlert('执行失败', error.message, 'error');
1072
+ }
1073
+ }
1074
+ }
1075
+
1076
+ // 同步定时任务信息
1077
+ function startScheduleInfoSync() {
1078
+ // 立即同步一次
1079
+ syncScheduleInfo();
1080
+
1081
+ // 每分钟同步一次
1082
+ scheduleInfoTimer = setInterval(syncScheduleInfo, 60000);
1083
+
1084
+ // 每秒更新倒计时显示
1085
+ setInterval(updateCountdown, 1000);
1086
+ }
1087
+
1088
+ // 同步服务器时间和定时任务信息
1089
+ async function syncScheduleInfo() {
1090
+ try {
1091
+ const response = await authenticatedFetch('/api/cert/schedule-info');
1092
+ const result = await response.json();
1093
+
1094
+ if (result.code === 0) {
1095
+ const data = result.data;
1096
+
1097
+ // 计算时间偏移
1098
+ const serverTime = new Date(data.serverTime).getTime();
1099
+ const localTime = Date.now();
1100
+ serverTimeOffset = serverTime - localTime;
1101
+
1102
+ // 更新定时任务信息
1103
+ if (data.scheduledExecutionTime) {
1104
+ document.getElementById('scheduleTime').textContent = data.scheduledExecutionTime.formatted;
1105
+ }
1106
+
1107
+ // 保存下次执行时间
1108
+ if (data.nextExecutionTime) {
1109
+ nextExecutionTime = new Date(data.nextExecutionTime).getTime();
1110
+ }
1111
+
1112
+ // 更新任务状态
1113
+ const taskStatus = document.getElementById('taskStatus');
1114
+ if (data.isTaskRunning) {
1115
+ taskStatus.textContent = '执行中...';
1116
+ taskStatus.style.color = '#ffc107';
1117
+ } else {
1118
+ taskStatus.textContent = '空闲中';
1119
+ taskStatus.style.color = '#888';
1120
+ }
1121
+ }
1122
+ } catch (error) {
1123
+ if (error.message !== '未设置密码' && error.message !== '密码验证失败') {
1124
+ console.error('同步定时任务信息失败:', error);
1125
+ }
1126
+ }
1127
+ }
1128
+
1129
+ // 更新倒计时显示(始终显示到秒)
1130
+ function updateCountdown() {
1131
+ if (!nextExecutionTime) {
1132
+ document.getElementById('countdown').textContent = '--';
1133
+ return;
1134
+ }
1135
+
1136
+ // 使用校正后的本地时间
1137
+ const now = Date.now() + serverTimeOffset;
1138
+ const remaining = nextExecutionTime - now;
1139
+
1140
+ if (remaining <= 0) {
1141
+ document.getElementById('countdown').textContent = '即将执行';
1142
+ return;
1143
+ }
1144
+
1145
+ const totalSeconds = Math.floor(remaining / 1000);
1146
+ const hours = Math.floor(totalSeconds / 3600);
1147
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
1148
+ const seconds = totalSeconds % 60;
1149
+
1150
+ // 始终显示到秒
1151
+ let text = '';
1152
+ if (hours > 0) {
1153
+ text = `${hours}小时 ${minutes}分钟 ${seconds}秒`;
1154
+ } else if (minutes > 0) {
1155
+ text = `${minutes}分钟 ${seconds}秒`;
1156
+ } else {
1157
+ text = `${seconds}秒`;
1158
+ }
1159
+
1160
+ document.getElementById('countdown').textContent = text;
1161
+ }
1162
+
1163
+ // WebSocket 连接
1164
+ function connectWebSocket() {
1165
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
1166
+ const wsUrl = `${protocol}//${window.location.host}/ws`;
1167
+
1168
+ ws = new WebSocket(wsUrl);
1169
+
1170
+ ws.onopen = function() {
1171
+ updateWSStatus(true);
1172
+ // 这里要显示 服务器,而不是WebSocket,因为客户看不懂WebSocket
1173
+ addLog('✓ 服务器 连接已建立', 'success');
1174
+ };
1175
+
1176
+ ws.onmessage = function(event) {
1177
+ try {
1178
+ const data = JSON.parse(event.data);
1179
+ const message = data.message || '';
1180
+
1181
+ // 检查是否是定时任务触发执行的消息
1182
+ if (message.includes('[定时任务] 触发执行')) {
1183
+ const taskStatus = document.getElementById('taskStatus');
1184
+ taskStatus.textContent = '执行中...';
1185
+ taskStatus.style.color = '#ffc107';
1186
+ addLog(message, data.type || 'info');
1187
+ }
1188
+ // 检查是否是定时任务运行结束的消息
1189
+ else if (message.includes('[定时任务] 运行结束')) {
1190
+ const taskStatus = document.getElementById('taskStatus');
1191
+ taskStatus.textContent = '空闲中';
1192
+ taskStatus.style.color = '#888';
1193
+ syncScheduleInfo();
1194
+ addLog(message, data.type || 'info');
1195
+ // 刷新证书列表
1196
+ loadCertList();
1197
+ }
1198
+ // 检查是否是证书更新完成的消息
1199
+ else if ((data.type === 'cert_update' && message === 'CERT_UPDATE_COMPLETE') || message.includes('任务完成')) {
1200
+ loadCertList();
1201
+ } else {
1202
+ addLog(message, data.type || 'info');
1203
+ }
1204
+ } catch (e) {
1205
+ addLog(event.data, 'info');
1206
+ }
1207
+ };
1208
+
1209
+ ws.onerror = function(error) {
1210
+ // 这里要显示 服务器,而不是WebSocket,因为客户看不懂WebSocket
1211
+ addLog('✗ 服务器 连接错误', 'error');
1212
+ };
1213
+
1214
+ ws.onclose = function() {
1215
+ updateWSStatus(false);
1216
+ // 这里要显示 服务器,而不是WebSocket,因为客户看不懂WebSocket
1217
+ addLog('✗ 服务器 连接已断开,5秒后重连...', 'warning');
1218
+ reconnectTimer = setTimeout(connectWebSocket, 5000);
1219
+ };
1220
+ }
1221
+
1222
+ // 更新WebSocket状态
1223
+ function updateWSStatus(connected) {
1224
+ const indicator = document.getElementById('wsStatus');
1225
+ const text = document.getElementById('wsStatusText');
1226
+ if (connected) {
1227
+ indicator.className = 'status-indicator status-connected';
1228
+ text.textContent = '已连接';
1229
+ text.style.color = '#00ff88';
1230
+ } else {
1231
+ indicator.className = 'status-indicator status-disconnected';
1232
+ text.textContent = '未连接';
1233
+ text.style.color = '#f44336';
1234
+ }
1235
+ }
1236
+
1237
+ // 添加日志
1238
+ function addLog(message, type = 'info') {
1239
+ const container = document.getElementById('logContainer');
1240
+ const line = document.createElement('div');
1241
+ line.className = `log-line log-${type}`;
1242
+
1243
+ const timestamp = new Date().toLocaleTimeString('zh-CN', { hour12: false });
1244
+ line.textContent = `[${timestamp}] ${message}`;
1245
+
1246
+ // 如果是第一条日志,清空"等待日志输出..."
1247
+ if (container.children.length === 1 && container.children[0].textContent.includes('等待日志输出')) {
1248
+ container.innerHTML = '';
1249
+ }
1250
+
1251
+ container.appendChild(line);
1252
+ container.scrollTop = container.scrollHeight;
1253
+
1254
+ // 限制日志数量(使用配置的最大条数)
1255
+ while (container.children.length > MAX_LOG_LINES) {
1256
+ container.removeChild(container.children[0]);
1257
+ }
1258
+ }
1259
+
1260
+ // 清空日志
1261
+ function clearLogs() {
1262
+ const container = document.getElementById('logContainer');
1263
+ container.innerHTML = '<div class="log-line log-dim">日志已清空</div>';
1264
+ }
1265
+
1266
+ // 工具函数
1267
+ function escapeHtml(text) {
1268
+ const div = document.createElement('div');
1269
+ div.textContent = text;
1270
+ return div.innerHTML;
1271
+ }
1272
+
1273
+ function formatDate(dateString) {
1274
+ const date = new Date(dateString);
1275
+ return date.toLocaleString('zh-CN', {
1276
+ year: 'numeric',
1277
+ month: '2-digit',
1278
+ day: '2-digit',
1279
+ hour: '2-digit',
1280
+ minute: '2-digit'
1281
+ });
1282
+ }
1283
+
1284
+ // 自定义Alert对话框
1285
+ function showAlert(title, message, type = 'info') {
1286
+ return new Promise((resolve) => {
1287
+ const dialog = document.getElementById('customDialog');
1288
+ const dialogTitle = document.getElementById('dialogTitleText');
1289
+ const dialogMessage = document.getElementById('dialogMessage');
1290
+ const dialogButtons = document.getElementById('dialogButtons');
1291
+ const dialogIcon = document.getElementById('dialogIcon');
1292
+
1293
+ // 设置图标
1294
+ const icons = {
1295
+ 'success': '✓',
1296
+ 'error': '✗',
1297
+ 'warning': '⚠',
1298
+ 'info': 'ℹ️'
1299
+ };
1300
+ dialogIcon.textContent = icons[type] || icons.info;
1301
+
1302
+ dialogTitle.textContent = title;
1303
+ dialogMessage.textContent = message;
1304
+
1305
+ // 创建确定按钮
1306
+ dialogButtons.innerHTML = '';
1307
+ const okBtn = document.createElement('button');
1308
+ okBtn.textContent = '确定';
1309
+ okBtn.onclick = () => {
1310
+ dialog.classList.remove('show');
1311
+ resolve(true);
1312
+ };
1313
+ dialogButtons.appendChild(okBtn);
1314
+
1315
+ dialog.classList.add('show');
1316
+ });
1317
+ }
1318
+
1319
+ // 自定义Confirm对话框
1320
+ function showConfirm(title, message) {
1321
+ return new Promise((resolve) => {
1322
+ const dialog = document.getElementById('customDialog');
1323
+ const dialogTitle = document.getElementById('dialogTitleText');
1324
+ const dialogMessage = document.getElementById('dialogMessage');
1325
+ const dialogButtons = document.getElementById('dialogButtons');
1326
+ const dialogIcon = document.getElementById('dialogIcon');
1327
+
1328
+ dialogIcon.textContent = '❓';
1329
+ dialogTitle.textContent = title;
1330
+ dialogMessage.textContent = message;
1331
+
1332
+ // 创建按钮
1333
+ dialogButtons.innerHTML = '';
1334
+
1335
+ const cancelBtn = document.createElement('button');
1336
+ cancelBtn.textContent = '取消';
1337
+ cancelBtn.className = 'btn-secondary';
1338
+ cancelBtn.onclick = () => {
1339
+ dialog.classList.remove('show');
1340
+ resolve(false);
1341
+ };
1342
+
1343
+ const okBtn = document.createElement('button');
1344
+ okBtn.textContent = '确定';
1345
+ okBtn.onclick = () => {
1346
+ dialog.classList.remove('show');
1347
+ resolve(true);
1348
+ };
1349
+
1350
+ dialogButtons.appendChild(cancelBtn);
1351
+ dialogButtons.appendChild(okBtn);
1352
+
1353
+ dialog.classList.add('show');
1354
+ });
1355
+ }
1356
+
1357
+ // 所有弹窗都不允许点击背景关闭,只能通过按钮关闭
1358
+
1359
+ // 页面卸载时清理
1360
+ window.addEventListener('beforeunload', function() {
1361
+ if (ws) {
1362
+ ws.close();
1363
+ }
1364
+ if (reconnectTimer) {
1365
+ clearTimeout(reconnectTimer);
1366
+ }
1367
+ if (scheduleInfoTimer) {
1368
+ clearInterval(scheduleInfoTimer);
1369
+ }
1370
+ });
1371
+ </script>
1372
+ </body>
1373
+ </html>