ricebug commited on
Commit
ee46a2d
·
verified ·
1 Parent(s): e5970af

Upload 7 files

Browse files
Files changed (8) hide show
  1. .gitattributes +1 -0
  2. package-lock.json +0 -0
  3. package.json +18 -0
  4. public/bg.png +3 -0
  5. public/favicon.png +0 -0
  6. public/index.html +1758 -0
  7. server.js +695 -0
  8. zbpack.json +4 -0
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ public/bg.png filter=lfs diff=lfs merge=lfs -text
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "zeabur-monitor",
3
+ "version": "1.0.0",
4
+ "description": "Multi-account Zeabur monitoring dashboard",
5
+ "main": "server.js",
6
+ "scripts": {
7
+ "start": "node server.js",
8
+ "dev": "nodemon server.js"
9
+ },
10
+ "dependencies": {
11
+ "cors": "^2.8.5",
12
+ "dotenv": "^16.3.1",
13
+ "express": "^4.18.2"
14
+ },
15
+ "devDependencies": {
16
+ "nodemon": "^3.0.1"
17
+ }
18
+ }
public/bg.png ADDED

Git LFS Details

  • SHA256: 483b350bf6021c732ae366835fc353340d2b28acdff7f95fea135bbd045432bb
  • Pointer size: 132 Bytes
  • Size of remote file: 1.23 MB
public/favicon.png ADDED
public/index.html ADDED
@@ -0,0 +1,1758 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Zeabur 多账号监控面板</title>
7
+ <link rel="icon" type="image/png" href="favicon.png">
8
+ <script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js"></script>
9
+ <style>
10
+ * { margin: 0; padding: 0; box-sizing: border-box; }
11
+ :root {
12
+ --card-opacity: 0.3;
13
+ }
14
+ @keyframes gradient {
15
+ 0% { background-position: 0% 50%; }
16
+ 50% { background-position: 100% 50%; }
17
+ 100% { background-position: 0% 50%; }
18
+ }
19
+ body {
20
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
21
+ background: url('bg.png') center/cover fixed;
22
+ position: relative;
23
+ min-height: 100vh;
24
+ padding: 20px;
25
+ }
26
+ body::before {
27
+ content: '';
28
+ position: fixed;
29
+ top: 0;
30
+ left: 0;
31
+ right: 0;
32
+ bottom: 0;
33
+ background: rgba(255, 255, 255, 0.05);
34
+ z-index: 0;
35
+ }
36
+ .container {
37
+ max-width: 1400px;
38
+ margin: 0 auto;
39
+ position: relative;
40
+ z-index: 1;
41
+ }
42
+ h1 {
43
+ color: white;
44
+ margin-bottom: 30px;
45
+ font-size: 28px;
46
+ font-weight: 700;
47
+ text-shadow: 0 2px 4px rgba(0,0,0,0.2);
48
+ }
49
+ .refresh-btn {
50
+ background: linear-gradient(135deg, #f696c6 0%, #fbb6d8 100%);
51
+ color: white;
52
+ border: 2px solid rgba(255,255,255,0.3);
53
+ padding: 10px 20px;
54
+ border-radius: 25px;
55
+ cursor: pointer;
56
+ font-size: 14px;
57
+ font-weight: 600;
58
+ transition: all 0.3s;
59
+ box-shadow: 0 4px 15px rgba(246,150,198,0.3);
60
+ }
61
+ .refresh-btn:hover {
62
+ transform: translateY(-2px);
63
+ box-shadow: 0 6px 20px rgba(0,0,0,0.3);
64
+ }
65
+ .refresh-btn:disabled {
66
+ opacity: 0.6;
67
+ cursor: not-allowed;
68
+ transform: none;
69
+ }
70
+ @keyframes fadeInUp {
71
+ from {
72
+ opacity: 0;
73
+ transform: translateY(20px);
74
+ }
75
+ to {
76
+ opacity: 1;
77
+ transform: translateY(0);
78
+ }
79
+ }
80
+ .account-card {
81
+ background: rgba(255,255,255,var(--card-opacity));
82
+ backdrop-filter: blur(var(--blur-amount)) saturate(var(--saturate-amount));
83
+ -webkit-backdrop-filter: blur(var(--blur-amount)) saturate(var(--saturate-amount));
84
+ border-radius: 20px;
85
+ padding: 24px;
86
+ margin-bottom: 20px;
87
+ box-shadow: 0 8px 32px rgba(0, 0, 0, var(--shadow-opacity));
88
+ border: 1px solid rgba(255,255,255,var(--border-opacity));
89
+ transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.2s ease, z-index 0s 0.2s;
90
+ animation: fadeInUp 0.6s ease-out;
91
+ will-change: transform;
92
+ transform: translate3d(0, 0, 0);
93
+ position: relative;
94
+ z-index: 1;
95
+ }
96
+ .account-card:hover {
97
+ transform: translate3d(0, -8px, 0) scale(1.02);
98
+ border-color: rgba(255,255,255,0.3);
99
+ z-index: 10;
100
+ transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.2s ease, z-index 0s 0s;
101
+ }
102
+ .account-header {
103
+ display: flex;
104
+ justify-content: space-between;
105
+ align-items: center;
106
+ margin-bottom: 15px;
107
+ padding: 10px;
108
+ margin: -10px -10px 15px -10px;
109
+ border-radius: 12px;
110
+ transition: background 0.2s;
111
+ }
112
+ .account-header:hover {
113
+ background: rgba(255,255,255,0.1);
114
+ }
115
+ .account-name {
116
+ font-size: 20px;
117
+ font-weight: bold;
118
+ color: #333;
119
+ }
120
+ .balance {
121
+ font-size: 24px;
122
+ font-weight: bold;
123
+ color: #4CAF50;
124
+ }
125
+ .balance.low { color: #ff9800; }
126
+ .balance.critical { color: #f44336; }
127
+ .projects-grid {
128
+ display: grid;
129
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
130
+ gap: 16px;
131
+ margin-top: 15px;
132
+ }
133
+ @keyframes shimmer {
134
+ 0% { background-position: -1000px 0; }
135
+ 100% { background-position: 1000px 0; }
136
+ }
137
+ .project-card {
138
+ background: rgba(255,255,255,var(--card-opacity));
139
+ backdrop-filter: blur(var(--blur-amount-small)) saturate(var(--saturate-amount));
140
+ -webkit-backdrop-filter: blur(var(--blur-amount-small)) saturate(var(--saturate-amount));
141
+ border-radius: 16px;
142
+ padding: 20px;
143
+ border: 1px solid rgba(255,255,255,var(--border-opacity-light));
144
+ position: relative;
145
+ transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
146
+ display: flex;
147
+ flex-direction: column;
148
+ min-height: 280px;
149
+ height: 100%;
150
+ overflow: hidden;
151
+ box-shadow: 0 4px 16px rgba(0, 0, 0, var(--shadow-opacity));
152
+ }
153
+ .project-card::before {
154
+ content: '';
155
+ position: absolute;
156
+ top: 0;
157
+ left: -2px;
158
+ right: -2px;
159
+ bottom: 0;
160
+ background: linear-gradient(135deg, #f696c6, #fbb6d8, #fdd7e8);
161
+ border-radius: 16px;
162
+ z-index: -1;
163
+ opacity: 0;
164
+ transition: opacity 0.4s;
165
+ }
166
+ .project-card:hover {
167
+ transform: translateY(-8px) scale(1.03);
168
+ }
169
+ .project-card:hover::before {
170
+ opacity: 1;
171
+ }
172
+ .project-name {
173
+ font-weight: bold;
174
+ color: #333;
175
+ margin-bottom: 5px;
176
+ }
177
+ .project-name button:hover {
178
+ background: rgba(246, 150, 198, 0.4) !important;
179
+ transform: scale(1.1);
180
+ }
181
+ .project-info {
182
+ font-size: 12px;
183
+ color: #666;
184
+ margin-bottom: 10px;
185
+ }
186
+ .service-item {
187
+ background: rgba(255,255,255,var(--service-opacity));
188
+ backdrop-filter: blur(var(--blur-amount-tiny)) saturate(var(--saturate-amount));
189
+ -webkit-backdrop-filter: blur(var(--blur-amount-tiny)) saturate(var(--saturate-amount));
190
+ padding: 14px 16px;
191
+ margin: 8px 0;
192
+ border-radius: 12px;
193
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
194
+ border: 1px solid rgba(255, 255, 255, var(--border-opacity-strong));
195
+ min-height: 70px;
196
+ display: flex;
197
+ align-items: center;
198
+ gap: 20px;
199
+ position: relative;
200
+ overflow: hidden;
201
+ box-shadow: 0 2px 8px rgba(0, 0, 0, var(--shadow-opacity-light));
202
+ }
203
+ .service-item::before {
204
+ content: '';
205
+ position: absolute;
206
+ top: 0;
207
+ left: -100%;
208
+ width: 100%;
209
+ height: 100%;
210
+ background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent);
211
+ transition: left 0.5s;
212
+ }
213
+ .service-item:hover {
214
+ background: rgba(255,255,255,0.45);
215
+ box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);
216
+ transform: translateX(8px) scale(1.02);
217
+ border-color: rgba(255, 255, 255, 0.6);
218
+ }
219
+ .service-item:hover::before {
220
+ left: 100%;
221
+ }
222
+ .services-container {
223
+ flex: 1;
224
+ display: flex;
225
+ flex-direction: column;
226
+ }
227
+ .modal-overlay {
228
+ position: fixed;
229
+ top: 0;
230
+ left: 0;
231
+ right: 0;
232
+ bottom: 0;
233
+ background: rgba(0,0,0,0.5);
234
+ backdrop-filter: blur(4px);
235
+ display: flex;
236
+ align-items: center;
237
+ justify-content: center;
238
+ z-index: 1000;
239
+ }
240
+ .modal-content {
241
+ background: white;
242
+ border-radius: 16px;
243
+ padding: 30px;
244
+ max-width: 500px;
245
+ width: 90%;
246
+ box-shadow: 0 20px 60px rgba(0,0,0,0.3);
247
+ }
248
+ .modal-title {
249
+ font-size: 24px;
250
+ font-weight: 700;
251
+ color: #333;
252
+ margin-bottom: 20px;
253
+ }
254
+ .input-group {
255
+ margin-bottom: 20px;
256
+ }
257
+ .input-label {
258
+ display: block;
259
+ font-size: 14px;
260
+ font-weight: 600;
261
+ color: #666;
262
+ margin-bottom: 8px;
263
+ }
264
+ .input-field {
265
+ width: 100%;
266
+ padding: 12px 16px;
267
+ border: 2px solid #e5e7eb;
268
+ border-radius: 8px;
269
+ font-size: 14px;
270
+ transition: all 0.3s;
271
+ }
272
+ .input-field:focus {
273
+ outline: none;
274
+ border-color: #f696c6;
275
+ box-shadow: 0 0 0 3px rgba(246,150,198,0.1);
276
+ }
277
+ .btn-primary {
278
+ background: linear-gradient(135deg, #f696c6 0%, #fbb6d8 100%);
279
+ color: white;
280
+ border: none;
281
+ padding: 12px 24px;
282
+ border-radius: 8px;
283
+ font-size: 14px;
284
+ font-weight: 600;
285
+ cursor: pointer;
286
+ transition: all 0.3s;
287
+ width: 100%;
288
+ }
289
+ .btn-primary:hover {
290
+ transform: translateY(-2px);
291
+ box-shadow: 0 6px 20px rgba(246,150,198,0.4);
292
+ }
293
+ .btn-secondary {
294
+ background: #f3f4f6;
295
+ color: #666;
296
+ border: none;
297
+ padding: 12px 24px;
298
+ border-radius: 8px;
299
+ font-size: 14px;
300
+ font-weight: 600;
301
+ cursor: pointer;
302
+ transition: all 0.3s;
303
+ width: 100%;
304
+ margin-top: 10px;
305
+ }
306
+ .btn-secondary:hover {
307
+ background: #e5e7eb;
308
+ }
309
+ .add-account-btn {
310
+ background: rgba(255,255,255,0.2);
311
+ color: white;
312
+ border: 2px solid rgba(255,255,255,0.3);
313
+ padding: 10px 20px;
314
+ border-radius: 25px;
315
+ cursor: pointer;
316
+ font-size: 14px;
317
+ font-weight: 600;
318
+ transition: all 0.3s;
319
+ box-shadow: 0 4px 15px rgba(0,0,0,0.1);
320
+ }
321
+ .add-account-btn:hover {
322
+ background: rgba(255,255,255,0.3);
323
+ transform: translateY(-2px);
324
+ }
325
+ input[type="range"] {
326
+ -webkit-appearance: none;
327
+ appearance: none;
328
+ background: rgba(255,255,255,0.3);
329
+ border-radius: 10px;
330
+ height: 6px;
331
+ outline: none;
332
+ }
333
+ input[type="range"]::-webkit-slider-thumb {
334
+ -webkit-appearance: none;
335
+ appearance: none;
336
+ width: 18px;
337
+ height: 18px;
338
+ border-radius: 50%;
339
+ background: white;
340
+ cursor: pointer;
341
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
342
+ }
343
+ input[type="range"]::-moz-range-thumb {
344
+ width: 18px;
345
+ height: 18px;
346
+ border-radius: 50%;
347
+ background: white;
348
+ cursor: pointer;
349
+ border: none;
350
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
351
+ }
352
+ @keyframes pulse {
353
+ 0%, 100% { opacity: 1; }
354
+ 50% { opacity: 0.7; }
355
+ }
356
+ .status {
357
+ padding: 6px 14px;
358
+ border-radius: 20px;
359
+ font-size: 11px;
360
+ font-weight: 700;
361
+ text-transform: uppercase;
362
+ letter-spacing: 0.5px;
363
+ box-shadow: 0 4px 12px rgba(0,0,0,0.2);
364
+ position: relative;
365
+ overflow: hidden;
366
+ }
367
+ .status::before {
368
+ content: '';
369
+ position: absolute;
370
+ top: 50%;
371
+ left: 50%;
372
+ width: 100%;
373
+ height: 100%;
374
+ background: rgba(255,255,255,0.3);
375
+ transform: translate(-50%, -50%) scale(0);
376
+ border-radius: 50%;
377
+ transition: transform 0.6s;
378
+ }
379
+ .status:hover::before {
380
+ transform: translate(-50%, -50%) scale(2);
381
+ }
382
+ .status.running {
383
+ background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
384
+ color: white;
385
+ animation: pulse 2s ease-in-out infinite;
386
+ }
387
+ .status.stopped {
388
+ background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%);
389
+ color: white;
390
+ }
391
+ .status.suspended {
392
+ background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%);
393
+ color: white;
394
+ }
395
+ .status.deploying {
396
+ background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
397
+ color: white;
398
+ }
399
+ .status.paused {
400
+ background: linear-gradient(135deg, #4b6cb7 0%, #182848 100%);
401
+ color: white;
402
+ }
403
+ .error {
404
+ background: #ffebee;
405
+ color: #c62828;
406
+ padding: 15px;
407
+ border-radius: 6px;
408
+ margin-bottom: 15px;
409
+ }
410
+ .loading {
411
+ text-align: center;
412
+ padding: 40px;
413
+ color: white;
414
+ font-size: 16px;
415
+ }
416
+ .stats {
417
+ display: grid;
418
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
419
+ gap: 10px;
420
+ margin-top: 10px;
421
+ }
422
+ .stat-box {
423
+ padding: 10px;
424
+ border-radius: 4px;
425
+ text-align: center;
426
+ }
427
+ .stat-label {
428
+ font-size: 12px;
429
+ color: #666;
430
+ margin-bottom: 5px;
431
+ }
432
+ .stat-value {
433
+ font-size: 18px;
434
+ font-weight: bold;
435
+ color: #333;
436
+ }
437
+ </style>
438
+ </head>
439
+ <body>
440
+ <div id="app">
441
+ <!-- 首次设置密码界面 -->
442
+ <div v-if="showSetPasswordModal" style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 10000;">
443
+ <div style="background: white; padding: 40px; border-radius: 16px; box-shadow: 0 8px 32px rgba(0,0,0,0.2); max-width: 400px; width: 90%;">
444
+ <h2 style="text-align: center; color: #f696c6; margin-bottom: 10px;">🎉 欢迎使用</h2>
445
+ <p style="text-align: center; color: #666; font-size: 14px; margin-bottom: 30px;">请设置管理员密码</p>
446
+ <div style="margin-bottom: 20px;">
447
+ <input
448
+ v-model="setPassword"
449
+ type="password"
450
+ placeholder="请输入密码(至少6位)"
451
+ @keyup.enter="setAdminPassword"
452
+ style="width: 100%; padding: 12px; border: 2px solid #f696c6; border-radius: 8px; font-size: 14px; outline: none;"
453
+ />
454
+ </div>
455
+ <div style="margin-bottom: 20px;">
456
+ <input
457
+ v-model="setPasswordConfirm"
458
+ type="password"
459
+ placeholder="请再次输入密码"
460
+ @keyup.enter="setAdminPassword"
461
+ style="width: 100%; padding: 12px; border: 2px solid #f696c6; border-radius: 8px; font-size: 14px; outline: none;"
462
+ />
463
+ </div>
464
+ <div v-if="setPasswordError" style="color: #c00; font-size: 13px; margin-bottom: 15px; text-align: center;">
465
+ {{ setPasswordError }}
466
+ </div>
467
+ <button
468
+ @click="setAdminPassword"
469
+ style="width: 100%; padding: 12px; background: #f696c6; color: white; border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer;"
470
+ >
471
+ 设置密码
472
+ </button>
473
+ </div>
474
+ </div>
475
+
476
+ <!-- 登录界面 -->
477
+ <div v-if="showLoginModal" style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 10000;">
478
+ <div style="background: white; padding: 40px; border-radius: 16px; box-shadow: 0 8px 32px rgba(0,0,0,0.2); max-width: 400px; width: 90%;">
479
+ <h2 style="text-align: center; color: #f696c6; margin-bottom: 30px;">🔐 管理员登录</h2>
480
+ <div style="margin-bottom: 20px;">
481
+ <input
482
+ v-model="loginPassword"
483
+ type="password"
484
+ placeholder="请输入管理员密码"
485
+ @keyup.enter="verifyPassword"
486
+ style="width: 100%; padding: 12px; border: 2px solid #f696c6; border-radius: 8px; font-size: 14px; outline: none;"
487
+ />
488
+ </div>
489
+ <div v-if="loginError" style="color: #c00; font-size: 13px; margin-bottom: 15px; text-align: center;">
490
+ {{ loginError }}
491
+ </div>
492
+ <button
493
+ @click="verifyPassword"
494
+ style="width: 100%; padding: 12px; background: #f696c6; color: white; border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer;"
495
+ >
496
+ 登录
497
+ </button>
498
+ </div>
499
+ </div>
500
+
501
+ <div v-if="isAuthenticated" class="container">
502
+ <h1>🚀 Zeabur 多账号监控面板</h1>
503
+ <div style="background: rgba(255,255,255,0.9); backdrop-filter: blur(10px); padding: 15px 18px; border-radius: 12px; margin-bottom: 15px; font-size: 13px; border: 1px solid rgba(255,255,255,0.3); box-shadow: 0 4px 12px rgba(0,0,0,0.1);">
504
+ <div style="font-weight: 700; margin-bottom: 8px; color: #f696c6;">💡 使用说明</div>
505
+ <div style="line-height: 1.8; color: #555;">
506
+ • 数据每30秒自动刷新
507
+ </div>
508
+ </div>
509
+
510
+ <div style="display: flex; gap: 10px; margin-bottom: 20px; align-items: center; flex-wrap: wrap;">
511
+ <button class="refresh-btn" @click="fetchData" :disabled="loading">
512
+ {{ loading ? '加载中...' : '🔄 刷新数据' }}
513
+ </button>
514
+ <button class="add-account-btn" @click="showManageModal = true">
515
+ ⚙️ 管理账号
516
+ </button>
517
+ <button class="add-account-btn" @click="clearCache" style="background: rgba(255,100,100,0.3);">
518
+ 🗑️ 清除缓存
519
+ </button>
520
+
521
+ <!-- 透明度控制 -->
522
+ <div style="display: flex; align-items: center; gap: 10px; background: rgba(255,255,255,0.2); backdrop-filter: blur(10px); padding: 8px 16px; border-radius: 25px; border: 1px solid rgba(255,255,255,0.3);">
523
+ <span style="color: white; font-size: 13px; font-weight: 600;">透明度</span>
524
+ <input
525
+ type="range"
526
+ v-model="opacity"
527
+ min="0"
528
+ max="100"
529
+ style="width: 100px; cursor: pointer;"
530
+ />
531
+ <span style="color: white; font-size: 13px; font-weight: 600; min-width: 35px;">{{ opacity }}%</span>
532
+ </div>
533
+
534
+ <span style="color: white; font-size: 14px; margin-left: auto; text-shadow: 0 2px 4px rgba(0,0,0,0.2);">
535
+ 上次更新: {{ lastUpdate }}
536
+ </span>
537
+ </div>
538
+
539
+ <!-- 日志模态框 -->
540
+ <div v-if="showLogsModal" class="modal-overlay" @click.self="showLogsModal = false" style="z-index: 2000;">
541
+ <div style="background: #1e1e1e; border-radius: 16px; width: 90%; max-width: 900px; height: 80vh; display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0,0,0,0.5);">
542
+ <div style="padding: 16px 20px; border-bottom: 1px solid #333; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0;">
543
+ <h2 style="color: #f696c6; font-size: 16px; margin: 0;">📋 {{ logsModalTitle }}</h2>
544
+ <button @click="showLogsModal = false" style="background: #444; color: #fff; border: none; width: 28px; height: 28px; border-radius: 6px; cursor: pointer; font-size: 16px; line-height: 1;">×</button>
545
+ </div>
546
+ <div style="padding: 10px 20px; background: #264f78; color: #d4d4d4; font-size: 12px; display: flex; gap: 16px; flex-wrap: wrap; flex-shrink: 0;">
547
+ <span>项目: {{ logsModalInfo.project }}</span>
548
+ <span>账号: {{ logsModalInfo.account }}</span>
549
+ <span>日志: {{ logsModalInfo.count }} 条</span>
550
+ <span>时间: {{ logsModalInfo.time }}</span>
551
+ </div>
552
+ <div style="flex: 1; overflow-y: auto; padding: 12px 16px; min-height: 0;">
553
+ <div v-if="logsLoading" style="text-align: center; padding: 40px; color: #888;">⏳ 正在加载日志...</div>
554
+ <pre v-else style="background: #252526; padding: 12px; border-radius: 8px; font-family: Consolas, Monaco, monospace; font-size: 12px; line-height: 1.5; white-space: pre-wrap; word-break: break-all; color: #d4d4d4; margin: 0; min-height: 100%;">{{ logsContent || '暂无日志' }}</pre>
555
+ </div>
556
+ <div style="padding: 12px 20px; border-top: 1px solid #333; text-align: right; flex-shrink: 0;">
557
+ <button @click="showLogsModal = false" style="padding: 8px 20px; background: #f696c6; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 13px;">关闭</button>
558
+ </div>
559
+ </div>
560
+ </div>
561
+
562
+ <!-- 账号管理模态框 -->
563
+ <div v-if="showManageModal" class="modal-overlay" @mousedown.self="showManageModal = false">
564
+ <div class="modal-content" style="max-width: 700px;">
565
+ <div class="modal-title">⚙️ 账号管理</div>
566
+
567
+ <!-- 账号列表 -->
568
+ <div style="margin-bottom: 20px;">
569
+ <!-- 显示本地管理的账号 -->
570
+ <div v-for="(account, index) in managedAccounts" :key="'local-' + index"
571
+ style="background: #f9fafb; padding: 15px; border-radius: 8px; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center;">
572
+ <div style="flex: 1;">
573
+ <div style="font-weight: 600; color: #333; margin-bottom: 4px;">{{ account.name }}</div>
574
+ <div style="font-size: 12px; color: #999;">{{ maskEmail(account.email) || '未知邮箱' }}</div>
575
+ </div>
576
+ <button @click="removeAccount(index)"
577
+ style="background: #fee; color: #c00; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 600;">
578
+ 🗑️ 删除
579
+ </button>
580
+ </div>
581
+
582
+ <!-- 显示服务器配置的账号(只读) -->
583
+ <div v-if="managedAccounts.length === 0 && accounts.length > 0">
584
+ <div style="font-size: 12px; color: #666; margin-bottom: 10px; padding: 10px; background: #f0f9ff; border-radius: 6px;">
585
+ 💡 以下账号来自服务器配置(.env 文件),无法在此删除
586
+ </div>
587
+ <div v-for="account in accounts" :key="'server-' + account.name"
588
+ style="background: #f9fafb; padding: 15px; border-radius: 8px; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center;">
589
+ <div style="flex: 1;">
590
+ <div style="font-weight: 600; color: #333; margin-bottom: 4px;">{{ account.name }}</div>
591
+ <div style="font-size: 12px; color: #999;">{{ account.data?.email || account.data?.username || '服务器配置' }}</div>
592
+ </div>
593
+ <span style="font-size: 12px; color: #999; padding: 6px 12px; background: #e5e7eb; border-radius: 6px;">
594
+ 🔒 服务器配置
595
+ </span>
596
+ </div>
597
+ </div>
598
+
599
+ <div v-if="managedAccounts.length === 0 && accounts.length === 0" style="text-align: center; color: #999; padding: 30px;">
600
+ 暂无账号,点击下方按钮添加
601
+ </div>
602
+ </div>
603
+
604
+ <!-- 添加新账号表单 -->
605
+ <div style="border-top: 2px solid #e5e7eb; padding-top: 20px;">
606
+ <div style="font-weight: 600; margin-bottom: 15px; color: #f696c6;">➕ 添加新账号</div>
607
+
608
+ <div class="input-group">
609
+ <label class="input-label">账号名称</label>
610
+ <input
611
+ v-model="newAccount.name"
612
+ type="text"
613
+ class="input-field"
614
+ placeholder="例如:我的账号"
615
+ />
616
+ </div>
617
+
618
+ <div class="input-group">
619
+ <label class="input-label">API Token</label>
620
+ <input
621
+ v-model="newAccount.token"
622
+ type="password"
623
+ class="input-field"
624
+ placeholder="sk-xxxxxxxxxxxxxxxx"
625
+ />
626
+ </div>
627
+
628
+ <div v-if="addAccountError" style="background: #fee; color: #c00; padding: 10px; border-radius: 6px; margin-bottom: 15px; font-size: 13px;">
629
+ {{ addAccountError }}
630
+ </div>
631
+
632
+ <div v-if="addAccountSuccess" style="background: #efe; color: #060; padding: 10px; border-radius: 6px; margin-bottom: 15px; font-size: 13px;">
633
+ {{ addAccountSuccess }}
634
+ </div>
635
+
636
+ <button class="btn-primary" @click="addAccountToList" :disabled="addingAccount">
637
+ {{ addingAccount ? '验证中...' : '➕ 添加到列表' }}
638
+ </button>
639
+ </div>
640
+
641
+ <!-- 批量添加 -->
642
+ <div style="margin-top: 30px; padding-top: 30px; border-top: 2px dashed #e5e7eb;">
643
+ <div style="font-weight: 600; color: #333; margin-bottom: 15px; font-size: 16px;">📦 批量添加账号</div>
644
+ <div style="margin-bottom: 10px; font-size: 13px; color: #666;">
645
+ 每行一个账号,支持格式:<code style="background: #f3f4f6; padding: 2px 6px; border-radius: 4px;">名称:Token</code> 或 <code style="background: #f3f4f6; padding: 2px 6px; border-radius: 4px;">名称:Token</code> 或 <code style="background: #f3f4f6; padding: 2px 6px; border-radius: 4px;">名称(Token)</code> 或 <code style="background: #f3f4f6; padding: 2px 6px; border-radius: 4px;">名称(Token)</code>
646
+ </div>
647
+ <div style="position: relative; min-height: 120px;">
648
+ <div style="width: 100%; min-height: 120px; padding: 12px; border: 2px solid transparent; border-radius: 8px; font-size: 13px; font-family: monospace; white-space: pre-wrap; word-break: break-all; pointer-events: none; color: #333; line-height: 1.5;">{{ maskedBatchAccounts }}</div>
649
+ <textarea
650
+ v-model="batchAccounts"
651
+ @input="updateBatchDisplay"
652
+ placeholder="每行一个账号,格式:账号名称:API_Token"
653
+ style="width: 100%; min-height: 120px; padding: 12px; border: 2px solid #e5e7eb; border-radius: 8px; font-size: 13px; font-family: monospace; resize: vertical; outline: none; position: absolute; top: 0; left: 0; color: transparent; caret-color: black; background: transparent; line-height: 1.5;"
654
+ ></textarea>
655
+ </div>
656
+ <div v-if="batchAddError" style="color: #c00; font-size: 13px; margin-top: 10px;">
657
+ {{ batchAddError }}
658
+ </div>
659
+ <div v-if="batchAddSuccess" style="color: #0a0; font-size: 13px; margin-top: 10px;">
660
+ {{ batchAddSuccess }}
661
+ </div>
662
+ <button
663
+ class="btn-primary"
664
+ @click="batchAddAccounts"
665
+ :disabled="addingAccount"
666
+ style="margin-top: 15px;"
667
+ >
668
+ {{ addingAccount ? '添加中...' : '📦 批量添加' }}
669
+ </button>
670
+ </div>
671
+
672
+ <button class="btn-secondary" @click="closeManageModal">
673
+ 关闭
674
+ </button>
675
+ </div>
676
+ </div>
677
+
678
+ <!-- 旧的添加账号模态框(保留兼容) -->
679
+ <div v-if="showAddModal" class="modal-overlay" @mousedown.self="showAddModal = false">
680
+ <div class="modal-content">
681
+ <div class="modal-title">➕ 添加 Zeabur 账号</div>
682
+
683
+ <div class="input-group">
684
+ <label class="input-label">账号名称</label>
685
+ <input
686
+ v-model="newAccount.name"
687
+ type="text"
688
+ class="input-field"
689
+ placeholder="例如:我的账号"
690
+ />
691
+ </div>
692
+
693
+ <div class="input-group">
694
+ <label class="input-label">API Token</label>
695
+ <input
696
+ v-model="newAccount.token"
697
+ type="password"
698
+ class="input-field"
699
+ placeholder="sk-xxxxxxxxxxxxxxxx"
700
+ />
701
+ <div style="font-size: 12px; color: #999; margin-top: 6px;">
702
+ 在 Zeabur 控制台的设置中创建 API Token
703
+ </div>
704
+ </div>
705
+
706
+ <div v-if="addAccountError" style="background: #fee; color: #c00; padding: 10px; border-radius: 6px; margin-bottom: 15px; font-size: 13px;">
707
+ {{ addAccountError }}
708
+ </div>
709
+
710
+ <div v-if="addAccountSuccess" style="background: #efe; color: #060; padding: 10px; border-radius: 6px; margin-bottom: 15px; font-size: 13px;">
711
+ {{ addAccountSuccess }}
712
+ </div>
713
+
714
+ <button class="btn-primary" @click="addAccount" :disabled="addingAccount">
715
+ {{ addingAccount ? '验证中...' : '添加账号' }}
716
+ </button>
717
+
718
+ <button class="btn-secondary" @click="closeAddModal">
719
+ 取消
720
+ </button>
721
+ </div>
722
+ </div>
723
+
724
+ <div v-if="accounts.length > 0" class="account-card" style="background: rgba(246, 150, 198, 0.3); backdrop-filter: blur(20px) saturate(180%); color: white; border: 1px solid rgba(255,255,255,0.3);">
725
+ <h3 style="margin-bottom: 15px; font-size: 20px; font-weight: 700;">✨ 总览</h3>
726
+ <div class="stats">
727
+ <div class="stat-box" style="background: transparent; border: 1px solid rgba(255,255,255,0.2);">
728
+ <div class="stat-label" style="color: rgba(255,255,255,0.9);">账号数</div>
729
+ <div class="stat-value" style="color: #4ade80;">{{ accounts.length }}</div>
730
+ </div>
731
+ <div class="stat-box" style="background: transparent; border: 1px solid rgba(255,255,255,0.2);">
732
+ <div class="stat-label" style="color: rgba(255,255,255,0.9);">项目总数</div>
733
+ <div class="stat-value" style="color: #4ade80;">{{ totalProjects }}</div>
734
+ </div>
735
+ <div class="stat-box" style="background: transparent; border: 1px solid rgba(255,255,255,0.2);">
736
+ <div class="stat-label" style="color: rgba(255,255,255,0.9);">服务总数</div>
737
+ <div class="stat-value" style="color: #4ade80;">{{ totalServices }}</div>
738
+ </div>
739
+ <div class="stat-box" style="background: transparent; border: 1px solid rgba(255,255,255,0.2);">
740
+ <div class="stat-label" style="color: rgba(255,255,255,0.9);">运行中</div>
741
+ <div class="stat-value" style="color: #4ade80;">{{ runningServices }}</div>
742
+ </div>
743
+ <div class="stat-box" style="background: transparent; border: 1px solid rgba(255,255,255,0.2);">
744
+ <div class="stat-label" style="color: rgba(255,255,255,0.9);">总费用</div>
745
+ <div class="stat-value" style="color: #ef4444;">${{ totalCost.toFixed(2) }}</div>
746
+ </div>
747
+ </div>
748
+ </div>
749
+
750
+ <div v-if="loading && accounts.length === 0" class="loading">
751
+ ⚡ 正在加载数据...
752
+ </div>
753
+
754
+ <div v-for="account in accounts" :key="account.name" class="account-card">
755
+ <div class="account-header" @click="toggleAccount(account.name)" style="cursor: pointer;">
756
+ <div style="flex: 1;">
757
+ <div style="display: flex; align-items: center; gap: 10px;">
758
+ <span style="font-size: 20px; transition: transform 0.3s;" :style="{ transform: isAccountExpanded(account.name) ? 'rotate(90deg)' : 'rotate(0deg)' }">
759
+
760
+ </span>
761
+ <div>
762
+ <div class="account-name">{{ account.name }}</div>
763
+ <div v-if="account.data" style="color: #666; font-size: 14px;">
764
+ {{ maskEmail(account.data.email) || account.data.username }}
765
+ </div>
766
+ </div>
767
+ </div>
768
+ </div>
769
+ <div style="display: flex; flex-direction: column; align-items: flex-end; gap: 8px;">
770
+ <div v-if="account.data" class="balance" :class="getBalanceClass(account.data.credit)">
771
+ ${{ (account.data.credit / 100).toFixed(2) }}
772
+ </div>
773
+ <div v-if="account.aihub && account.aihub.balance"
774
+ style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 8px 16px; border-radius: 12px; font-size: 14px; font-weight: 600; box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);">
775
+ 🤖 AI Hub: ${{ (account.aihub.balance / 100000).toFixed(2) }}
776
+ </div>
777
+ </div>
778
+ </div>
779
+
780
+ <div v-if="account.error" class="error">
781
+ ❌ 错误: {{ account.error }}
782
+ </div>
783
+
784
+ <div v-if="account.projects && isAccountExpanded(account.name)" class="projects-grid">
785
+ <div v-for="project in account.projects" :key="project._id" class="project-card">
786
+ <div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px;">
787
+ <div style="flex: 1;">
788
+ <div class="project-name" style="display: flex; align-items: center; gap: 8px;">
789
+ <span v-if="!project.isEditing">📦 {{ project.name }}</span>
790
+ <input
791
+ v-else
792
+ v-model="project.editingName"
793
+ @keyup.enter="saveProjectName(account, project)"
794
+ @keyup.esc="cancelEditProjectName(project)"
795
+ @blur="saveProjectName(account, project)"
796
+ style="flex: 1; padding: 4px 8px; border: 2px solid #f696c6; border-radius: 6px; font-size: 14px; font-weight: bold; outline: none;"
797
+ ref="projectNameInput"
798
+ />
799
+ <button
800
+ v-if="!project.isEditing"
801
+ @click="startEditProjectName(project)"
802
+ style="padding: 4px 8px; background: rgba(246, 150, 198, 0.2); border: 1px solid rgba(246, 150, 198, 0.4); border-radius: 6px; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center;"
803
+ title="重命名项目"
804
+ >
805
+ ✏️
806
+ </button>
807
+ <button
808
+ v-else
809
+ @click="cancelEditProjectName(project)"
810
+ style="padding: 4px 8px; background: rgba(255, 100, 100, 0.2); border: 1px solid rgba(255, 100, 100, 0.4); border-radius: 6px; cursor: pointer; transition: all 0.2s;"
811
+ title="取消"
812
+ >
813
+ ✖️
814
+ </button>
815
+ </div>
816
+ <div class="project-info">
817
+ 📍 {{ project.region }}
818
+ </div>
819
+ </div>
820
+ <div style="text-align: right;">
821
+ <div style="font-size: 12px; color: #666; margin-bottom: 2px;">本月用量</div>
822
+ <div style="font-size: 20px; font-weight: bold; color: #2196F3;">
823
+ ${{ formatCost(project.cost) }}
824
+ <span v-if="!project.hasCostData"
825
+ style="font-size: 12px; color: #ff9800;"
826
+ title="未配置费用数据">
827
+ ⚠️
828
+ </span>
829
+ </div>
830
+ </div>
831
+ </div>
832
+ <div style="display: flex; gap: 15px; margin-bottom: 12px; font-size: 13px; color: #666;">
833
+ <span>🔧 {{ project.services.length }} 个服务</span>
834
+ <span>✅ {{ project.services.filter(s => s.status === 'RUNNING').length }} 运行中</span>
835
+ <span>⏸️ {{ project.services.filter(s => s.status === 'SUSPENDED').length }} 已暂停</span>
836
+ </div>
837
+ <div class="services-container">
838
+ <div v-for="service in project.services" :key="service._id" class="service-item" style="flex-direction: column; align-items: stretch; padding: 12px;">
839
+ <!-- 服务名称和资源信息 -->
840
+ <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;">
841
+ <div style="flex: 1; min-width: 0;">
842
+ <div style="font-weight: 600; font-size: 14px; color: #333; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 4px;" :title="service.name">
843
+ {{ service.name }}
844
+ </div>
845
+ <div v-if="service.resourceLimit" style="font-size: 11px; color: #666; display: flex; gap: 12px;">
846
+ <span style="display: flex; align-items: center; gap: 4px;">
847
+ <span style="font-size: 14px;">💻</span>
848
+ <span>{{ service.resourceLimit.cpu }}m</span>
849
+ </span>
850
+ <span style="display: flex; align-items: center; gap: 4px;">
851
+ <span style="font-size: 14px;">🧠</span>
852
+ <span>{{ service.resourceLimit.memory }}MB</span>
853
+ </span>
854
+ </div>
855
+ </div>
856
+ </div>
857
+
858
+ <!-- 状态和操作按钮 -->
859
+ <div style="display: flex; gap: 6px; flex-wrap: wrap;">
860
+ <span :class="['status', service.status.toLowerCase()]" style="flex-shrink: 0;">
861
+ {{ service.status === 'RUNNING' ? '✅ 运行中' : service.status === 'SUSPENDED' ? '⏸️ 已暂停' : service.status }}
862
+ </span>
863
+ <button v-if="service.status === 'RUNNING'"
864
+ @click="pauseService(account, project, service)"
865
+ style="padding: 6px 12px; background: linear-gradient(135deg, #ff9500 0%, #ff9f0a 100%); color: white; border: none; border-radius: 12px; cursor: pointer; font-size: 11px; font-weight: 600; transition: transform 0.15s ease-out;">
866
+ ⏸️ 暂停
867
+ </button>
868
+ <button @click="restartService(account, project, service)"
869
+ style="padding: 6px 12px; background: linear-gradient(135deg, #007aff 0%, #5ac8fa 100%); color: white; border: none; border-radius: 12px; cursor: pointer; font-size: 11px; font-weight: 600; transition: transform 0.15s ease-out;">
870
+ {{ service.status === 'SUSPENDED' ? '▶️ 启动' : '🔄 重启' }}
871
+ </button>
872
+ <button @click="showServiceLogs(account, project, service)"
873
+ style="padding: 6px 12px; background: linear-gradient(135deg, #af52de 0%, #bf5af2 100%); color: white; border: none; border-radius: 12px; cursor: pointer; font-size: 11px; font-weight: 600; transition: transform 0.15s ease-out;">
874
+ 📋 日志
875
+ </button>
876
+ </div>
877
+ </div>
878
+ <div v-if="project.services.length === 0" style="color: #999; font-size: 14px; text-align: center; padding: 20px;">
879
+ 暂无服务
880
+ </div>
881
+ </div>
882
+
883
+ <!-- 项目域名 -->
884
+ <div v-if="getProjectDomains(project).length > 0" style="margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(255,255,255,0.3); display: flex; justify-content: center;">
885
+ <div style="display: flex; flex-direction: column; gap: 6px; max-width: 280px; width: 100%;">
886
+ <a v-for="domainInfo in getProjectDomains(project)"
887
+ :key="domainInfo.domain"
888
+ :href="'https://' + domainInfo.domain"
889
+ target="_blank"
890
+ style="display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; text-decoration: none; border-radius: 16px; font-size: 13px; font-weight: 600; transition: all 0.3s; box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);"
891
+ @mouseover="$event.target.style.transform = 'translateY(-2px)'; $event.target.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.5)'"
892
+ @mouseout="$event.target.style.transform = 'translateY(0)'; $event.target.style.boxShadow = '0 2px 8px rgba(102, 126, 234, 0.3)'">
893
+ <div style="display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0;">
894
+ <span style="font-size: 16px; flex-shrink: 0;">🌐</span>
895
+ <span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ domainInfo.domain }}</span>
896
+ </div>
897
+ <span v-if="domainInfo.isGenerated" style="background: rgba(255,255,255,0.25); padding: 3px 10px; border-radius: 10px; font-size: 11px; flex-shrink: 0; margin-left: 6px;">自动</span>
898
+ </a>
899
+ </div>
900
+ </div>
901
+ </div>
902
+ </div>
903
+ </div>
904
+
905
+ <div v-if="!loading && accounts.length === 0" class="error">
906
+ 未配置账号或无法获取数据。请检查 .env 配置文件。
907
+ </div>
908
+ </div>
909
+ </div>
910
+
911
+ <script>
912
+ const { createApp } = Vue;
913
+
914
+ createApp({
915
+ data() {
916
+ return {
917
+ accounts: [],
918
+ loading: false,
919
+ lastUpdate: '--:--:--',
920
+ showAddModal: false,
921
+ showManageModal: false,
922
+ managedAccounts: [],
923
+ projectCosts: {},
924
+ newAccount: {
925
+ name: '',
926
+ token: '',
927
+ balance: ''
928
+ },
929
+ addingAccount: false,
930
+ addAccountError: '',
931
+ addAccountSuccess: '',
932
+ opacity: 39,
933
+ expandedAccounts: {},
934
+ // 密码验证
935
+ isAuthenticated: false,
936
+ showLoginModal: false,
937
+ showSetPasswordModal: false,
938
+ loginPassword: '',
939
+ loginError: '',
940
+ setPassword: '',
941
+ setPasswordConfirm: '',
942
+ setPasswordError: '',
943
+ // 批量添加
944
+ batchAccounts: '',
945
+ maskedBatchAccounts: '',
946
+ batchAddError: '',
947
+ batchAddSuccess: '',
948
+ // 日志模态框
949
+ showLogsModal: false,
950
+ logsModalTitle: '',
951
+ logsModalInfo: {},
952
+ logsContent: '',
953
+ logsLoading: false
954
+ };
955
+ },
956
+ async mounted() {
957
+ // 检查服务器是否已设置密码
958
+ const hasPasswordResponse = await fetch('/api/check-password');
959
+ const { hasPassword } = await hasPasswordResponse.json();
960
+
961
+ if (!hasPassword) {
962
+ // 首次使用,显示设置密码界面
963
+ this.showSetPasswordModal = true;
964
+ return;
965
+ }
966
+
967
+ // 检查本地是否有保存的密码和时间戳
968
+ const savedPassword = localStorage.getItem('admin_password');
969
+ const savedTime = localStorage.getItem('password_time');
970
+
971
+ if (savedPassword && savedTime) {
972
+ const now = Date.now();
973
+ const elapsed = now - parseInt(savedTime);
974
+ const fourDays = 4 * 24 * 60 * 60 * 1000; // 4天的毫秒数
975
+
976
+ if (elapsed < fourDays) {
977
+ // 4天内,自动登录
978
+ this.loginPassword = savedPassword;
979
+ await this.verifyPassword();
980
+ return;
981
+ }
982
+ }
983
+
984
+ // 需要输入密码
985
+ this.showLoginModal = true;
986
+ },
987
+ watch: {
988
+ opacity(newVal) {
989
+ localStorage.setItem('card_opacity', newVal);
990
+ this.updateOpacity();
991
+ }
992
+ },
993
+ beforeUnmount() {
994
+ // 清理定时器,防止内存泄漏
995
+ if (this.refreshInterval) {
996
+ clearInterval(this.refreshInterval);
997
+ }
998
+ },
999
+ computed: {
1000
+ totalProjects() {
1001
+ let total = 0;
1002
+ for (const acc of this.accounts) {
1003
+ if (acc.projects) total += acc.projects.length;
1004
+ }
1005
+ return total;
1006
+ },
1007
+ totalServices() {
1008
+ let total = 0;
1009
+ for (const acc of this.accounts) {
1010
+ if (acc.projects) {
1011
+ for (const p of acc.projects) {
1012
+ if (p.services) total += p.services.length;
1013
+ }
1014
+ }
1015
+ }
1016
+ return total;
1017
+ },
1018
+ runningServices() {
1019
+ let total = 0;
1020
+ for (const acc of this.accounts) {
1021
+ if (acc.projects) {
1022
+ for (const p of acc.projects) {
1023
+ if (p.services) {
1024
+ for (const s of p.services) {
1025
+ if (s.status === 'RUNNING') total++;
1026
+ }
1027
+ }
1028
+ }
1029
+ }
1030
+ }
1031
+ return total;
1032
+ },
1033
+ totalCost() {
1034
+ let total = 0;
1035
+ for (const acc of this.accounts) {
1036
+ if (acc.projects) {
1037
+ for (const p of acc.projects) {
1038
+ total += p.cost || 0;
1039
+ }
1040
+ }
1041
+ }
1042
+ return total;
1043
+ }
1044
+ },
1045
+ methods: {
1046
+ // 设置密码(首次)
1047
+ async setAdminPassword() {
1048
+ this.setPasswordError = '';
1049
+
1050
+ if (!this.setPassword || this.setPassword.length < 6) {
1051
+ this.setPasswordError = '密码长度至少6位';
1052
+ return;
1053
+ }
1054
+
1055
+ if (this.setPassword !== this.setPasswordConfirm) {
1056
+ this.setPasswordError = '两次输入的密码不一致';
1057
+ return;
1058
+ }
1059
+
1060
+ try {
1061
+ const response = await fetch('/api/set-password', {
1062
+ method: 'POST',
1063
+ headers: { 'Content-Type': 'application/json' },
1064
+ body: JSON.stringify({ password: this.setPassword })
1065
+ });
1066
+
1067
+ const result = await response.json();
1068
+ if (result.success) {
1069
+ // 设置成功,自动登录
1070
+ this.loginPassword = this.setPassword;
1071
+ localStorage.setItem('admin_password', this.setPassword);
1072
+ localStorage.setItem('password_time', Date.now().toString());
1073
+
1074
+ this.showSetPasswordModal = false;
1075
+ this.isAuthenticated = true;
1076
+
1077
+ await this.loadManagedAccounts();
1078
+ this.loadProjectCosts();
1079
+ this.fetchData();
1080
+
1081
+ // 启动自动刷新
1082
+ this.refreshInterval = setInterval(() => this.fetchData(), 30000);
1083
+
1084
+ // 加载透明度设置
1085
+ const savedOpacity = localStorage.getItem('card_opacity');
1086
+ if (savedOpacity) {
1087
+ this.opacity = parseInt(savedOpacity);
1088
+ this.updateOpacity();
1089
+ }
1090
+ } else {
1091
+ this.setPasswordError = result.error || '设置失败';
1092
+ }
1093
+ } catch (error) {
1094
+ this.setPasswordError = '设置失败: ' + error.message;
1095
+ }
1096
+ },
1097
+ // 验证密码
1098
+ async verifyPassword() {
1099
+ this.loginError = '';
1100
+ try {
1101
+ const response = await fetch('/api/verify-password', {
1102
+ method: 'POST',
1103
+ headers: { 'Content-Type': 'application/json' },
1104
+ body: JSON.stringify({ password: this.loginPassword })
1105
+ });
1106
+
1107
+ const result = await response.json();
1108
+ if (result.success) {
1109
+ this.isAuthenticated = true;
1110
+ this.showLoginModal = false;
1111
+
1112
+ // 保存密码和时间戳
1113
+ localStorage.setItem('admin_password', this.loginPassword);
1114
+ localStorage.setItem('password_time', Date.now().toString());
1115
+
1116
+ await this.loadManagedAccounts();
1117
+ this.loadProjectCosts();
1118
+ this.fetchData();
1119
+
1120
+ // 启动自动刷新
1121
+ this.refreshInterval = setInterval(() => this.fetchData(), 30000);
1122
+
1123
+ // 加载透明度设置
1124
+ const savedOpacity = localStorage.getItem('card_opacity');
1125
+ if (savedOpacity) {
1126
+ this.opacity = parseInt(savedOpacity);
1127
+ this.updateOpacity();
1128
+ }
1129
+ } else {
1130
+ this.loginError = '密码错误,请重试';
1131
+ }
1132
+ } catch (error) {
1133
+ this.loginError = '验证失败: ' + error.message;
1134
+ }
1135
+ },
1136
+ // 邮箱打码
1137
+ maskEmail(email) {
1138
+ if (!email || !email.includes('@')) return email;
1139
+ const [local, domain] = email.split('@');
1140
+ if (local.length <= 4) return email;
1141
+ const masked = local.substring(0, 2) + 'x'.repeat(local.length - 4) + local.substring(local.length - 2);
1142
+ return masked + '@' + domain;
1143
+ },
1144
+ // 获取请求头(包含密码)
1145
+ getAuthHeaders() {
1146
+ return {
1147
+ 'Content-Type': 'application/json',
1148
+ 'x-admin-password': this.loginPassword
1149
+ };
1150
+ },
1151
+ async loadManagedAccounts() {
1152
+ try {
1153
+ // 从服务器加载账号
1154
+ const response = await fetch('/api/server-accounts', {
1155
+ headers: this.getAuthHeaders()
1156
+ });
1157
+ const accounts = await response.json();
1158
+ if (accounts && accounts.length > 0) {
1159
+ this.managedAccounts = accounts;
1160
+ console.log(`📋 从服务器加载 ${accounts.length} 个账号`);
1161
+ }
1162
+ } catch (error) {
1163
+ console.log('⚠️ 从服务器加载账号失败:', error.message);
1164
+ }
1165
+ },
1166
+ async saveManagedAccounts() {
1167
+ try {
1168
+ // 保存到服务器
1169
+ const response = await fetch('/api/server-accounts', {
1170
+ method: 'POST',
1171
+ headers: this.getAuthHeaders(),
1172
+ body: JSON.stringify({ accounts: this.managedAccounts })
1173
+ });
1174
+ const result = await response.json();
1175
+ if (result.success) {
1176
+ console.log('✅ 账号已保存到服务器');
1177
+ }
1178
+ } catch (error) {
1179
+ console.error('❌ 保存账号到服务器失败:', error.message);
1180
+ }
1181
+ },
1182
+ loadProjectCosts() {
1183
+ const saved = localStorage.getItem('zeabur_project_costs');
1184
+ if (saved) {
1185
+ this.projectCosts = JSON.parse(saved);
1186
+ }
1187
+ },
1188
+
1189
+ async fetchData() {
1190
+ this.loading = true;
1191
+ try {
1192
+ // 如果有账号,使用账号
1193
+ if (this.managedAccounts.length > 0) {
1194
+ // 清除账号中的手动余额,让服务器使用 API 真实数据
1195
+ const accountsWithoutManualBalance = this.managedAccounts.map(acc => ({
1196
+ ...acc,
1197
+ balance: null // 不发送手动余额
1198
+ }));
1199
+
1200
+ const [accountsRes, projectsRes] = await Promise.all([
1201
+ fetch('/api/temp-accounts', {
1202
+ method: 'POST',
1203
+ headers: this.getAuthHeaders(),
1204
+ body: JSON.stringify({ accounts: accountsWithoutManualBalance })
1205
+ }).then(r => r.json()),
1206
+ fetch('/api/temp-projects', {
1207
+ method: 'POST',
1208
+ headers: this.getAuthHeaders(),
1209
+ body: JSON.stringify({
1210
+ accounts: accountsWithoutManualBalance,
1211
+ projectCosts: {} // 不发送手动费用,让服务器尝试从 API 获取
1212
+ })
1213
+ }).then(r => r.json())
1214
+ ]);
1215
+
1216
+ console.log('API 返回的账号数据:', accountsRes);
1217
+ console.log('API 返回的项目数据:', projectsRes);
1218
+
1219
+ this.accounts = accountsRes.map((account, index) => {
1220
+ const projectData = projectsRes[index];
1221
+ console.log(`账号 ${account.name} 余额: ${account.data?.credit} (${account.data?.credit/100} USD)`);
1222
+ return {
1223
+ ...account,
1224
+ projects: projectData.projects || []
1225
+ };
1226
+ });
1227
+ } else {
1228
+ // 否则使用服务器配置的账号
1229
+ const [accountsRes, projectsRes] = await Promise.all([
1230
+ fetch('/api/accounts').then(r => r.json()),
1231
+ fetch('/api/projects').then(r => r.json())
1232
+ ]);
1233
+
1234
+ this.accounts = accountsRes.map((account, index) => {
1235
+ const projectData = projectsRes[index];
1236
+ return {
1237
+ ...account,
1238
+ projects: projectData.projects || []
1239
+ };
1240
+ });
1241
+ }
1242
+ } catch (error) {
1243
+ console.error('获取数据失败:', error);
1244
+ alert('获取数据失败: ' + error.message);
1245
+ } finally {
1246
+ this.loading = false;
1247
+ this.lastUpdate = new Date().toLocaleTimeString('zh-CN');
1248
+ }
1249
+ },
1250
+ getBalanceClass(credit) {
1251
+ const balance = credit / 100;
1252
+ if (balance < 10) return 'critical';
1253
+ if (balance < 50) return 'low';
1254
+ return '';
1255
+ },
1256
+ async batchAddAccounts() {
1257
+ this.batchAddError = '';
1258
+ this.batchAddSuccess = '';
1259
+
1260
+ if (!this.batchAccounts.trim()) {
1261
+ this.batchAddError = '请输入账号信息';
1262
+ return;
1263
+ }
1264
+
1265
+ const lines = this.batchAccounts.trim().split('\n');
1266
+ const accounts = [];
1267
+
1268
+ // 解析每一行
1269
+ for (let i = 0; i < lines.length; i++) {
1270
+ const line = lines[i].trim();
1271
+ if (!line) continue;
1272
+
1273
+ let name = '';
1274
+ let token = '';
1275
+
1276
+ // 尝试匹配括号格式:名称(token) 或 名称(token)
1277
+ const bracketMatch = line.match(/^(.+?)[((](.+?)[))]$/);
1278
+ if (bracketMatch) {
1279
+ name = bracketMatch[1].trim();
1280
+ token = bracketMatch[2].trim();
1281
+ } else if (line.includes(':')) {
1282
+ // 冒号格式:名称:token
1283
+ const parts = line.split(':');
1284
+ name = parts[0].trim();
1285
+ token = parts.slice(1).join(':').trim();
1286
+ } else if (line.includes(':')) {
1287
+ // 中文冒号格式:名称:token
1288
+ const parts = line.split(':');
1289
+ name = parts[0].trim();
1290
+ token = parts.slice(1).join(':').trim();
1291
+ } else {
1292
+ this.batchAddError = `第 ${i + 1} 行格式错误,支持的格式:名称:Token 或 名称:Token 或 名称(Token) 或 名称(Token)`;
1293
+ return;
1294
+ }
1295
+
1296
+ if (!name || !token) {
1297
+ this.batchAddError = `第 ${i + 1} 行:账号名称或 Token 不能为空`;
1298
+ return;
1299
+ }
1300
+
1301
+ accounts.push({ name, token });
1302
+ }
1303
+
1304
+ if (accounts.length === 0) {
1305
+ this.batchAddError = '没有有效的账号信息';
1306
+ return;
1307
+ }
1308
+
1309
+ this.addingAccount = true;
1310
+ let successCount = 0;
1311
+ let failedAccounts = [];
1312
+
1313
+ // 逐个验证并添加
1314
+ for (const account of accounts) {
1315
+ try {
1316
+ const response = await fetch('/api/validate-account', {
1317
+ method: 'POST',
1318
+ headers: this.getAuthHeaders(),
1319
+ body: JSON.stringify({
1320
+ accountName: account.name,
1321
+ apiToken: account.token
1322
+ })
1323
+ });
1324
+
1325
+ const data = await response.json();
1326
+
1327
+ if (response.ok) {
1328
+ // 检查是否已存在
1329
+ const exists = this.managedAccounts.some(acc => acc.name === account.name);
1330
+ if (!exists) {
1331
+ this.managedAccounts.push({
1332
+ name: account.name,
1333
+ token: account.token,
1334
+ email: data.userData.email,
1335
+ username: data.userData.username
1336
+ });
1337
+ successCount++;
1338
+ } else {
1339
+ failedAccounts.push(`${account.name}(已存在)`);
1340
+ }
1341
+ } else {
1342
+ failedAccounts.push(`${account.name}(${data.error || '验证失败'})`);
1343
+ }
1344
+ } catch (error) {
1345
+ failedAccounts.push(`${account.name}(网络错误)`);
1346
+ }
1347
+ }
1348
+
1349
+ this.addingAccount = false;
1350
+
1351
+ if (successCount > 0) {
1352
+ await this.saveManagedAccounts();
1353
+ this.fetchData();
1354
+ }
1355
+
1356
+ // 显示结果
1357
+ if (successCount > 0 && failedAccounts.length === 0) {
1358
+ this.batchAddSuccess = `✅ 成功添加 ${successCount} 个账号`;
1359
+ this.batchAccounts = '';
1360
+ this.maskedBatchAccounts = '';
1361
+ } else if (successCount > 0) {
1362
+ this.batchAddSuccess = `✅ 成功添加 ${successCount} 个账号`;
1363
+ this.batchAddError = `❌ 失败: ${failedAccounts.join(', ')}`;
1364
+ } else {
1365
+ this.batchAddError = `❌ 全部失败: ${failedAccounts.join(', ')}`;
1366
+ }
1367
+
1368
+ // 3秒后清除提示
1369
+ setTimeout(() => {
1370
+ this.batchAddSuccess = '';
1371
+ if (successCount > 0 && failedAccounts.length === 0) {
1372
+ this.batchAddError = '';
1373
+ }
1374
+ }, 3000);
1375
+ },
1376
+ async addAccountToList() {
1377
+ if (!this.newAccount.name || !this.newAccount.token) {
1378
+ this.addAccountError = '请填写账号名称和 API Token';
1379
+ return;
1380
+ }
1381
+
1382
+ this.addingAccount = true;
1383
+ this.addAccountError = '';
1384
+ this.addAccountSuccess = '';
1385
+
1386
+ try {
1387
+ const response = await fetch('/api/validate-account', {
1388
+ method: 'POST',
1389
+ headers: this.getAuthHeaders(),
1390
+ body: JSON.stringify({
1391
+ accountName: this.newAccount.name,
1392
+ apiToken: this.newAccount.token
1393
+ })
1394
+ });
1395
+
1396
+ const data = await response.json();
1397
+
1398
+ if (response.ok) {
1399
+ // 添加到本地列表
1400
+ this.managedAccounts.push({
1401
+ name: this.newAccount.name,
1402
+ token: this.newAccount.token,
1403
+ balance: this.newAccount.balance ? parseFloat(this.newAccount.balance) : null,
1404
+ email: data.userData.email,
1405
+ username: data.userData.username
1406
+ });
1407
+
1408
+ this.saveManagedAccounts();
1409
+ this.addAccountSuccess = `✅ 账号添加成功!用户: ${data.userData.username}`;
1410
+
1411
+ // 清空表单
1412
+ this.newAccount = { name: '', token: '', balance: '' };
1413
+
1414
+ // 刷新数据
1415
+ setTimeout(() => {
1416
+ this.fetchData();
1417
+ this.addAccountSuccess = '';
1418
+ }, 1500);
1419
+ } else {
1420
+ this.addAccountError = data.error || '添加失败';
1421
+ }
1422
+ } catch (error) {
1423
+ this.addAccountError = '网络错误: ' + error.message;
1424
+ } finally {
1425
+ this.addingAccount = false;
1426
+ }
1427
+ },
1428
+ async removeAccount(index) {
1429
+ const accountName = this.managedAccounts[index].name;
1430
+ const password = prompt(`删除账号"${accountName}"需要验证管理员密码:`);
1431
+
1432
+ if (!password) return;
1433
+
1434
+ // 验证密码
1435
+ try {
1436
+ const response = await fetch('/api/verify-password', {
1437
+ method: 'POST',
1438
+ headers: { 'Content-Type': 'application/json' },
1439
+ body: JSON.stringify({ password })
1440
+ });
1441
+
1442
+ const result = await response.json();
1443
+ if (result.success) {
1444
+ this.managedAccounts.splice(index, 1);
1445
+ await this.saveManagedAccounts();
1446
+ this.fetchData();
1447
+ alert('账号已删除');
1448
+ } else {
1449
+ alert('密码错误,删除失败');
1450
+ }
1451
+ } catch (error) {
1452
+ alert('验证失败: ' + error.message);
1453
+ }
1454
+ },
1455
+ closeAddModal() {
1456
+ this.showAddModal = false;
1457
+ this.newAccount = { name: '', token: '', balance: '' };
1458
+ this.addAccountError = '';
1459
+ this.addAccountSuccess = '';
1460
+ },
1461
+ closeManageModal() {
1462
+ this.showManageModal = false;
1463
+ this.newAccount = { name: '', token: '', balance: '' };
1464
+ this.addAccountError = '';
1465
+ this.addAccountSuccess = '';
1466
+ },
1467
+ updateOpacity() {
1468
+ const opacity = this.opacity / 100;
1469
+ const root = document.documentElement;
1470
+ if (!root) return; // 防止 DOM 未加载
1471
+
1472
+ // 设置所有相关的CSS变量
1473
+ root.style.setProperty('--card-opacity', opacity);
1474
+ root.style.setProperty('--service-opacity', Math.min(opacity + 0.05, 1));
1475
+ root.style.setProperty('--blur-amount', `${20 * opacity}px`);
1476
+ root.style.setProperty('--blur-amount-small', `${15 * opacity}px`);
1477
+ root.style.setProperty('--blur-amount-tiny', `${10 * opacity}px`);
1478
+ root.style.setProperty('--saturate-amount', `${100 + 80 * opacity}%`);
1479
+ root.style.setProperty('--shadow-opacity', 0.1 * opacity);
1480
+ root.style.setProperty('--shadow-opacity-light', 0.05 * opacity);
1481
+ root.style.setProperty('--border-opacity', 0.3 * opacity);
1482
+ root.style.setProperty('--border-opacity-light', 0.4 * opacity);
1483
+ root.style.setProperty('--border-opacity-strong', 0.5 * opacity);
1484
+ },
1485
+ clearCache() {
1486
+ if (confirm('确定要清除所有缓存数据吗?这将删除所有本地保存的账号、余额和费用数据。')) {
1487
+ // 清除所有本地数据
1488
+ this.managedAccounts = [];
1489
+ this.projectCosts = {};
1490
+ localStorage.removeItem('zeabur_accounts');
1491
+ localStorage.removeItem('zeabur_project_costs');
1492
+
1493
+ alert('缓存已清除!正在重新获取数据...');
1494
+ this.fetchData();
1495
+ }
1496
+ },
1497
+ toggleAccount(accountName) {
1498
+ this.expandedAccounts[accountName] = !this.expandedAccounts[accountName];
1499
+ },
1500
+ isAccountExpanded(accountName) {
1501
+ return this.expandedAccounts[accountName] !== false;
1502
+ },
1503
+ // 暂停服务
1504
+ async pauseService(account, project, service) {
1505
+ if (!confirm(`确定要暂停服务"${service.name}"吗?`)) return;
1506
+
1507
+ try {
1508
+ // 获取环境 ID
1509
+ const environmentId = project.environments && project.environments[0] ? project.environments[0]._id : null;
1510
+ if (!environmentId) {
1511
+ alert('❌ 无法获取环境 ID,请刷新页面后重试');
1512
+ return;
1513
+ }
1514
+
1515
+ // 获取账号 token
1516
+ const accountData = this.managedAccounts.find(acc => acc.name === account.name);
1517
+ if (!accountData || !accountData.token) {
1518
+ alert('❌ 无法获取账号 token,请重新添加账号');
1519
+ return;
1520
+ }
1521
+
1522
+ const response = await fetch('/api/service/pause', {
1523
+ method: 'POST',
1524
+ headers: this.getAuthHeaders(),
1525
+ body: JSON.stringify({
1526
+ token: accountData.token,
1527
+ serviceId: service._id,
1528
+ environmentId: environmentId
1529
+ })
1530
+ });
1531
+
1532
+ const result = await response.json();
1533
+ if (result.success) {
1534
+ alert('✅ 服务已暂停');
1535
+ this.fetchData();
1536
+ } else {
1537
+ alert('❌ 暂停失败: ' + (result.error || JSON.stringify(result)));
1538
+ }
1539
+ } catch (error) {
1540
+ alert('❌ 操作失败: ' + error.message);
1541
+ }
1542
+ },
1543
+ // 重启服务
1544
+ async restartService(account, project, service) {
1545
+ const action = service.status === 'SUSPENDED' ? '启动' : '重启';
1546
+ if (!confirm(`确定要${action}服务"${service.name}"吗?`)) return;
1547
+
1548
+ try {
1549
+ // 获取环境 ID
1550
+ const environmentId = project.environments && project.environments[0] ? project.environments[0]._id : null;
1551
+ if (!environmentId) {
1552
+ alert('❌ 无法获取环境 ID,请刷新页面后重试');
1553
+ return;
1554
+ }
1555
+
1556
+ // 获取账号 token
1557
+ const accountData = this.managedAccounts.find(acc => acc.name === account.name);
1558
+ if (!accountData || !accountData.token) {
1559
+ alert('❌ 无法获取账号 token,请重新添加账号');
1560
+ return;
1561
+ }
1562
+
1563
+ const response = await fetch('/api/service/restart', {
1564
+ method: 'POST',
1565
+ headers: this.getAuthHeaders(),
1566
+ body: JSON.stringify({
1567
+ token: accountData.token,
1568
+ serviceId: service._id,
1569
+ environmentId: environmentId
1570
+ })
1571
+ });
1572
+
1573
+ const result = await response.json();
1574
+ if (result.success) {
1575
+ alert(`✅ 服务已${action}`);
1576
+ this.fetchData();
1577
+ } else {
1578
+ alert(`❌ ${action}失败: ` + (result.error || JSON.stringify(result)));
1579
+ }
1580
+ } catch (error) {
1581
+ alert('❌ 操作失败: ' + error.message);
1582
+ }
1583
+ },
1584
+ // 查看服务日志
1585
+ async showServiceLogs(account, project, service) {
1586
+ this.logsModalTitle = '服务日志 - ' + service.name;
1587
+ this.logsModalInfo = { project: project.name, account: account.name, count: 0, time: new Date().toLocaleString('zh-CN') };
1588
+ this.logsContent = '';
1589
+ this.logsLoading = true;
1590
+ this.showLogsModal = true;
1591
+
1592
+ try {
1593
+ const environmentId = project.environments && project.environments[0] ? project.environments[0]._id : null;
1594
+ if (!environmentId) { this.logsContent = '❌ 无法获取环境 ID'; this.logsLoading = false; return; }
1595
+
1596
+ const accountData = this.managedAccounts.find(acc => acc.name === account.name);
1597
+ if (!accountData || !accountData.token) { this.logsContent = '❌ 无法获取账号 token'; this.logsLoading = false; return; }
1598
+
1599
+ const response = await fetch('/api/service/logs', {
1600
+ method: 'POST',
1601
+ headers: this.getAuthHeaders(),
1602
+ body: JSON.stringify({ token: accountData.token, serviceId: service._id, environmentId: environmentId, projectId: project._id, limit: 200 })
1603
+ });
1604
+
1605
+ const result = await response.json();
1606
+ if (result.success && result.logs) {
1607
+ this.logsContent = result.logs.map(log => '[' + new Date(log.timestamp).toLocaleString('zh-CN') + '] ' + log.message).join('\n');
1608
+ this.logsModalInfo.count = result.count;
1609
+ } else {
1610
+ this.logsContent = '❌ 获取日志失败: ' + (result.error || '未知错误');
1611
+ }
1612
+ } catch (error) {
1613
+ this.logsContent = '❌ 获取日志失败: ' + error.message;
1614
+ } finally {
1615
+ this.logsLoading = false;
1616
+ }
1617
+ },
1618
+ // 格式化费用显示(小于 $0.01 显示为 $0.01)
1619
+ formatCost(cost) {
1620
+ if (cost > 0 && cost < 0.01) {
1621
+ return '0.01';
1622
+ }
1623
+ return cost.toFixed(2);
1624
+ },
1625
+ // 更新批量添加的打码显示
1626
+ updateBatchDisplay() {
1627
+ if (!this.batchAccounts) {
1628
+ this.maskedBatchAccounts = '';
1629
+ return;
1630
+ }
1631
+ const lines = this.batchAccounts.split('\n');
1632
+ this.maskedBatchAccounts = lines.map(line => {
1633
+ // 尝试匹配括号格式:名称(token) 或 名称(token)
1634
+ const bracketMatch = line.match(/^(.+?)[((](.+?)[))]$/);
1635
+ if (bracketMatch) {
1636
+ const name = bracketMatch[1];
1637
+ const bracket = line.includes('(') ? '(' : '(';
1638
+ const closeBracket = line.includes(')') ? ')' : ')';
1639
+ const maskedToken = bracketMatch[2].replace(/./g, '●');
1640
+ return name + bracket + maskedToken + closeBracket;
1641
+ }
1642
+
1643
+ // 冒号格式
1644
+ let separatorIndex = -1;
1645
+ let separator = '';
1646
+
1647
+ if (line.includes(':')) {
1648
+ separatorIndex = line.indexOf(':');
1649
+ separator = ':';
1650
+ } else if (line.includes(':')) {
1651
+ separatorIndex = line.indexOf(':');
1652
+ separator = ':';
1653
+ }
1654
+
1655
+ if (separatorIndex === -1) return line;
1656
+
1657
+ const name = line.substring(0, separatorIndex);
1658
+ const token = line.substring(separatorIndex + 1);
1659
+ return name + separator + token.replace(/./g, '●');
1660
+ }).join('\n');
1661
+ },
1662
+ // 获取项目的所有域名
1663
+ getProjectDomains(project) {
1664
+ const domains = [];
1665
+ if (project.services) {
1666
+ project.services.forEach(service => {
1667
+ if (service.domains && service.domains.length > 0) {
1668
+ service.domains.forEach(d => {
1669
+ if (d.domain) {
1670
+ domains.push({
1671
+ domain: d.domain,
1672
+ isGenerated: d.isGenerated || false
1673
+ });
1674
+ }
1675
+ });
1676
+ }
1677
+ });
1678
+ }
1679
+ return domains;
1680
+ },
1681
+ // 开始编辑项目名称
1682
+ startEditProjectName(project) {
1683
+ project.isEditing = true;
1684
+ project.editingName = project.name;
1685
+ setTimeout(() => {
1686
+ const inputs = document.querySelectorAll('input[type="text"]');
1687
+ const lastInput = inputs[inputs.length - 1];
1688
+ if (lastInput) lastInput.focus();
1689
+ }, 50);
1690
+ },
1691
+ // 取消编辑项目名称
1692
+ cancelEditProjectName(project) {
1693
+ project.isEditing = false;
1694
+ project.editingName = '';
1695
+ },
1696
+ // 保存项目名称
1697
+ async saveProjectName(account, project) {
1698
+ // 如果不在编辑状态,直接返回(避免 blur 事件重复触发)
1699
+ if (!project.isEditing) {
1700
+ return;
1701
+ }
1702
+
1703
+ if (!project.editingName || project.editingName.trim() === '') {
1704
+ alert('❌ 项目名称不能为空');
1705
+ return;
1706
+ }
1707
+
1708
+ if (project.editingName === project.name) {
1709
+ this.cancelEditProjectName(project);
1710
+ return;
1711
+ }
1712
+
1713
+ try {
1714
+ const accountData = this.managedAccounts.find(acc => acc.name === account.name);
1715
+ if (!accountData || !accountData.token) {
1716
+ alert('❌ 无法获取账号 token,请重新添加账号');
1717
+ return;
1718
+ }
1719
+
1720
+ const response = await fetch('/api/project/rename', {
1721
+ method: 'POST',
1722
+ headers: this.getAuthHeaders(),
1723
+ body: JSON.stringify({
1724
+ token: accountData.token,
1725
+ projectId: project._id,
1726
+ newName: project.editingName.trim()
1727
+ })
1728
+ });
1729
+
1730
+ const result = await response.json();
1731
+ if (result.success) {
1732
+ project.name = project.editingName.trim();
1733
+ this.cancelEditProjectName(project);
1734
+ alert('✅ 项目名称已更新');
1735
+ } else {
1736
+ alert('❌ 更新失败: ' + (result.error || '未知错误'));
1737
+ }
1738
+ } catch (error) {
1739
+ alert('❌ 操作失败: ' + error.message);
1740
+ }
1741
+ }
1742
+ }
1743
+ }).mount('#app');
1744
+ </script>
1745
+
1746
+
1747
+ <!-- 项目来源 -->
1748
+ <div style="text-align: center; padding: 30px 20px;">
1749
+ <div style="display: inline-block; background: rgba(255,255,255,0.95); backdrop-filter: blur(10px); padding: 12px 24px; border-radius: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); border: 1px solid rgba(246,150,198,0.3); font-size: 13px;">
1750
+ <span style="color: #666;">⭐ 项目地址:</span>
1751
+ <a href="https://github.com/jiujiu532/zeabur-monitor" target="_blank" style="color: #f696c6; text-decoration: none; font-weight: 600; margin-left: 5px;">
1752
+ github.com/jiujiu532/zeabur-monitor
1753
+ </a>
1754
+ </div>
1755
+ </div>
1756
+
1757
+ </body>
1758
+ </html>
server.js ADDED
@@ -0,0 +1,695 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ require('dotenv').config();
2
+ const express = require('express');
3
+ const cors = require('cors');
4
+ const https = require('https');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ const app = express();
9
+ const PORT = process.env.PORT || 3000;
10
+
11
+ app.use(cors());
12
+ app.use(express.json());
13
+
14
+ // 密码验证中间件
15
+ function requireAuth(req, res, next) {
16
+ const password = req.headers['x-admin-password'];
17
+ const savedPassword = loadAdminPassword();
18
+
19
+ if (!savedPassword) {
20
+ // 如果没有设置密码,允许访问(首次设置)
21
+ next();
22
+ } else if (password === savedPassword) {
23
+ next();
24
+ } else {
25
+ res.status(401).json({ error: '密码错误' });
26
+ }
27
+ }
28
+
29
+ app.use(express.static('public'));
30
+
31
+ // 数据文件路径
32
+ const ACCOUNTS_FILE = path.join(__dirname, 'accounts.json');
33
+ const PASSWORD_FILE = path.join(__dirname, 'password.json');
34
+
35
+ // 读取服务器存储的账号
36
+ function loadServerAccounts() {
37
+ try {
38
+ if (fs.existsSync(ACCOUNTS_FILE)) {
39
+ const data = fs.readFileSync(ACCOUNTS_FILE, 'utf8');
40
+ return JSON.parse(data);
41
+ }
42
+ } catch (e) {
43
+ console.error('❌ 读取账号文件失败:', e.message);
44
+ }
45
+ return [];
46
+ }
47
+
48
+ // 保存账号到服务器
49
+ function saveServerAccounts(accounts) {
50
+ try {
51
+ fs.writeFileSync(ACCOUNTS_FILE, JSON.stringify(accounts, null, 2), 'utf8');
52
+ return true;
53
+ } catch (e) {
54
+ console.error('❌ 保存账号文件失败:', e.message);
55
+ return false;
56
+ }
57
+ }
58
+
59
+ // 读取管理员密码
60
+ function loadAdminPassword() {
61
+ try {
62
+ if (fs.existsSync(PASSWORD_FILE)) {
63
+ const data = fs.readFileSync(PASSWORD_FILE, 'utf8');
64
+ return JSON.parse(data).password;
65
+ }
66
+ } catch (e) {
67
+ console.error('❌ 读取密码文件失败:', e.message);
68
+ }
69
+ return null;
70
+ }
71
+
72
+ // 保存管理员密码
73
+ function saveAdminPassword(password) {
74
+ try {
75
+ fs.writeFileSync(PASSWORD_FILE, JSON.stringify({ password }, null, 2), 'utf8');
76
+ return true;
77
+ } catch (e) {
78
+ console.error('❌ 保存密码文件失败:', e.message);
79
+ return false;
80
+ }
81
+ }
82
+
83
+ // Zeabur GraphQL 查询
84
+ async function queryZeabur(token, query) {
85
+ return new Promise((resolve, reject) => {
86
+ const data = JSON.stringify({ query });
87
+ const options = {
88
+ hostname: 'api.zeabur.com',
89
+ path: '/graphql',
90
+ method: 'POST',
91
+ headers: {
92
+ 'Authorization': `Bearer ${token}`,
93
+ 'Content-Type': 'application/json',
94
+ 'Content-Length': data.length
95
+ },
96
+ timeout: 10000
97
+ };
98
+
99
+ const req = https.request(options, (res) => {
100
+ let body = '';
101
+ res.on('data', (chunk) => body += chunk);
102
+ res.on('end', () => {
103
+ try {
104
+ resolve(JSON.parse(body));
105
+ } catch (e) {
106
+ reject(new Error('Invalid JSON response'));
107
+ }
108
+ });
109
+ });
110
+
111
+ req.on('error', reject);
112
+ req.on('timeout', () => {
113
+ req.destroy();
114
+ reject(new Error('Request timeout'));
115
+ });
116
+ req.write(data);
117
+ req.end();
118
+ });
119
+ }
120
+
121
+ // 获取用户信息和项目
122
+ async function fetchAccountData(token) {
123
+ // 查询用户信息
124
+ const userQuery = `
125
+ query {
126
+ me {
127
+ _id
128
+ username
129
+ email
130
+ credit
131
+ }
132
+ }
133
+ `;
134
+
135
+ // 查询项目信息
136
+ const projectsQuery = `
137
+ query {
138
+ projects {
139
+ edges {
140
+ node {
141
+ _id
142
+ name
143
+ region {
144
+ name
145
+ }
146
+ environments {
147
+ _id
148
+ }
149
+ services {
150
+ _id
151
+ name
152
+ status
153
+ template
154
+ resourceLimit {
155
+ cpu
156
+ memory
157
+ }
158
+ domains {
159
+ domain
160
+ isGenerated
161
+ }
162
+ }
163
+ }
164
+ }
165
+ }
166
+ }
167
+ `;
168
+
169
+ // 查询 AI Hub 余额
170
+ const aihubQuery = `
171
+ query GetAIHubTenant {
172
+ aihubTenant {
173
+ balance
174
+ keys {
175
+ keyID
176
+ alias
177
+ cost
178
+ }
179
+ }
180
+ }
181
+ `;
182
+
183
+ const [userData, projectsData, aihubData] = await Promise.all([
184
+ queryZeabur(token, userQuery),
185
+ queryZeabur(token, projectsQuery),
186
+ queryZeabur(token, aihubQuery).catch(() => ({ data: { aihubTenant: null } }))
187
+ ]);
188
+
189
+ return {
190
+ user: userData.data?.me || {},
191
+ projects: (projectsData.data?.projects?.edges || []).map(edge => edge.node),
192
+ aihub: aihubData.data?.aihubTenant || null
193
+ };
194
+ }
195
+
196
+ // 获取项目用量数据
197
+ async function fetchUsageData(token, userID, projects = []) {
198
+ const now = new Date();
199
+ const year = now.getFullYear();
200
+ const month = now.getMonth() + 1;
201
+ const fromDate = `${year}-${String(month).padStart(2, '0')}-01`;
202
+ // 使用明天的日期确保包含今天的所有数据
203
+ const tomorrow = new Date(now);
204
+ tomorrow.setDate(tomorrow.getDate() + 1);
205
+ const toDate = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`;
206
+
207
+ const usageQuery = {
208
+ operationName: 'GetHeaderMonthlyUsage',
209
+ variables: {
210
+ from: fromDate,
211
+ to: toDate,
212
+ groupByEntity: 'PROJECT',
213
+ groupByTime: 'DAY',
214
+ groupByType: 'ALL',
215
+ userID: userID
216
+ },
217
+ query: `query GetHeaderMonthlyUsage($from: String!, $to: String!, $groupByEntity: GroupByEntity, $groupByTime: GroupByTime, $groupByType: GroupByType, $userID: ObjectID!) {
218
+ usages(
219
+ from: $from
220
+ to: $to
221
+ groupByEntity: $groupByEntity
222
+ groupByTime: $groupByTime
223
+ groupByType: $groupByType
224
+ userID: $userID
225
+ ) {
226
+ categories
227
+ data {
228
+ id
229
+ name
230
+ groupByEntity
231
+ usageOfEntity
232
+ __typename
233
+ }
234
+ __typename
235
+ }
236
+ }`
237
+ };
238
+
239
+ return new Promise((resolve, reject) => {
240
+ const data = JSON.stringify(usageQuery);
241
+ const options = {
242
+ hostname: 'api.zeabur.com',
243
+ path: '/graphql',
244
+ method: 'POST',
245
+ headers: {
246
+ 'Authorization': `Bearer ${token}`,
247
+ 'Content-Type': 'application/json',
248
+ 'Content-Length': Buffer.byteLength(data)
249
+ },
250
+ timeout: 10000
251
+ };
252
+
253
+ const req = https.request(options, (res) => {
254
+ let body = '';
255
+ res.on('data', (chunk) => body += chunk);
256
+ res.on('end', () => {
257
+ try {
258
+ const result = JSON.parse(body);
259
+ const usages = result.data?.usages?.data || [];
260
+
261
+ // 计算每个项目的总费用
262
+ const projectCosts = {};
263
+ let totalUsage = 0;
264
+
265
+ usages.forEach(project => {
266
+ const projectTotal = project.usageOfEntity.reduce((a, b) => a + b, 0);
267
+ // 单个项目显示:向上取整到 $0.01(与 Zeabur 官方一致)
268
+ const displayCost = projectTotal > 0 ? Math.ceil(projectTotal * 100) / 100 : 0;
269
+ projectCosts[project.id] = displayCost;
270
+ // 总用量计算:使用原始费用(不取整,保证总余额准确)
271
+ totalUsage += projectTotal;
272
+ });
273
+
274
+ resolve({
275
+ projectCosts,
276
+ totalUsage,
277
+ freeQuotaRemaining: 5 - totalUsage, // 免费额度 $5
278
+ freeQuotaLimit: 5
279
+ });
280
+ } catch (e) {
281
+ reject(new Error('Invalid JSON response'));
282
+ }
283
+ });
284
+ });
285
+
286
+ req.on('error', reject);
287
+ req.on('timeout', () => {
288
+ req.destroy();
289
+ reject(new Error('Request timeout'));
290
+ });
291
+ req.write(data);
292
+ req.end();
293
+ });
294
+ }
295
+
296
+ // 临时账号API - 获取账号信息
297
+ app.post('/api/temp-accounts', requireAuth, express.json(), async (req, res) => {
298
+ const { accounts } = req.body;
299
+
300
+ console.log('📥 收到账号请求:', accounts?.length, '个账号');
301
+
302
+ if (!accounts || !Array.isArray(accounts)) {
303
+ return res.status(400).json({ error: '无效的账号列表' });
304
+ }
305
+
306
+ const results = await Promise.all(accounts.map(async (account) => {
307
+ try {
308
+ console.log(`🔍 正在获取账号 [${account.name}] 的数据...`);
309
+ const { user, projects, aihub } = await fetchAccountData(account.token);
310
+ console.log(` API 返回的 credit: ${user.credit}`);
311
+
312
+ // 获取用量数据
313
+ let usageData = { totalUsage: 0, freeQuotaRemaining: 5, freeQuotaLimit: 5 };
314
+ if (user._id) {
315
+ try {
316
+ usageData = await fetchUsageData(account.token, user._id, projects);
317
+ console.log(`💰 [${account.name}] 用量: $${usageData.totalUsage.toFixed(2)}, 剩余: $${usageData.freeQuotaRemaining.toFixed(2)}`);
318
+ } catch (e) {
319
+ console.log(`⚠️ [${account.name}] 获取用量失败:`, e.message);
320
+ }
321
+ }
322
+
323
+ // 计算剩余额度并转换为 credit(以分为单位)
324
+ const creditInCents = Math.round(usageData.freeQuotaRemaining * 100);
325
+
326
+ return {
327
+ name: account.name,
328
+ success: true,
329
+ data: {
330
+ ...user,
331
+ credit: creditInCents, // 使用计算的剩余额度
332
+ totalUsage: usageData.totalUsage,
333
+ freeQuotaLimit: usageData.freeQuotaLimit
334
+ },
335
+ aihub: aihub
336
+ };
337
+ } catch (error) {
338
+ console.error(`❌ [${account.name}] 错误:`, error.message);
339
+ return {
340
+ name: account.name,
341
+ success: false,
342
+ error: error.message
343
+ };
344
+ }
345
+ }));
346
+
347
+ console.log('📤 返回结果:', results.length, '个账号');
348
+ res.json(results);
349
+ });
350
+
351
+ // 临时账号API - 获取项目信息
352
+ app.post('/api/temp-projects', requireAuth, express.json(), async (req, res) => {
353
+ const { accounts } = req.body;
354
+
355
+ console.log('📥 收到项目请求:', accounts?.length, '个账号');
356
+
357
+ if (!accounts || !Array.isArray(accounts)) {
358
+ return res.status(400).json({ error: '无效的账号列表' });
359
+ }
360
+
361
+ const results = await Promise.all(accounts.map(async (account) => {
362
+ try {
363
+ console.log(`🔍 正在获��账号 [${account.name}] 的项目...`);
364
+ const { user, projects } = await fetchAccountData(account.token);
365
+
366
+ // 获取用量数据
367
+ let projectCosts = {};
368
+ if (user._id) {
369
+ try {
370
+ const usageData = await fetchUsageData(account.token, user._id, projects);
371
+ projectCosts = usageData.projectCosts;
372
+ } catch (e) {
373
+ console.log(`⚠️ [${account.name}] 获取用量失败:`, e.message);
374
+ }
375
+ }
376
+
377
+ console.log(`📦 [${account.name}] 找到 ${projects.length} 个项目`);
378
+
379
+ const projectsWithCost = projects.map(project => {
380
+ const cost = projectCosts[project._id] || 0;
381
+ console.log(` - ${project.name}: $${cost.toFixed(2)}`);
382
+
383
+ return {
384
+ _id: project._id,
385
+ name: project.name,
386
+ region: project.region?.name || 'Unknown',
387
+ environments: project.environments || [],
388
+ services: project.services || [],
389
+ cost: cost,
390
+ hasCostData: cost > 0
391
+ };
392
+ });
393
+
394
+ return {
395
+ name: account.name,
396
+ success: true,
397
+ projects: projectsWithCost
398
+ };
399
+ } catch (error) {
400
+ console.error(`❌ [${account.name}] 错误:`, error.message);
401
+ return {
402
+ name: account.name,
403
+ success: false,
404
+ error: error.message
405
+ };
406
+ }
407
+ }));
408
+
409
+ console.log('📤 返回项目结果');
410
+ res.json(results);
411
+ });
412
+
413
+ // 验证账号
414
+ app.post('/api/validate-account', requireAuth, express.json(), async (req, res) => {
415
+ const { accountName, apiToken } = req.body;
416
+
417
+ if (!accountName || !apiToken) {
418
+ return res.status(400).json({ error: '账号名称和 API Token 不能为空' });
419
+ }
420
+
421
+ try {
422
+ const { user } = await fetchAccountData(apiToken);
423
+
424
+ if (user._id) {
425
+ res.json({
426
+ success: true,
427
+ message: '账号验证成功!',
428
+ userData: user,
429
+ accountName,
430
+ apiToken
431
+ });
432
+ } else {
433
+ res.status(400).json({ error: 'API Token 无效或没有权限' });
434
+ }
435
+ } catch (error) {
436
+ res.status(400).json({ error: 'API Token 验证失败: ' + error.message });
437
+ }
438
+ });
439
+
440
+ // 从环境变量读取预配置的账号
441
+ function getEnvAccounts() {
442
+ const accountsEnv = process.env.ACCOUNTS;
443
+ if (!accountsEnv) return [];
444
+
445
+ try {
446
+ // 格式: "账号1名称:token1,账号2名称:token2"
447
+ return accountsEnv.split(',').map(item => {
448
+ const [name, token] = item.split(':');
449
+ return { name: name.trim(), token: token.trim() };
450
+ }).filter(acc => acc.name && acc.token);
451
+ } catch (e) {
452
+ console.error('❌ 解析环境变量 ACCOUNTS 失败:', e.message);
453
+ return [];
454
+ }
455
+ }
456
+
457
+ // 检查是否已设置密码
458
+ app.get('/api/check-password', (req, res) => {
459
+ const savedPassword = loadAdminPassword();
460
+ res.json({ hasPassword: !!savedPassword });
461
+ });
462
+
463
+ // 设置管理员密码(首次)
464
+ app.post('/api/set-password', (req, res) => {
465
+ const { password } = req.body;
466
+ const savedPassword = loadAdminPassword();
467
+
468
+ if (savedPassword) {
469
+ return res.status(400).json({ error: '密码已设置,无法重复设置' });
470
+ }
471
+
472
+ if (!password || password.length < 6) {
473
+ return res.status(400).json({ error: '密码长度至少6位' });
474
+ }
475
+
476
+ if (saveAdminPassword(password)) {
477
+ console.log('✅ 管理员密码已设置');
478
+ res.json({ success: true });
479
+ } else {
480
+ res.status(500).json({ error: '保存密码失败' });
481
+ }
482
+ });
483
+
484
+ // 验证密码
485
+ app.post('/api/verify-password', (req, res) => {
486
+ const { password } = req.body;
487
+ const savedPassword = loadAdminPassword();
488
+
489
+ if (!savedPassword) {
490
+ return res.status(400).json({ success: false, error: '请先设置密码' });
491
+ }
492
+
493
+ if (password === savedPassword) {
494
+ res.json({ success: true });
495
+ } else {
496
+ res.status(401).json({ success: false, error: '密码错误' });
497
+ }
498
+ });
499
+
500
+ // 获取所有账号(服务器存储 + 环境变量)
501
+ app.get('/api/server-accounts', requireAuth, async (req, res) => {
502
+ const serverAccounts = loadServerAccounts();
503
+ const envAccounts = getEnvAccounts();
504
+
505
+ // 合并账号,环境变量账号优先
506
+ const allAccounts = [...envAccounts, ...serverAccounts];
507
+ console.log(`📋 返回 ${allAccounts.length} 个账号 (环境变量: ${envAccounts.length}, 服务器: ${serverAccounts.length})`);
508
+ res.json(allAccounts);
509
+ });
510
+
511
+ // 保存账号到服务器
512
+ app.post('/api/server-accounts', requireAuth, async (req, res) => {
513
+ const { accounts } = req.body;
514
+
515
+ if (!accounts || !Array.isArray(accounts)) {
516
+ return res.status(400).json({ error: '无效的账号列表' });
517
+ }
518
+
519
+ if (saveServerAccounts(accounts)) {
520
+ console.log(`✅ 保存 ${accounts.length} 个账号到服务器`);
521
+ res.json({ success: true, message: '账号已保存到服务器' });
522
+ } else {
523
+ res.status(500).json({ error: '保存失败' });
524
+ }
525
+ });
526
+
527
+ // 删除服务器账号
528
+ app.delete('/api/server-accounts/:index', requireAuth, async (req, res) => {
529
+ const index = parseInt(req.params.index);
530
+ const accounts = loadServerAccounts();
531
+
532
+ if (index >= 0 && index < accounts.length) {
533
+ const removed = accounts.splice(index, 1);
534
+ if (saveServerAccounts(accounts)) {
535
+ console.log(`🗑️ 删除账号: ${removed[0].name}`);
536
+ res.json({ success: true, message: '账号已删除' });
537
+ } else {
538
+ res.status(500).json({ error: '删除失败' });
539
+ }
540
+ } else {
541
+ res.status(404).json({ error: '账号不存在' });
542
+ }
543
+ });
544
+
545
+ // 服务器配置的账号API(兼容旧版本)
546
+ app.get('/api/accounts', async (req, res) => {
547
+ res.json([]);
548
+ });
549
+
550
+ app.get('/api/projects', async (req, res) => {
551
+ res.json([]);
552
+ });
553
+
554
+ // 暂停服务
555
+ app.post('/api/service/pause', requireAuth, async (req, res) => {
556
+ const { token, serviceId, environmentId } = req.body;
557
+
558
+ if (!token || !serviceId || !environmentId) {
559
+ return res.status(400).json({ error: '缺少必要参数' });
560
+ }
561
+
562
+ try {
563
+ const mutation = `mutation { suspendService(serviceID: "${serviceId}", environmentID: "${environmentId}") }`;
564
+ const result = await queryZeabur(token, mutation);
565
+
566
+ if (result.data?.suspendService) {
567
+ res.json({ success: true, message: '服务已暂停' });
568
+ } else {
569
+ res.status(400).json({ error: '暂停失败', details: result });
570
+ }
571
+ } catch (error) {
572
+ res.status(500).json({ error: '暂停服务失败: ' + error.message });
573
+ }
574
+ });
575
+
576
+ // 重启服务
577
+ app.post('/api/service/restart', requireAuth, async (req, res) => {
578
+ const { token, serviceId, environmentId } = req.body;
579
+
580
+ if (!token || !serviceId || !environmentId) {
581
+ return res.status(400).json({ error: '缺少必要参数' });
582
+ }
583
+
584
+ try {
585
+ const mutation = `mutation { restartService(serviceID: "${serviceId}", environmentID: "${environmentId}") }`;
586
+ const result = await queryZeabur(token, mutation);
587
+
588
+ if (result.data?.restartService) {
589
+ res.json({ success: true, message: '服务已重启' });
590
+ } else {
591
+ res.status(400).json({ error: '重启失败', details: result });
592
+ }
593
+ } catch (error) {
594
+ res.status(500).json({ error: '重启服务失败: ' + error.message });
595
+ }
596
+ });
597
+
598
+ // 获取服务日志
599
+ app.post('/api/service/logs', requireAuth, express.json(), async (req, res) => {
600
+ const { token, serviceId, environmentId, projectId, limit = 200 } = req.body;
601
+
602
+ if (!token || !serviceId || !environmentId || !projectId) {
603
+ return res.status(400).json({ error: '缺少必要参数' });
604
+ }
605
+
606
+ try {
607
+ const query = `
608
+ query {
609
+ runtimeLogs(
610
+ projectID: "${projectId}"
611
+ serviceID: "${serviceId}"
612
+ environmentID: "${environmentId}"
613
+ ) {
614
+ message
615
+ timestamp
616
+ }
617
+ }
618
+ `;
619
+
620
+ const result = await queryZeabur(token, query);
621
+
622
+ if (result.data?.runtimeLogs) {
623
+ // 按时间戳排序,最新的在最后
624
+ const sortedLogs = result.data.runtimeLogs.sort((a, b) => {
625
+ return new Date(a.timestamp) - new Date(b.timestamp);
626
+ });
627
+
628
+ // 获取最后 N 条日志
629
+ const logs = sortedLogs.slice(-limit);
630
+
631
+ res.json({
632
+ success: true,
633
+ logs,
634
+ count: logs.length,
635
+ totalCount: result.data.runtimeLogs.length
636
+ });
637
+ } else {
638
+ res.status(400).json({ error: '获取日志失败', details: result });
639
+ }
640
+ } catch (error) {
641
+ res.status(500).json({ error: '获取日志失败: ' + error.message });
642
+ }
643
+ });
644
+
645
+ // 重命名项目
646
+ app.post('/api/project/rename', requireAuth, async (req, res) => {
647
+ const { token, projectId, newName } = req.body;
648
+
649
+ console.log(`📝 收到重命名请求: projectId=${projectId}, newName=${newName}`);
650
+
651
+ if (!token || !projectId || !newName) {
652
+ return res.status(400).json({ error: '缺少必要参数' });
653
+ }
654
+
655
+ try {
656
+ const mutation = `mutation { renameProject(_id: "${projectId}", name: "${newName}") }`;
657
+ console.log(`🔍 发送 GraphQL mutation:`, mutation);
658
+
659
+ const result = await queryZeabur(token, mutation);
660
+ console.log(`📥 API 响应:`, JSON.stringify(result, null, 2));
661
+
662
+ if (result.data?.renameProject) {
663
+ console.log(`✅ 项目已重命名: ${newName}`);
664
+ res.json({ success: true, message: '项目已重命名' });
665
+ } else {
666
+ console.log(`❌ 重命名失败:`, result);
667
+ res.status(400).json({ error: '重命名失败', details: result });
668
+ }
669
+ } catch (error) {
670
+ console.log(`❌ 异常:`, error);
671
+ res.status(500).json({ error: '重命名项目失败: ' + error.message });
672
+ }
673
+ });
674
+
675
+ app.listen(PORT, () => {
676
+ console.log(`✨ Zeabur Monitor 运行在 http://localhost:${PORT}`);
677
+
678
+ const envAccounts = getEnvAccounts();
679
+ const serverAccounts = loadServerAccounts();
680
+ const totalAccounts = envAccounts.length + serverAccounts.length;
681
+
682
+ if (totalAccounts > 0) {
683
+ console.log(`📋 已加载 ${totalAccounts} 个账号`);
684
+ if (envAccounts.length > 0) {
685
+ console.log(` 环境变量: ${envAccounts.length} 个`);
686
+ envAccounts.forEach(acc => console.log(` - ${acc.name}`));
687
+ }
688
+ if (serverAccounts.length > 0) {
689
+ console.log(` 服务器存储: ${serverAccounts.length} 个`);
690
+ serverAccounts.forEach(acc => console.log(` - ${acc.name}`));
691
+ }
692
+ } else {
693
+ console.log(`📊 准备就绪,等待添加账号...`);
694
+ }
695
+ });
zbpack.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "build_command": "npm install",
3
+ "start_command": "npm start"
4
+ }