wzxwhxcz commited on
Commit
70fb5e6
·
verified ·
1 Parent(s): 1cc9b21

Create web/static/app.js

Browse files
Files changed (1) hide show
  1. web/static/app.js +1980 -0
web/static/app.js ADDED
@@ -0,0 +1,1980 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const API_BASE = '/api';
2
+ const REFRESH_INTERVAL = 3000;
3
+ let autoRefreshTimer = null;
4
+
5
+ // Admin Password Management
6
+ let adminPassword = null;
7
+ const ADMIN_STORAGE_KEY = 'zencoder_admin_pass';
8
+
9
+ // State
10
+ let currentState = {
11
+ page: 1,
12
+ size: 10,
13
+ category: 'normal',
14
+ total: 0,
15
+ selectedIds: new Set(),
16
+ items: []
17
+ };
18
+
19
+ // --- Admin Password Management ---
20
+ function savePassword(password) {
21
+ try {
22
+ localStorage.setItem(ADMIN_STORAGE_KEY, password);
23
+ console.log('Password saved to localStorage');
24
+ return true;
25
+ } catch (e) {
26
+ console.error('Failed to save password to localStorage:', e);
27
+ return false;
28
+ }
29
+ }
30
+
31
+ function getSavedPassword() {
32
+ try {
33
+ const saved = localStorage.getItem(ADMIN_STORAGE_KEY);
34
+ if (saved) {
35
+ console.log('Found saved password in localStorage');
36
+ }
37
+ return saved;
38
+ } catch (e) {
39
+ console.error('Failed to get password from localStorage:', e);
40
+ return null;
41
+ }
42
+ }
43
+
44
+ function clearSavedPassword() {
45
+ try {
46
+ localStorage.removeItem(ADMIN_STORAGE_KEY);
47
+ } catch (e) {
48
+ console.error('Failed to clear password from localStorage:', e);
49
+ }
50
+ }
51
+
52
+ async function verifyAdminPassword(password) {
53
+ try {
54
+ // 尝试调用一个需要管理密码的API来验证
55
+ const response = await fetch(`${API_BASE}/accounts?page=1&size=1`, {
56
+ headers: {
57
+ 'X-Admin-Password': password
58
+ }
59
+ });
60
+ return response.ok;
61
+ } catch (e) {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ function showAdminLogin() {
67
+ document.getElementById('adminPasswordModal').classList.remove('hidden');
68
+ document.getElementById('mainApp').classList.add('hidden');
69
+ document.getElementById('adminPassword').focus();
70
+ }
71
+
72
+ function hideAdminLogin() {
73
+ document.getElementById('adminPasswordModal').classList.add('hidden');
74
+ document.getElementById('mainApp').classList.remove('hidden');
75
+ document.getElementById('mainApp').classList.add('flex');
76
+ }
77
+
78
+ async function handleAdminLogin(password, remember = false) {
79
+ console.log('Attempting login, remember:', remember);
80
+
81
+ const isValid = await verifyAdminPassword(password);
82
+
83
+ if (isValid) {
84
+ adminPassword = password;
85
+
86
+ if (remember) {
87
+ const saved = savePassword(password);
88
+ if (!saved) {
89
+ console.warn('Failed to save password to localStorage');
90
+ }
91
+ } else {
92
+ // 如果没有勾选记住,清除之前保存的密码
93
+ clearSavedPassword();
94
+ }
95
+
96
+ hideAdminLogin();
97
+ document.getElementById('passwordError').classList.add('hidden');
98
+
99
+ // 开始加载数据
100
+ initializeApp();
101
+ return true;
102
+ } else {
103
+ document.getElementById('passwordError').classList.remove('hidden');
104
+ return false;
105
+ }
106
+ }
107
+
108
+ function logout() {
109
+ adminPassword = null;
110
+ clearSavedPassword();
111
+
112
+ // 停止自动刷新
113
+ if (autoRefreshTimer) {
114
+ clearInterval(autoRefreshTimer);
115
+ autoRefreshTimer = null;
116
+ }
117
+
118
+ // 显示登录界面
119
+ showAdminLogin();
120
+ }
121
+
122
+ async function initAdminAuth() {
123
+ // 检查是否有保存的密码
124
+ const savedPassword = getSavedPassword();
125
+
126
+ if (savedPassword) {
127
+ console.log('Found saved password, attempting auto-login...');
128
+ // 直接设置密码,不验证(因为验证可能由于网络问题失败)
129
+ adminPassword = savedPassword;
130
+
131
+ // 尝试验证密码
132
+ try {
133
+ const isValid = await verifyAdminPassword(savedPassword);
134
+ if (isValid) {
135
+ console.log('Saved password validated successfully');
136
+ hideAdminLogin();
137
+ initializeApp();
138
+ return;
139
+ } else {
140
+ console.log('Saved password validation failed');
141
+ // 密码无效,清除并显示登录界面
142
+ adminPassword = null;
143
+ clearSavedPassword();
144
+ }
145
+ } catch (e) {
146
+ console.log('Password validation error, keeping saved password:', e);
147
+ // 网络错误时仍然保留密码并尝试使用
148
+ hideAdminLogin();
149
+ initializeApp();
150
+ return;
151
+ }
152
+ }
153
+
154
+ // 显示登录界面
155
+ showAdminLogin();
156
+ }
157
+
158
+ function toggleAdminPasswordVisibility() {
159
+ const input = document.getElementById('adminPassword');
160
+ const eyeIcon = document.getElementById('adminEyeIcon');
161
+
162
+ if (input.type === 'password') {
163
+ input.type = 'text';
164
+ eyeIcon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />';
165
+ } else {
166
+ input.type = 'password';
167
+ eyeIcon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />';
168
+ }
169
+ }
170
+
171
+ // Admin Password Form Handler
172
+ document.addEventListener('DOMContentLoaded', function() {
173
+ const adminForm = document.getElementById('adminPasswordForm');
174
+ if (adminForm) {
175
+ // 检查并恢复记住密码的勾选状态
176
+ const hasSavedPassword = getSavedPassword() !== null;
177
+ if (hasSavedPassword) {
178
+ const rememberCheckbox = document.getElementById('rememberPassword');
179
+ if (rememberCheckbox) {
180
+ rememberCheckbox.checked = true;
181
+ }
182
+ }
183
+
184
+ adminForm.addEventListener('submit', async (e) => {
185
+ e.preventDefault();
186
+
187
+ const password = document.getElementById('adminPassword').value.trim();
188
+ const remember = document.getElementById('rememberPassword').checked;
189
+ const btn = document.getElementById('adminLoginBtn');
190
+ const btnText = document.getElementById('adminBtnText');
191
+ const btnLoading = document.getElementById('adminBtnLoading');
192
+
193
+ if (!password) {
194
+ document.getElementById('passwordError').textContent = '请输入管理密码';
195
+ document.getElementById('passwordError').classList.remove('hidden');
196
+ return;
197
+ }
198
+
199
+ btn.disabled = true;
200
+ btnText.textContent = '验证中...';
201
+ btnLoading.classList.remove('hidden');
202
+
203
+ const success = await handleAdminLogin(password, remember);
204
+
205
+ btn.disabled = false;
206
+ btnText.textContent = '验证';
207
+ btnLoading.classList.add('hidden');
208
+
209
+ if (success) {
210
+ document.getElementById('adminPassword').value = '';
211
+ }
212
+ });
213
+ }
214
+ });
215
+
216
+ function getAuthHeaders() {
217
+ const headers = {};
218
+ if (adminPassword) {
219
+ headers['X-Admin-Password'] = adminPassword;
220
+ }
221
+ return headers;
222
+ }
223
+
224
+ // --- Theme Management ---
225
+ function initTheme() {
226
+ const isDark = localStorage.theme === 'dark' ||
227
+ (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
228
+
229
+ if (isDark) {
230
+ document.documentElement.classList.add('dark');
231
+ } else {
232
+ document.documentElement.classList.remove('dark');
233
+ }
234
+ updateThemeIcons(isDark);
235
+ }
236
+
237
+ function toggleTheme() {
238
+ const isDark = document.documentElement.classList.toggle('dark');
239
+ localStorage.theme = isDark ? 'dark' : 'light';
240
+ updateThemeIcons(isDark);
241
+ }
242
+
243
+ function updateThemeIcons(isDark) {
244
+ const sun = document.getElementById('sunIcon');
245
+ const moon = document.getElementById('moonIcon');
246
+ if (isDark) {
247
+ sun.classList.remove('hidden');
248
+ moon.classList.add('hidden');
249
+ } else {
250
+ sun.classList.add('hidden');
251
+ moon.classList.remove('hidden');
252
+ }
253
+ }
254
+
255
+ document.getElementById('themeToggle').addEventListener('click', toggleTheme);
256
+
257
+ // --- Password Visibility ---
258
+ function togglePasswordVisibility() {
259
+ const input = document.getElementById('client_secret');
260
+ const eyeIcon = document.getElementById('eyeIcon');
261
+
262
+ if (input.type === 'password') {
263
+ input.type = 'text';
264
+ eyeIcon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />';
265
+ } else {
266
+ input.type = 'password';
267
+ eyeIcon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />';
268
+ }
269
+ }
270
+
271
+ // --- Data Logic ---
272
+ const PLAN_LIMITS = { Free: 30, Starter: 280, Core: 750, Advanced: 1900, Max: 4200 };
273
+
274
+ function getStatusConfig(acc) {
275
+ switch (acc.status) {
276
+ case 'banned':
277
+ return { text: '已封禁', class: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', dot: 'bg-red-500' };
278
+ case 'error':
279
+ return { text: '异常', class: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400', dot: 'bg-yellow-500' };
280
+ case 'cooling':
281
+ return { text: '冷却中', class: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', dot: 'bg-orange-500' };
282
+ case 'disabled':
283
+ return { text: '已禁用', class: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400', dot: 'bg-gray-500' };
284
+ default:
285
+ return { text: '正常', class: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', dot: 'bg-green-500' };
286
+ }
287
+ }
288
+
289
+ function getTokenStatusConfig(record) {
290
+ switch (record.status) {
291
+ case 'banned':
292
+ return { text: '已封禁', class: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', dot: 'bg-red-500' };
293
+ case 'expired':
294
+ return { text: '已过期', class: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', dot: 'bg-orange-500' };
295
+ case 'disabled':
296
+ return { text: '已禁用', class: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400', dot: 'bg-gray-500' };
297
+ default:
298
+ return { text: '正常', class: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', dot: 'bg-green-500' };
299
+ }
300
+ }
301
+
302
+ function formatDate(dateStr) {
303
+ if (!dateStr || dateStr.startsWith('0001')) return '-';
304
+ const d = new Date(dateStr);
305
+ return d.toLocaleDateString('zh-CN');
306
+ }
307
+
308
+ function formatLastUsed(dateStr) {
309
+ if (!dateStr || dateStr.startsWith('0001')) return '从未';
310
+
311
+ const date = new Date(dateStr);
312
+ const now = new Date();
313
+ const diff = now - date;
314
+
315
+ // 转换为秒、分钟、小时、天
316
+ const seconds = Math.floor(diff / 1000);
317
+ const minutes = Math.floor(seconds / 60);
318
+ const hours = Math.floor(minutes / 60);
319
+ const days = Math.floor(hours / 24);
320
+
321
+ if (days > 0) {
322
+ return `${days}天前`;
323
+ } else if (hours > 0) {
324
+ return `${hours}小时前`;
325
+ } else if (minutes > 0) {
326
+ return `${minutes}分钟前`;
327
+ } else if (seconds > 0) {
328
+ return `${seconds}秒前`;
329
+ } else {
330
+ return '刚刚';
331
+ }
332
+ }
333
+
334
+ // 格式化积分刷新时间显示
335
+ function formatCreditRefresh(creditRefreshTimeStr) {
336
+ if (!creditRefreshTimeStr || creditRefreshTimeStr.startsWith('0001')) {
337
+ return { text: '未知', class: 'text-gray-400', detail: '' };
338
+ }
339
+
340
+ const refreshTime = new Date(creditRefreshTimeStr);
341
+ const now = new Date();
342
+ const diffMs = refreshTime - now;
343
+
344
+ if (diffMs < 0) {
345
+ // 已过期,需要刷新
346
+ const daysPast = Math.floor(-diffMs / (1000 * 60 * 60 * 24));
347
+ const hoursPast = Math.floor(-diffMs / (1000 * 60 * 60));
348
+
349
+ if (daysPast > 0) {
350
+ return {
351
+ text: '需要刷新',
352
+ class: 'text-red-600 dark:text-red-400',
353
+ detail: `${daysPast}天前过期`
354
+ };
355
+ } else if (hoursPast > 0) {
356
+ return {
357
+ text: '需要刷新',
358
+ class: 'text-red-600 dark:text-red-400',
359
+ detail: `${hoursPast}小时前过期`
360
+ };
361
+ } else {
362
+ return {
363
+ text: '需要刷新',
364
+ class: 'text-red-600 dark:text-red-400',
365
+ detail: '刚过期'
366
+ };
367
+ }
368
+ } else if (diffMs < 1000 * 60 * 60) {
369
+ // 1小时内刷新
370
+ const minutes = Math.floor(diffMs / (1000 * 60));
371
+ return {
372
+ text: `${minutes}分钟后`,
373
+ class: 'text-orange-600 dark:text-orange-400',
374
+ detail: '即将刷新'
375
+ };
376
+ } else if (diffMs < 1000 * 60 * 60 * 24) {
377
+ // 24小时内刷新
378
+ const hours = Math.floor(diffMs / (1000 * 60 * 60));
379
+ return {
380
+ text: `${hours}小时后`,
381
+ class: 'text-yellow-600 dark:text-yellow-400',
382
+ detail: '今日刷新'
383
+ };
384
+ } else {
385
+ // 超过1天
386
+ const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
387
+ return {
388
+ text: `${days}天后`,
389
+ class: 'text-green-600 dark:text-green-400',
390
+ detail: refreshTime.toLocaleDateString('zh-CN') + ' ' + refreshTime.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'})
391
+ };
392
+ }
393
+ }
394
+
395
+ async function loadAccounts(isAutoRefresh = false) {
396
+ try {
397
+ const params = new URLSearchParams({
398
+ page: currentState.page,
399
+ size: currentState.size,
400
+ status: currentState.category // map category to status param
401
+ });
402
+
403
+ const resp = await fetch(`${API_BASE}/accounts?${params}`, {
404
+ headers: getAuthHeaders()
405
+ });
406
+ if (!resp.ok) throw new Error('Failed to fetch');
407
+
408
+ const data = await resp.json();
409
+
410
+ // Handle both old and new API response formats temporarily if needed, but we know it's new
411
+ const items = data.items || [];
412
+ const total = data.total || 0;
413
+
414
+ currentState.items = items;
415
+ currentState.total = total;
416
+
417
+ // Retain selection if items still exist
418
+ const newSet = new Set();
419
+ items.forEach(item => {
420
+ if (currentState.selectedIds.has(item.id)) {
421
+ newSet.add(item.id);
422
+ }
423
+ });
424
+ currentState.selectedIds = newSet;
425
+
426
+ renderAccounts(items);
427
+ updatePaginationUI();
428
+ updateTabsUI();
429
+ updateBatchUI();
430
+
431
+ if (data.stats) {
432
+ updateStatsUI(data.stats);
433
+ }
434
+ } catch (e) {
435
+ if (!isAutoRefresh) console.error("Failed to load accounts", e);
436
+ }
437
+ }
438
+
439
+ function updateStatsUI(stats) {
440
+ if (!stats) return;
441
+ document.getElementById('stat-total-accounts').textContent = stats.total_accounts;
442
+ document.getElementById('stat-active-accounts').textContent = stats.active_accounts;
443
+ document.getElementById('stat-cooling-accounts').textContent = stats.cooling_accounts || 0;
444
+ document.getElementById('stat-banned-accounts').textContent = stats.banned_accounts;
445
+ document.getElementById('stat-error-accounts').textContent = stats.error_accounts;
446
+ document.getElementById('stat-disabled-accounts').textContent = stats.disabled_accounts || 0;
447
+ document.getElementById('stat-today-usage').textContent = stats.today_usage.toFixed(2);
448
+ document.getElementById('stat-total-usage').textContent = stats.total_usage.toFixed(2);
449
+ }
450
+
451
+ function renderAccounts(accounts) {
452
+ const tbody = document.getElementById('accountList');
453
+ const emptyState = document.getElementById('emptyState');
454
+ const tableContainer = document.getElementById('tableContainer');
455
+ const paginationContainer = document.getElementById('paginationContainer');
456
+ const selectAll = document.getElementById('selectAll');
457
+
458
+ if (accounts.length === 0) {
459
+ tbody.innerHTML = '';
460
+ tableContainer.classList.add('hidden');
461
+ paginationContainer.classList.add('hidden');
462
+
463
+ emptyState.classList.remove('hidden');
464
+ emptyState.classList.add('flex');
465
+ selectAll.checked = false;
466
+ selectAll.disabled = true;
467
+ return;
468
+ }
469
+
470
+ tableContainer.classList.remove('hidden');
471
+ paginationContainer.classList.remove('hidden');
472
+
473
+ emptyState.classList.add('hidden');
474
+ emptyState.classList.remove('flex');
475
+
476
+ selectAll.disabled = false;
477
+ selectAll.checked = accounts.length > 0 && accounts.every(a => currentState.selectedIds.has(a.id));
478
+
479
+ const html = accounts.map(acc => {
480
+ const status = getStatusConfig(acc);
481
+ const limit = PLAN_LIMITS[acc.plan_type] || 30;
482
+ const subDate = formatDate(acc.subscription_start_date);
483
+ const emailOrId = acc.email ? acc.email : acc.client_id;
484
+ const shortId = acc.client_id.length > 12 ? acc.client_id.substring(0, 12) + '...' : acc.client_id;
485
+ const usagePercent = Math.min((acc.daily_used / limit) * 100, 100);
486
+ const isSelected = currentState.selectedIds.has(acc.id);
487
+ const lastUsedText = formatLastUsed(acc.last_used);
488
+
489
+ // 格式化Token过期时间
490
+ const formatTokenExpiry = (tokenExpiryStr) => {
491
+ if (!tokenExpiryStr || tokenExpiryStr.startsWith('0001')) {
492
+ return { text: '未知', class: 'text-gray-400', detail: '' };
493
+ }
494
+
495
+ const expiryDate = new Date(tokenExpiryStr);
496
+ const now = new Date();
497
+ const diffMs = expiryDate - now;
498
+
499
+ if (diffMs < 0) {
500
+ // 已过期
501
+ const daysPast = Math.floor(-diffMs / (1000 * 60 * 60 * 24));
502
+ return {
503
+ text: '已过期',
504
+ class: 'text-red-600 dark:text-red-400',
505
+ detail: `${daysPast}天前过期`
506
+ };
507
+ } else if (diffMs < 1000 * 60 * 60) {
508
+ // 1小时内过期
509
+ const minutes = Math.floor(diffMs / (1000 * 60));
510
+ return {
511
+ text: `${minutes}分钟后`,
512
+ class: 'text-red-500 dark:text-red-400',
513
+ detail: '即将过期'
514
+ };
515
+ } else if (diffMs < 1000 * 60 * 60 * 24) {
516
+ // 24小时内过期
517
+ const hours = Math.floor(diffMs / (1000 * 60 * 60));
518
+ return {
519
+ text: `${hours}小时后`,
520
+ class: 'text-orange-600 dark:text-orange-400',
521
+ detail: '今日过期'
522
+ };
523
+ } else {
524
+ // 超过1天
525
+ const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
526
+ return {
527
+ text: `${days}天后`,
528
+ class: 'text-green-600 dark:text-green-400',
529
+ detail: expiryDate.toLocaleDateString('zh-CN')
530
+ };
531
+ }
532
+ };
533
+
534
+ const tokenExpiry = formatTokenExpiry(acc.token_expiry);
535
+
536
+ return `
537
+ <tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors ${isSelected ? 'bg-blue-50 dark:bg-blue-900/10' : ''}">
538
+ <td class="px-6 py-4 whitespace-nowrap text-center">
539
+ <input type="checkbox" onchange="toggleSelect(${acc.id})" ${isSelected ? 'checked' : ''} class="rounded border-gray-300 dark:border-gray-600 text-primary focus:ring-primary h-4 w-4 mx-auto">
540
+ </td>
541
+ <td class="px-6 py-4 whitespace-nowrap text-center">
542
+ <div class="text-sm font-medium text-gray-900 dark:text-white">${acc.id}</div>
543
+ </td>
544
+ <td class="px-6 py-4 whitespace-nowrap">
545
+ <div class="flex items-center justify-center text-center">
546
+ <div>
547
+ <div class="text-sm font-medium text-gray-900 dark:text-white">${emailOrId}</div>
548
+ <div class="text-xs text-gray-500 dark:text-gray-400 font-mono mt-0.5" title="${acc.client_id}">ID: ${shortId}</div>
549
+ </div>
550
+ </div>
551
+ </td>
552
+ <td class="px-6 py-4 whitespace-nowrap text-center">
553
+ <div class="text-sm text-gray-900 dark:text-white font-medium">${acc.plan_type}</div>
554
+ <div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">自: ${subDate}</div>
555
+ </td>
556
+ <td class="px-6 py-4 whitespace-nowrap">
557
+ <div class="w-full max-w-[140px] mx-auto">
558
+ <div class="flex justify-end gap-1 text-xs mb-1">
559
+ <span class="text-gray-900 dark:text-white font-medium">${acc.daily_used.toFixed(2)}</span>
560
+ <span class="text-gray-500">/ ${limit}</span>
561
+ </div>
562
+ <div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5 overflow-hidden">
563
+ <div class="bg-primary h-1.5 rounded-full" style="width: ${usagePercent}%"></div>
564
+ </div>
565
+ <div class="text-[10px] text-gray-400 mt-1">总计: ${acc.total_used.toFixed(2)}</div>
566
+ </div>
567
+ </td>
568
+ <td class="px-6 py-4 whitespace-nowrap text-center">
569
+ <div class="text-sm">
570
+ <span class="${lastUsedText === '从未' ? 'text-gray-400' : 'text-gray-600 dark:text-gray-300'}">${lastUsedText}</span>
571
+ ${acc.last_used && !acc.last_used.startsWith('0001') ?
572
+ `<div class="text-[10px] text-gray-400 mt-0.5">${new Date(acc.last_used).toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'})}</div>`
573
+ : ''}
574
+ </div>
575
+ </td>
576
+ <td class="px-6 py-4 whitespace-nowrap text-center">
577
+ <div class="text-sm">
578
+ <span class="${tokenExpiry.class} font-medium">${tokenExpiry.text}</span>
579
+ ${tokenExpiry.detail ? `<div class="text-[10px] text-gray-400 mt-0.5">${tokenExpiry.detail}</div>` : ''}
580
+ </div>
581
+ </td>
582
+ <td class="px-6 py-4 whitespace-nowrap text-center">
583
+ <div class="text-sm">
584
+ <span class="${formatCreditRefresh(acc.credit_refresh_time).class} font-medium">${formatCreditRefresh(acc.credit_refresh_time).text}</span>
585
+ ${formatCreditRefresh(acc.credit_refresh_time).detail ? `<div class="text-[10px] text-gray-400 mt-0.5">${formatCreditRefresh(acc.credit_refresh_time).detail}</div>` : ''}
586
+ </div>
587
+ </td>
588
+ <td class="px-6 py-4 whitespace-nowrap text-center">
589
+ <span class="px-2.5 py-0.5 inline-flex items-center text-xs font-medium rounded-full ${status.class}">
590
+ <span class="w-1.5 h-1.5 rounded-full ${status.dot} mr-1.5"></span>
591
+ ${status.text}
592
+ </span>
593
+ ${acc.status === 'cooling' && acc.cooling_until && !acc.cooling_until.startsWith('0001') ?
594
+ (() => {
595
+ const coolingDate = new Date(acc.cooling_until);
596
+ const now = new Date();
597
+ const diffMs = coolingDate - now;
598
+
599
+ if (diffMs > 0) {
600
+ const hours = Math.floor(diffMs / (1000 * 60 * 60));
601
+ const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
602
+
603
+ let timeText = '';
604
+ if (hours > 0) {
605
+ timeText = `${hours}小时${minutes}分钟后解除`;
606
+ } else {
607
+ timeText = `${minutes}分钟后解除`;
608
+ }
609
+
610
+ return `<div class="text-[10px] text-orange-500 dark:text-orange-400 mt-1" title="${coolingDate.toLocaleString('zh-CN')}">
611
+ <svg xmlns="http://www.w3.org/2000/svg" class="inline h-3 w-3 mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
612
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
613
+ </svg>
614
+ ${timeText}
615
+ </div>`;
616
+ } else {
617
+ return `<div class="text-[10px] text-orange-500 dark:text-orange-400 mt-1">即将解除</div>`;
618
+ }
619
+ })()
620
+ : acc.ban_reason ? `<div class="text-[10px] text-red-500 dark:text-red-400 mt-1 max-w-[120px] truncate mx-auto" title="${acc.ban_reason}">${acc.ban_reason}</div>` : ''}
621
+ </td>
622
+ <td class="px-6 py-4 whitespace-nowrap text-center text-sm font-medium">
623
+ <div class="flex justify-center gap-3">
624
+ <button onclick="toggleAccount(${acc.id})" class="text-primary hover:text-primary-hover transition-colors" title="${acc.is_active ? '禁用' : '启用'}">
625
+ ${acc.is_active
626
+ ? '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>'
627
+ : '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>'
628
+ }
629
+ </button>
630
+ <button onclick="deleteAccount(${acc.id})" class="text-red-500 hover:text-red-700 transition-colors" title="删除">
631
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
632
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
633
+ </svg>
634
+ </button>
635
+ </div>
636
+ </td>
637
+ </tr>`;
638
+ }).join('');
639
+
640
+ // Optimization: Only update if content changed
641
+ if (tbody.innerHTML !== html) {
642
+ tbody.innerHTML = html;
643
+ }
644
+ }
645
+
646
+ // --- Interactions ---
647
+
648
+ function switchCategory(cat) {
649
+ currentState.category = cat;
650
+ currentState.page = 1;
651
+ currentState.selectedIds.clear();
652
+ loadAccounts();
653
+ }
654
+
655
+ function updateTabsUI() {
656
+ ['normal', 'banned', 'cooling', 'disabled', 'error'].forEach(cat => {
657
+ const btn = document.getElementById(`tab-${cat}`);
658
+ if (currentState.category === cat) {
659
+ btn.className = "px-3 py-1.5 text-xs font-medium rounded-md transition-all bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm";
660
+ } else {
661
+ btn.className = "px-3 py-1.5 text-xs font-medium rounded-md transition-all text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white";
662
+ }
663
+ });
664
+ }
665
+
666
+ function changePage(delta) {
667
+ const newPage = currentState.page + delta;
668
+ if (newPage > 0 && newPage <= Math.ceil(currentState.total / currentState.size)) {
669
+ currentState.page = newPage;
670
+ loadAccounts();
671
+ }
672
+ }
673
+
674
+ function updatePaginationUI() {
675
+ const totalPages = Math.ceil(currentState.total / currentState.size);
676
+ document.getElementById('pageStart').textContent = currentState.total === 0 ? 0 : (currentState.page - 1) * currentState.size + 1;
677
+ document.getElementById('pageEnd').textContent = Math.min(currentState.page * currentState.size, currentState.total);
678
+ document.getElementById('totalItems').textContent = currentState.total;
679
+
680
+ document.getElementById('prevPage').disabled = currentState.page <= 1;
681
+ document.getElementById('nextPage').disabled = currentState.page >= totalPages;
682
+ }
683
+
684
+ function toggleSelectAll() {
685
+ const selectAll = document.getElementById('selectAll');
686
+ if (selectAll.checked) {
687
+ currentState.items.forEach(item => currentState.selectedIds.add(item.id));
688
+ } else {
689
+ currentState.selectedIds.clear();
690
+ }
691
+ renderAccounts(currentState.items); // re-render to show selection state
692
+ updateBatchUI();
693
+ }
694
+
695
+ function toggleSelect(id) {
696
+ if (currentState.selectedIds.has(id)) {
697
+ currentState.selectedIds.delete(id);
698
+ } else {
699
+ currentState.selectedIds.add(id);
700
+ }
701
+ renderAccounts(currentState.items);
702
+ updateBatchUI();
703
+ }
704
+
705
+ function updateBatchUI() {
706
+ const batchActions = document.getElementById('batchActions');
707
+ const countSpan = document.getElementById('selectedCount');
708
+ const count = currentState.selectedIds.size;
709
+
710
+ // 始终���示批量操作区域
711
+ batchActions.classList.remove('hidden');
712
+ batchActions.classList.add('flex');
713
+
714
+ // 根据当前tab和选中状态显示不同的按钮
715
+ const buttonsHtml = getBatchButtonsHtml(currentState.category, count);
716
+
717
+ // 更新按钮区域内容
718
+ if (count > 0) {
719
+ countSpan.textContent = `${count} 选中`;
720
+ countSpan.classList.remove('hidden');
721
+ } else {
722
+ countSpan.classList.add('hidden');
723
+ }
724
+
725
+ // 更新按钮容器
726
+ const buttonsContainer = document.getElementById('batchButtonsContainer');
727
+ if (buttonsContainer) {
728
+ buttonsContainer.innerHTML = buttonsHtml;
729
+ }
730
+ }
731
+
732
+ function getBatchButtonsHtml(category, selectedCount) {
733
+ // 根据是否有选中数据,决定使用哪个函数
734
+ const moveHandler = selectedCount > 0 ? 'batchMove' : 'oneClickMove';
735
+ const deleteHandler = selectedCount > 0 ? 'batchDelete(false)' : 'batchDelete(true)';
736
+
737
+ // 统一的按钮布局,只隐藏当前tab对应的按钮
738
+ return `
739
+ <button onclick="${moveHandler}('normal')" class="px-3 py-1.5 bg-green-600 text-white text-xs font-medium rounded-md hover:bg-green-700 transition-colors ${category === 'normal' ? 'hidden' : ''}">
740
+ 移至正常
741
+ </button>
742
+ <button onclick="${moveHandler}('cooling')" class="px-3 py-1.5 bg-orange-600 text-white text-xs font-medium rounded-md hover:bg-orange-700 transition-colors ${category === 'cooling' ? 'hidden' : ''}">
743
+ 移至冷却
744
+ </button>
745
+ <button onclick="${moveHandler}('disabled')" class="px-3 py-1.5 bg-gray-600 text-white text-xs font-medium rounded-md hover:bg-gray-700 transition-colors ${category === 'disabled' ? 'hidden' : ''}">
746
+ 移至禁用
747
+ </button>
748
+ <button onclick="${moveHandler}('banned')" class="px-3 py-1.5 bg-red-600 text-white text-xs font-medium rounded-md hover:bg-red-700 transition-colors ${category === 'banned' ? 'hidden' : ''}">
749
+ 移至封禁
750
+ </button>
751
+ <button onclick="${moveHandler}('error')" class="px-3 py-1.5 bg-yellow-600 text-white text-xs font-medium rounded-md hover:bg-yellow-700 transition-colors ${category === 'error' ? 'hidden' : ''}">
752
+ 移至异常
753
+ </button>
754
+ <div class="border-l border-gray-300 dark:border-gray-600 mx-2 h-6"></div>
755
+ <button onclick="${deleteHandler}" class="px-3 py-1.5 bg-red-700 text-white text-xs font-medium rounded-md hover:bg-red-800 transition-colors flex items-center gap-1">
756
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
757
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
758
+ </svg>
759
+ ${selectedCount > 0 ? '删除选中' : '删除全部'}
760
+ </button>
761
+ `;
762
+ }
763
+
764
+ async function oneClickMove(targetCategory) {
765
+ // 一键移动当前分类的所有账号到目标分类
766
+ if (currentState.category === targetCategory) {
767
+ alert('当前已在目标分类中');
768
+ return;
769
+ }
770
+
771
+ const confirmMsg = `确定要将当前分类"${getCategoryName(currentState.category)}"的所有账号移至"${getCategoryName(targetCategory)}"吗?`;
772
+ if (!confirm(confirmMsg)) return;
773
+
774
+ try {
775
+ const resp = await fetch(`${API_BASE}/accounts/batch/move-all`, {
776
+ method: 'POST',
777
+ headers: {
778
+ 'Content-Type': 'application/json',
779
+ ...getAuthHeaders()
780
+ },
781
+ body: JSON.stringify({
782
+ from_status: currentState.category,
783
+ to_status: targetCategory
784
+ })
785
+ });
786
+
787
+ if (!resp.ok) throw new Error('One-click move failed');
788
+
789
+ const result = await resp.json();
790
+ alert(`成功移动 ${result.moved_count || 0} 个账号`);
791
+
792
+ // 刷新当前页面
793
+ loadAccounts();
794
+ } catch (e) {
795
+ alert('一键操作失败: ' + e.message);
796
+ }
797
+ }
798
+
799
+ function getCategoryName(category) {
800
+ const names = {
801
+ 'normal': '正常',
802
+ 'cooling': '冷却',
803
+ 'banned': '封禁',
804
+ 'disabled': '禁用',
805
+ 'error': '异常'
806
+ };
807
+ return names[category] || category;
808
+ }
809
+
810
+ async function batchMove(category) {
811
+ if (currentState.selectedIds.size === 0) return;
812
+
813
+ const ids = Array.from(currentState.selectedIds);
814
+ try {
815
+ const resp = await fetch(`${API_BASE}/accounts/batch/category`, {
816
+ method: 'POST',
817
+ headers: {
818
+ 'Content-Type': 'application/json',
819
+ ...getAuthHeaders()
820
+ },
821
+ body: JSON.stringify({ ids, status: category }) // send as status
822
+ });
823
+
824
+ if (!resp.ok) throw new Error('Batch update failed');
825
+
826
+ currentState.selectedIds.clear();
827
+ loadAccounts();
828
+ } catch (e) {
829
+ alert('批量操作失败');
830
+ }
831
+ }
832
+
833
+ // 批量删除功能
834
+ async function batchDelete(deleteAll = false) {
835
+ let confirmMsg;
836
+ let requestData;
837
+
838
+ if (deleteAll) {
839
+ // 删除当前分类的所有账号
840
+ confirmMsg = `确定要删除当前分类"${getCategoryName(currentState.category)}"的所有账号吗?此操作不可逆转!`;
841
+ requestData = {
842
+ delete_all: true,
843
+ status: currentState.category
844
+ };
845
+ } else {
846
+ // 删除选中的账号
847
+ if (currentState.selectedIds.size === 0) {
848
+ alert('请先选择要删除的账号');
849
+ return;
850
+ }
851
+ confirmMsg = `确定要删除选中的 ${currentState.selectedIds.size} 个账号吗?此操作不可逆转!`;
852
+ requestData = {
853
+ ids: Array.from(currentState.selectedIds)
854
+ };
855
+ }
856
+
857
+ if (!confirm(confirmMsg)) return;
858
+
859
+ try {
860
+ const resp = await fetch(`${API_BASE}/accounts/batch/delete`, {
861
+ method: 'POST',
862
+ headers: {
863
+ 'Content-Type': 'application/json',
864
+ ...getAuthHeaders()
865
+ },
866
+ body: JSON.stringify(requestData)
867
+ });
868
+
869
+ if (!resp.ok) {
870
+ const err = await resp.json();
871
+ throw new Error(err.error || 'Delete failed');
872
+ }
873
+
874
+ const result = await resp.json();
875
+ alert(`成功删除 ${result.deleted_count || 0} 个账号`);
876
+
877
+ // 清空选择并刷新页面
878
+ currentState.selectedIds.clear();
879
+ loadAccounts();
880
+ } catch (e) {
881
+ alert('批量删除失败: ' + e.message);
882
+ }
883
+ }
884
+
885
+ // 批量刷新Token功能
886
+ async function batchRefreshToken(refreshAll = false) {
887
+ let confirmMsg;
888
+ let requestData;
889
+
890
+ if (refreshAll) {
891
+ confirmMsg = "确定要刷新所有正常账号的Token吗?此操作可能需要较长时间。";
892
+ requestData = { all: true };
893
+ } else {
894
+ if (currentState.selectedIds.size === 0) {
895
+ alert('请先选择要刷新Token的账号');
896
+ return;
897
+ }
898
+ confirmMsg = `确定要刷新选中的 ${currentState.selectedIds.size} 个账号的Token吗?`;
899
+ requestData = { ids: Array.from(currentState.selectedIds) };
900
+ }
901
+
902
+ if (!confirm(confirmMsg)) return;
903
+
904
+ try {
905
+ // 显示进度弹窗
906
+ showRefreshProgressModal();
907
+ addRefreshProgressLog('开始批量刷新Token...', 'info');
908
+
909
+ const resp = await fetch(`${API_BASE}/accounts/batch/refresh-token`, {
910
+ method: 'POST',
911
+ headers: {
912
+ 'Content-Type': 'application/json',
913
+ ...getAuthHeaders()
914
+ },
915
+ body: JSON.stringify(requestData)
916
+ });
917
+
918
+ if (!resp.ok) {
919
+ const err = await resp.json();
920
+ throw new Error(err.error || 'Unknown error');
921
+ }
922
+
923
+ // 处理流式响应
924
+ const reader = resp.body.getReader();
925
+ const decoder = new TextDecoder();
926
+ let buffer = '';
927
+ let successCount = 0;
928
+ let failCount = 0;
929
+ let total = 0;
930
+
931
+ while (true) {
932
+ const { done, value } = await reader.read();
933
+ if (done) break;
934
+
935
+ buffer += decoder.decode(value, { stream: true });
936
+ const lines = buffer.split('\n');
937
+ buffer = lines.pop(); // 保留不完整的行
938
+
939
+ for (const line of lines) {
940
+ if (!line.trim() || !line.startsWith('data: ')) continue;
941
+
942
+ try {
943
+ const jsonStr = line.substring(6); // 移除 "data: " 前缀
944
+ const event = JSON.parse(jsonStr);
945
+
946
+ switch (event.type) {
947
+ case 'start':
948
+ total = event.total;
949
+ updateRefreshProgress(0, total, '开始刷新');
950
+ addRefreshProgressLog(`准备刷新 ${total} 个账号的Token`, 'info');
951
+ break;
952
+
953
+ case 'success':
954
+ successCount++;
955
+ updateRefreshProgress(successCount + failCount, total, '刷新中');
956
+ addRefreshProgressLog(`✓ [${event.index}/${total}] 刷新成功: ${event.account_id}${event.email ? ` (${event.email})` : ''}`, 'success');
957
+ break;
958
+
959
+ case 'error':
960
+ failCount++;
961
+ updateRefreshProgress(successCount + failCount, total, '刷新中');
962
+ addRefreshProgressLog(`✗ [${event.index}/${total}] ${event.message} (${event.account_id})`, 'error');
963
+ break;
964
+
965
+ case 'complete':
966
+ updateRefreshProgress(total, total, '完成');
967
+ addRefreshProgressLog(`批量刷新完成!成功 ${event.success} 个,失败 ${event.fail} 个`, 'info');
968
+ showRefreshProgressSummary(event.success, event.fail);
969
+ break;
970
+ }
971
+ } catch (e) {
972
+ console.error('解析事件失败:', e, line);
973
+ }
974
+ }
975
+ }
976
+
977
+ return true;
978
+ } catch (e) {
979
+ addRefreshProgressLog(`错误: ${e.message}`, 'error');
980
+ document.getElementById('refreshProgressCloseBtn').classList.remove('hidden');
981
+ return false;
982
+ }
983
+ }
984
+
985
+ // 刷新进度弹窗管理
986
+ function showRefreshProgressModal() {
987
+ document.getElementById('refreshProgressModal').classList.remove('hidden');
988
+ document.getElementById('refreshProgressLog').innerHTML = '';
989
+ document.getElementById('refreshProgressBar').style.width = '0%';
990
+ document.getElementById('refreshProgressText').textContent = '准备中...';
991
+ document.getElementById('refreshProgressCount').textContent = '0/0';
992
+ document.getElementById('refreshProgressSummary').classList.add('hidden');
993
+ document.getElementById('refreshProgressCloseBtn').classList.add('hidden');
994
+ }
995
+
996
+ function closeRefreshProgressModal() {
997
+ document.getElementById('refreshProgressModal').classList.add('hidden');
998
+ loadAccounts(); // 刷新账号列表
999
+ currentState.selectedIds.clear(); // 清空选择
1000
+ updateBatchUI(); // 更新批量操作UI
1001
+ }
1002
+
1003
+ function addRefreshProgressLog(message, type = 'info') {
1004
+ const log = document.getElementById('refreshProgressLog');
1005
+ const colors = {
1006
+ info: 'text-gray-600 dark:text-gray-400',
1007
+ success: 'text-green-600 dark:text-green-400',
1008
+ error: 'text-red-600 dark:text-red-400',
1009
+ warning: 'text-yellow-600 dark:text-yellow-400'
1010
+ };
1011
+
1012
+ const entry = document.createElement('div');
1013
+ entry.className = colors[type] || colors.info;
1014
+ entry.textContent = message;
1015
+ log.appendChild(entry);
1016
+
1017
+ // 自动滚动到底部
1018
+ log.scrollTop = log.scrollHeight;
1019
+ }
1020
+
1021
+ function updateRefreshProgress(current, total, text) {
1022
+ const percentage = total > 0 ? (current / total) * 100 : 0;
1023
+ document.getElementById('refreshProgressBar').style.width = `${percentage}%`;
1024
+ document.getElementById('refreshProgressText').textContent = text;
1025
+ document.getElementById('refreshProgressCount').textContent = `${current}/${total}`;
1026
+ }
1027
+
1028
+ function showRefreshProgressSummary(success, fail) {
1029
+ document.getElementById('refreshSummarySuccess').textContent = success;
1030
+ document.getElementById('refreshSummaryFail').textContent = fail;
1031
+ document.getElementById('refreshProgressSummary').classList.remove('hidden');
1032
+ document.getElementById('refreshProgressCloseBtn').classList.remove('hidden');
1033
+ }
1034
+
1035
+ async function addAccount(data) {
1036
+ const btn = document.getElementById('submitBtn');
1037
+ const btnText = document.getElementById('btnText');
1038
+ const btnLoading = document.getElementById('btnLoading');
1039
+
1040
+ btn.disabled = true;
1041
+ btnLoading.classList.remove('hidden');
1042
+
1043
+ try {
1044
+ // 生成模式使用流式传输
1045
+ if (data.generate_mode) {
1046
+ btnText.textContent = '生成中...';
1047
+
1048
+ const resp = await fetch(`${API_BASE}/accounts`, {
1049
+ method: 'POST',
1050
+ headers: {
1051
+ 'Content-Type': 'application/json',
1052
+ ...getAuthHeaders()
1053
+ },
1054
+ body: JSON.stringify(data)
1055
+ });
1056
+
1057
+ if (!resp.ok) {
1058
+ const err = await resp.json();
1059
+ throw new Error(err.error || 'Unknown error');
1060
+ }
1061
+
1062
+ // 处理流式响应
1063
+ const reader = resp.body.getReader();
1064
+ const decoder = new TextDecoder();
1065
+ let buffer = '';
1066
+ let successCount = 0;
1067
+ let failCount = 0;
1068
+ let total = 0;
1069
+ let firstSuccessAccount = null; // 记录第一个成功的账号信息
1070
+ let isProgressShown = false;
1071
+
1072
+ while (true) {
1073
+ const { done, value } = await reader.read();
1074
+ if (done) break;
1075
+
1076
+ buffer += decoder.decode(value, { stream: true });
1077
+ const lines = buffer.split('\n');
1078
+ buffer = lines.pop(); // 保留不完整的行
1079
+
1080
+ for (const line of lines) {
1081
+ if (!line.trim() || !line.startsWith('data: ')) continue;
1082
+
1083
+ try {
1084
+ const jsonStr = line.substring(6); // 移除 "data: " 前缀
1085
+ const event = JSON.parse(jsonStr);
1086
+
1087
+ switch (event.type) {
1088
+ case 'start':
1089
+ total = event.total;
1090
+ // 如果���单个账号生成,不显示进度窗口
1091
+ if (total > 1) {
1092
+ showProgressModal();
1093
+ updateProgress(0, total, '开始生成');
1094
+ addProgressLog('开始批量生成凭证...', 'info');
1095
+ addProgressLog(`准备生成 ${total} 个凭证`, 'info');
1096
+ isProgressShown = true;
1097
+ }
1098
+ break;
1099
+
1100
+ case 'success':
1101
+ successCount++;
1102
+ // 记录第一个成功的账号信息(单个生成时使用)
1103
+ if (!firstSuccessAccount && event.email) {
1104
+ firstSuccessAccount = {
1105
+ email: event.email,
1106
+ plan: event.plan,
1107
+ token_expiry: event.token_expiry,
1108
+ subscription_start_date: event.subscription_start_date
1109
+ };
1110
+ }
1111
+
1112
+ if (isProgressShown) {
1113
+ updateProgress(successCount + failCount, total, '生成中');
1114
+ const action = event.action === 'created' ? '创建' : '更新';
1115
+ addProgressLog(`✓ [${event.index}/${total}] ${action}成功: ${event.email} (${event.plan})`, 'success');
1116
+ }
1117
+ break;
1118
+
1119
+ case 'error':
1120
+ failCount++;
1121
+ if (isProgressShown) {
1122
+ updateProgress(successCount + failCount, total, '生成中');
1123
+ const clientInfo = event.client_id ? ` (${event.client_id})` : '';
1124
+ addProgressLog(`✗ [${event.index}/${total}] ${event.message}${clientInfo}`, 'error');
1125
+ }
1126
+ break;
1127
+
1128
+ case 'complete':
1129
+ if (isProgressShown) {
1130
+ // 批量生成完成
1131
+ updateProgress(total, total, '完成');
1132
+ addProgressLog(`批量生成完成!成功 ${event.success} 个,失败 ${event.fail} 个`, 'info');
1133
+ showProgressSummary(event.success, event.fail);
1134
+ } else {
1135
+ // 单个账号生成完成
1136
+ if (firstSuccessAccount) {
1137
+ // 显示账号信息弹窗
1138
+ showAccountInfoModal(firstSuccessAccount);
1139
+ } else if (failCount > 0) {
1140
+ // 生成失败
1141
+ showToast('账号生成失败', 'error');
1142
+ }
1143
+ }
1144
+ break;
1145
+ }
1146
+ } catch (e) {
1147
+ console.error('解析事件失败:', e, line);
1148
+ }
1149
+ }
1150
+ }
1151
+
1152
+ return true;
1153
+ } else {
1154
+ // 凭证模式使用普通请求
1155
+ btnText.textContent = '添加中...';
1156
+
1157
+ const resp = await fetch(`${API_BASE}/accounts`, {
1158
+ method: 'POST',
1159
+ headers: {
1160
+ 'Content-Type': 'application/json',
1161
+ ...getAuthHeaders()
1162
+ },
1163
+ body: JSON.stringify(data)
1164
+ });
1165
+
1166
+ if (!resp.ok) {
1167
+ const err = await resp.json();
1168
+ throw new Error(err.error || 'Unknown error');
1169
+ }
1170
+
1171
+ loadAccounts();
1172
+ return true;
1173
+ }
1174
+ } catch (e) {
1175
+ if (data.generate_mode) {
1176
+ showToast(`生成失败: ${e.message}`, 'error');
1177
+ } else {
1178
+ alert('操作失败: ' + e.message);
1179
+ }
1180
+ return false;
1181
+ } finally {
1182
+ btn.disabled = false;
1183
+ btnText.textContent = currentAddMode === 'credential' ? '添加账号' : '批量生成';
1184
+ btnLoading.classList.add('hidden');
1185
+ }
1186
+ }
1187
+
1188
+ async function deleteAccount(id) {
1189
+ if (!confirm('确定要删除此账号吗?')) return;
1190
+ try {
1191
+ await fetch(`${API_BASE}/accounts/${id}`, {
1192
+ method: 'DELETE',
1193
+ headers: getAuthHeaders()
1194
+ });
1195
+ loadAccounts();
1196
+ } catch (e) {
1197
+ alert('删除失败');
1198
+ }
1199
+ }
1200
+
1201
+ async function toggleAccount(id) {
1202
+ try {
1203
+ await fetch(`${API_BASE}/accounts/${id}/toggle`, {
1204
+ method: 'POST',
1205
+ headers: getAuthHeaders()
1206
+ });
1207
+ loadAccounts();
1208
+ } catch (e) {
1209
+ alert('操作失败');
1210
+ }
1211
+ }
1212
+
1213
+ // --- Progress Modal Management ---
1214
+ function showProgressModal() {
1215
+ document.getElementById('progressModal').classList.remove('hidden');
1216
+ document.getElementById('progressLog').innerHTML = '';
1217
+ document.getElementById('progressBar').style.width = '0%';
1218
+ document.getElementById('progressText').textContent = '准备中...';
1219
+ document.getElementById('progressCount').textContent = '0/0';
1220
+ document.getElementById('progressSummary').classList.add('hidden');
1221
+ document.getElementById('progressCloseBtn').classList.add('hidden');
1222
+ }
1223
+
1224
+ function closeProgressModal() {
1225
+ document.getElementById('progressModal').classList.add('hidden');
1226
+ loadAccounts(); // 刷新账号列表
1227
+ }
1228
+
1229
+ function addProgressLog(message, type = 'info') {
1230
+ const log = document.getElementById('progressLog');
1231
+ const colors = {
1232
+ info: 'text-gray-600 dark:text-gray-400',
1233
+ success: 'text-green-600 dark:text-green-400',
1234
+ error: 'text-red-600 dark:text-red-400',
1235
+ warning: 'text-yellow-600 dark:text-yellow-400'
1236
+ };
1237
+
1238
+ const entry = document.createElement('div');
1239
+ entry.className = colors[type] || colors.info;
1240
+ entry.textContent = message;
1241
+ log.appendChild(entry);
1242
+
1243
+ // 自动滚动到底部
1244
+ log.scrollTop = log.scrollHeight;
1245
+ }
1246
+
1247
+ function updateProgress(current, total, text) {
1248
+ const percentage = total > 0 ? (current / total) * 100 : 0;
1249
+ document.getElementById('progressBar').style.width = `${percentage}%`;
1250
+ document.getElementById('progressText').textContent = text;
1251
+ document.getElementById('progressCount').textContent = `${current}/${total}`;
1252
+ }
1253
+
1254
+ function showProgressSummary(success, fail) {
1255
+ document.getElementById('summarySuccess').textContent = success;
1256
+ document.getElementById('summaryFail').textContent = fail;
1257
+ document.getElementById('progressSummary').classList.remove('hidden');
1258
+ document.getElementById('progressCloseBtn').classList.remove('hidden');
1259
+ }
1260
+
1261
+ // --- Add Mode Management ---
1262
+ let currentAddMode = 'credential'; // 'credential' or 'generate'
1263
+
1264
+ function switchAddMode(mode) {
1265
+ currentAddMode = mode;
1266
+ const credentialBtn = document.getElementById('mode-credential');
1267
+ const generateBtn = document.getElementById('mode-generate');
1268
+ const credentialFields = document.getElementById('credentialFields');
1269
+ const generateFields = document.getElementById('generateFields');
1270
+ const submitBtn = document.getElementById('btnText');
1271
+
1272
+ if (mode === 'credential') {
1273
+ credentialBtn.classList.add('bg-white', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white', 'shadow-sm');
1274
+ credentialBtn.classList.remove('text-gray-500', 'hover:text-gray-900', 'dark:text-gray-400', 'dark:hover:text-white');
1275
+ generateBtn.classList.remove('bg-white', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white', 'shadow-sm');
1276
+ generateBtn.classList.add('text-gray-500', 'hover:text-gray-900', 'dark:text-gray-400', 'dark:hover:text-white');
1277
+ credentialFields.classList.remove('hidden');
1278
+ generateFields.classList.add('hidden');
1279
+ submitBtn.textContent = '添加账号';
1280
+ } else {
1281
+ generateBtn.classList.add('bg-white', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white', 'shadow-sm');
1282
+ generateBtn.classList.remove('text-gray-500', 'hover:text-gray-900', 'dark:text-gray-400', 'dark:hover:text-white');
1283
+ credentialBtn.classList.remove('bg-white', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white', 'shadow-sm');
1284
+ credentialBtn.classList.add('text-gray-500', 'hover:text-gray-900', 'dark:text-gray-400', 'dark:hover:text-white');
1285
+ credentialFields.classList.add('hidden');
1286
+ generateFields.classList.remove('hidden');
1287
+ submitBtn.textContent = '批量生成';
1288
+ }
1289
+ }
1290
+
1291
+ // Form Handler
1292
+ document.getElementById('addForm').addEventListener('submit', async (e) => {
1293
+ e.preventDefault();
1294
+
1295
+ let data;
1296
+
1297
+ if (currentAddMode === 'credential') {
1298
+ const accessToken = document.getElementById('credential_access_token').value.trim();
1299
+ const refreshToken = document.getElementById('credential_refresh_token').value.trim();
1300
+ const proxy = document.getElementById('proxy').value.trim();
1301
+
1302
+ if (!accessToken && !refreshToken) {
1303
+ alert('请填写 Access Token 或 Refresh Token(至少一个)');
1304
+ return;
1305
+ }
1306
+
1307
+ data = {
1308
+ proxy: proxy,
1309
+ generate_mode: false
1310
+ };
1311
+
1312
+ // 提交用户填写的所有token字段,后端会根据优先级处理
1313
+ if (accessToken) {
1314
+ data.access_token = accessToken;
1315
+ }
1316
+ if (refreshToken) {
1317
+ data.refresh_token = refreshToken;
1318
+ }
1319
+ } else {
1320
+ const masterAccessToken = document.getElementById('generate_access_token').value.trim();
1321
+ const masterRefreshToken = document.getElementById('generate_refresh_token').value.trim();
1322
+ const proxy = document.getElementById('proxy').value.trim();
1323
+
1324
+ if (!masterAccessToken && !masterRefreshToken) {
1325
+ alert('请填写 Master Access Token 或 Master Refresh Token(至少一个)');
1326
+ return;
1327
+ }
1328
+
1329
+ data = {
1330
+ proxy: proxy,
1331
+ generate_mode: true
1332
+ };
1333
+
1334
+ // 提交用户填写的所有token字段,后端会根据优先级处理
1335
+ if (masterAccessToken) {
1336
+ data.access_token = masterAccessToken;
1337
+ }
1338
+ if (masterRefreshToken) {
1339
+ data.refresh_token = masterRefreshToken;
1340
+ }
1341
+ }
1342
+
1343
+ if (await addAccount(data)) {
1344
+ e.target.reset();
1345
+ if (currentAddMode === 'credential') {
1346
+ document.getElementById('credential_refresh_token').focus();
1347
+ } else {
1348
+ document.getElementById('generate_refresh_token').focus();
1349
+ }
1350
+ }
1351
+ });
1352
+
1353
+ // Initialization function for after admin login
1354
+ function initializeApp() {
1355
+ loadAccounts();
1356
+
1357
+ // Auto Refresh
1358
+ if (autoRefreshTimer) {
1359
+ clearInterval(autoRefreshTimer);
1360
+ }
1361
+ autoRefreshTimer = setInterval(() => {
1362
+ loadAccounts(true);
1363
+ }, REFRESH_INTERVAL);
1364
+ }
1365
+
1366
+ // --- Token Management ---
1367
+ let currentMainView = 'pool'; // 'pool' or 'token'
1368
+ let tokenRecords = [];
1369
+ let generationTasks = [];
1370
+
1371
+ function switchMainView(view) {
1372
+ currentMainView = view;
1373
+ const poolView = document.getElementById('poolView');
1374
+ const tokenView = document.getElementById('tokenView');
1375
+ const poolBtn = document.getElementById('view-pool');
1376
+ const tokenBtn = document.getElementById('view-token');
1377
+
1378
+ if (view === 'pool') {
1379
+ poolView.classList.remove('hidden');
1380
+ tokenView.classList.add('hidden');
1381
+
1382
+ poolBtn.classList.add('bg-white', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white', 'shadow-sm');
1383
+ poolBtn.classList.remove('text-gray-500', 'hover:text-gray-900', 'dark:text-gray-400', 'dark:hover:text-white');
1384
+ tokenBtn.classList.remove('bg-white', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white', 'shadow-sm');
1385
+ tokenBtn.classList.add('text-gray-500', 'hover:text-gray-900', 'dark:text-gray-400', 'dark:hover:text-white');
1386
+
1387
+ loadAccounts();
1388
+ } else {
1389
+ poolView.classList.add('hidden');
1390
+ tokenView.classList.remove('hidden');
1391
+
1392
+ tokenBtn.classList.add('bg-white', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white', 'shadow-sm');
1393
+ tokenBtn.classList.remove('text-gray-500', 'hover:text-gray-900', 'dark:text-gray-400', 'dark:hover:text-white');
1394
+ poolBtn.classList.remove('bg-white', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white', 'shadow-sm');
1395
+ poolBtn.classList.add('text-gray-500', 'hover:text-gray-900', 'dark:text-gray-400', 'dark:hover:text-white');
1396
+
1397
+ loadTokenData();
1398
+ }
1399
+ }
1400
+
1401
+ async function loadTokenData() {
1402
+ await Promise.all([
1403
+ loadTokenRecords(),
1404
+ loadGenerationTasks(),
1405
+ loadPoolStatus()
1406
+ ]);
1407
+ }
1408
+
1409
+ async function loadTokenRecords() {
1410
+ try {
1411
+ const resp = await fetch(`${API_BASE}/tokens`, {
1412
+ headers: getAuthHeaders()
1413
+ });
1414
+ if (!resp.ok) throw new Error('Failed to fetch token records');
1415
+
1416
+ const data = await resp.json();
1417
+ tokenRecords = data.items || [];
1418
+ renderTokenRecords(tokenRecords);
1419
+ } catch (e) {
1420
+ console.error("Failed to load token records", e);
1421
+ }
1422
+ }
1423
+
1424
+ async function loadGenerationTasks() {
1425
+ try {
1426
+ const resp = await fetch(`${API_BASE}/tokens/tasks`, {
1427
+ headers: getAuthHeaders()
1428
+ });
1429
+ if (!resp.ok) throw new Error('Failed to fetch generation tasks');
1430
+
1431
+ const data = await resp.json();
1432
+ generationTasks = data.items || [];
1433
+ renderGenerationTasks(generationTasks);
1434
+ } catch (e) {
1435
+ console.error("Failed to load generation tasks", e);
1436
+ }
1437
+ }
1438
+
1439
+ async function loadPoolStatus() {
1440
+ try {
1441
+ const resp = await fetch(`${API_BASE}/tokens/pool-status`, {
1442
+ headers: getAuthHeaders()
1443
+ });
1444
+ if (!resp.ok) throw new Error('Failed to fetch pool status');
1445
+
1446
+ const data = await resp.json();
1447
+ updateTokenStatsUI(data);
1448
+ } catch (e) {
1449
+ console.error("Failed to load pool status", e);
1450
+ }
1451
+ }
1452
+
1453
+ function updateTokenStatsUI(stats) {
1454
+ document.getElementById('token-stat-active').textContent = stats.active_tokens || 0;
1455
+ document.getElementById('token-stat-normal').textContent = stats.normal_accounts || 0;
1456
+ document.getElementById('token-stat-running').textContent = stats.running_tasks || 0;
1457
+ }
1458
+
1459
+ function renderTokenRecords(records) {
1460
+ const tbody = document.getElementById('tokenList');
1461
+ const emptyState = document.getElementById('tokenEmptyState');
1462
+
1463
+ if (records.length === 0) {
1464
+ tbody.innerHTML = '';
1465
+ emptyState.classList.remove('hidden');
1466
+ emptyState.classList.add('flex');
1467
+ return;
1468
+ }
1469
+
1470
+ emptyState.classList.add('hidden');
1471
+ emptyState.classList.remove('flex');
1472
+
1473
+ const html = records.map(record => {
1474
+ // 格式化订阅日期
1475
+ const subDate = record.subscription_start_date && !record.subscription_start_date.startsWith('0001')
1476
+ ? new Date(record.subscription_start_date).toLocaleDateString('zh-CN')
1477
+ : '-';
1478
+
1479
+ // 格式化Token过期时间
1480
+ const tokenExpiryDate = record.token_expiry && !record.token_expiry.startsWith('0001')
1481
+ ? new Date(record.token_expiry)
1482
+ : null;
1483
+
1484
+ let tokenStatusClass, tokenStatusText;
1485
+ if (!tokenExpiryDate) {
1486
+ tokenStatusClass = 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400';
1487
+ tokenStatusText = '未知';
1488
+ } else {
1489
+ const now = new Date();
1490
+ const hoursUntilExpiry = (tokenExpiryDate - now) / (1000 * 60 * 60);
1491
+
1492
+ if (hoursUntilExpiry < 0) {
1493
+ tokenStatusClass = 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
1494
+ tokenStatusText = '已过期';
1495
+ } else if (hoursUntilExpiry < 1) {
1496
+ tokenStatusClass = 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400';
1497
+ tokenStatusText = `${Math.floor(hoursUntilExpiry * 60)}分钟后过期`;
1498
+ } else if (hoursUntilExpiry < 24) {
1499
+ tokenStatusClass = 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400';
1500
+ tokenStatusText = `${Math.floor(hoursUntilExpiry)}小时后过期`;
1501
+ } else {
1502
+ tokenStatusClass = 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
1503
+ tokenStatusText = `${Math.floor(hoursUntilExpiry / 24)}天后过期`;
1504
+ }
1505
+ }
1506
+
1507
+ return `
1508
+ <tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
1509
+ <td class="px-6 py-4">
1510
+ <div>
1511
+ <div class="text-sm font-medium text-gray-900 dark:text-white">${record.email || '未知邮箱'}</div>
1512
+ <div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">${record.description || `令牌 #${record.id}`}</div>
1513
+ </div>
1514
+ </td>
1515
+ <td class="px-6 py-4 text-center">
1516
+ <div>
1517
+ <div class="text-sm font-medium ${record.plan_type === 'Starter' ? 'text-blue-600 dark:text-blue-400' : record.plan_type === 'Core' ? 'text-purple-600 dark:text-purple-400' : record.plan_type === 'Advanced' ? 'text-orange-600 dark:text-orange-400' : record.plan_type === 'Max' ? 'text-red-600 dark:text-red-400' : 'text-gray-600 dark:text-gray-400'}">${record.plan_type || 'Free'}</div>
1518
+ </div>
1519
+ </td>
1520
+ <td class="px-6 py-4 text-center">
1521
+ <span class="text-sm text-gray-600 dark:text-gray-400">${subDate}</span>
1522
+ </td>
1523
+ <td class="px-6 py-4 text-center">
1524
+ <div class="space-y-1">
1525
+ <div>
1526
+ <span class="px-2 py-0.5 inline-flex items-center text-xs font-medium rounded-full ${getTokenStatusConfig(record).class}">
1527
+ <span class="w-1.5 h-1.5 rounded-full ${getTokenStatusConfig(record).dot} mr-1.5"></span>
1528
+ ${getTokenStatusConfig(record).text}
1529
+ </span>
1530
+ </div>
1531
+ <div>
1532
+ <span class="px-2 py-0.5 inline-flex text-xs rounded-full ${tokenStatusClass}">
1533
+ ${tokenStatusText}
1534
+ </span>
1535
+ </div>
1536
+ </div>
1537
+ </td>
1538
+ <td class="px-6 py-4 text-center">
1539
+ <div class="text-sm">
1540
+ <span class="text-gray-900 dark:text-white font-medium">${record.generated_count || 0}</span>
1541
+ <span class="text-gray-500">/</span>
1542
+ <span class="text-gray-600 dark:text-gray-400">${record.threshold}</span>
1543
+ <div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">批次: ${record.generate_batch}</div>
1544
+ </div>
1545
+ </td>
1546
+ <td class="px-6 py-4 text-center">
1547
+ <div class="text-sm">
1548
+ <span class="text-green-600 dark:text-green-400">${record.total_success || 0}</span>
1549
+ <span class="text-gray-500">/</span>
1550
+ <span class="text-red-600 dark:text-red-400">${record.total_fail || 0}</span>
1551
+ </div>
1552
+ </td>
1553
+ <td class="px-6 py-4 text-center">
1554
+ <button onclick="quickToggleAutoGenerate(${record.id}, ${!record.auto_generate})"
1555
+ class="${record.auto_generate ? 'text-green-600' : 'text-gray-400'} hover:opacity-70 transition-opacity">
1556
+ ${record.auto_generate
1557
+ ? '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mx-auto" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" /></svg>'
1558
+ : '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mx-auto" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" /></svg>'}
1559
+ </button>
1560
+ </td>
1561
+ <td class="px-6 py-4 text-center">
1562
+ <div class="flex justify-center gap-2">
1563
+ <button onclick='showTokenConfigModal(${JSON.stringify(record).replace(/'/g, "\\'")})'
1564
+ class="text-primary hover:text-primary-hover transition-colors" title="配置">
1565
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1566
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
1567
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
1568
+ </svg>
1569
+ </button>
1570
+ ${record.is_active ? `
1571
+ <button onclick="triggerGeneration(${record.id})"
1572
+ class="text-blue-600 hover:text-blue-700 transition-colors" title="手动触发生成">
1573
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1574
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
1575
+ </svg>
1576
+ </button>` : ''}
1577
+ ${record.has_refresh_token ? `
1578
+ <button onclick="refreshToken(${record.id})"
1579
+ class="text-green-600 hover:text-green-700 transition-colors" title="刷新令牌">
1580
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1581
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
1582
+ </svg>
1583
+ </button>` : ''}
1584
+ <button onclick="deleteTokenRecord(${record.id})"
1585
+ class="text-red-500 hover:text-red-700 transition-colors" title="删除">
1586
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
1587
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
1588
+ </svg>
1589
+ </button>
1590
+ </div>
1591
+ </td>
1592
+ </tr>`;
1593
+ }).join('');
1594
+
1595
+ tbody.innerHTML = html;
1596
+ }
1597
+
1598
+ // 添加刷新令牌函数
1599
+ async function refreshToken(tokenId) {
1600
+ try {
1601
+ const resp = await fetch(`${API_BASE}/tokens/${tokenId}/refresh`, {
1602
+ method: 'POST',
1603
+ headers: getAuthHeaders()
1604
+ });
1605
+
1606
+ if (!resp.ok) {
1607
+ const error = await resp.json();
1608
+ throw new Error(error.error || 'Failed to refresh token');
1609
+ }
1610
+
1611
+ const result = await resp.json();
1612
+ showToast(result.message || 'Token刷新成功', 'success');
1613
+ loadTokenRecords();
1614
+ } catch (e) {
1615
+ showToast('Token刷新失败: ' + e.message, 'error');
1616
+ }
1617
+ }
1618
+
1619
+ function renderGenerationTasks(tasks) {
1620
+ const tbody = document.getElementById('taskList');
1621
+
1622
+ if (tasks.length === 0) {
1623
+ tbody.innerHTML = '<tr><td colspan="6" class="px-6 py-8 text-center text-sm text-gray-500 dark:text-gray-400">暂无生成任务记录</td></tr>';
1624
+ return;
1625
+ }
1626
+
1627
+ const html = tasks.map(task => {
1628
+ const startTime = task.started_at && !task.started_at.startsWith('0001')
1629
+ ? new Date(task.started_at).toLocaleString('zh-CN')
1630
+ : '-';
1631
+ const completeTime = task.completed_at && !task.completed_at.startsWith('0001')
1632
+ ? new Date(task.completed_at).toLocaleString('zh-CN')
1633
+ : '-';
1634
+
1635
+ let statusClass, statusText;
1636
+ switch (task.status) {
1637
+ case 'running':
1638
+ statusClass = 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
1639
+ statusText = '运行中';
1640
+ break;
1641
+ case 'completed':
1642
+ statusClass = 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
1643
+ statusText = '已完成';
1644
+ break;
1645
+ case 'failed':
1646
+ statusClass = 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
1647
+ statusText = '失败';
1648
+ break;
1649
+ default:
1650
+ statusClass = 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400';
1651
+ statusText = '待处理';
1652
+ }
1653
+
1654
+ return `
1655
+ <tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
1656
+ <td class="px-6 py-3 text-sm text-gray-900 dark:text-white">#${task.id}</td>
1657
+ <td class="px-6 py-3 text-sm text-center text-gray-900 dark:text-white">${task.batch_size}</td>
1658
+ <td class="px-6 py-3 text-sm text-center">
1659
+ <span class="text-green-600 dark:text-green-400">${task.success_count}</span>
1660
+ <span class="text-gray-500">/</span>
1661
+ <span class="text-red-600 dark:text-red-400">${task.fail_count}</span>
1662
+ </td>
1663
+ <td class="px-6 py-3 text-center">
1664
+ <span class="px-2 py-0.5 inline-flex text-xs font-medium rounded-full ${statusClass}">
1665
+ ${statusText}
1666
+ </span>
1667
+ </td>
1668
+ <td class="px-6 py-3 text-sm text-center text-gray-600 dark:text-gray-400">${startTime}</td>
1669
+ <td class="px-6 py-3 text-sm text-center text-gray-600 dark:text-gray-400">${completeTime}</td>
1670
+ </tr>`;
1671
+ }).join('');
1672
+
1673
+ tbody.innerHTML = html;
1674
+ }
1675
+
1676
+ async function quickToggleAutoGenerate(tokenId, enable) {
1677
+ try {
1678
+ const resp = await fetch(`${API_BASE}/tokens/${tokenId}`, {
1679
+ method: 'PUT',
1680
+ headers: {
1681
+ 'Content-Type': 'application/json',
1682
+ ...getAuthHeaders()
1683
+ },
1684
+ body: JSON.stringify({ auto_generate: enable })
1685
+ });
1686
+
1687
+ if (!resp.ok) throw new Error('Failed to update token');
1688
+ loadTokenRecords();
1689
+ } catch (e) {
1690
+ alert('更新失败: ' + e.message);
1691
+ }
1692
+ }
1693
+
1694
+ async function quickToggleActive(tokenId, enable) {
1695
+ try {
1696
+ const resp = await fetch(`${API_BASE}/tokens/${tokenId}`, {
1697
+ method: 'PUT',
1698
+ headers: {
1699
+ 'Content-Type': 'application/json',
1700
+ ...getAuthHeaders()
1701
+ },
1702
+ body: JSON.stringify({ is_active: enable })
1703
+ });
1704
+
1705
+ if (!resp.ok) throw new Error('Failed to update token');
1706
+ loadTokenRecords();
1707
+ } catch (e) {
1708
+ alert('更新失败: ' + e.message);
1709
+ }
1710
+ }
1711
+
1712
+ async function deleteTokenRecord(tokenId) {
1713
+ if (!confirm('确定要删除此令牌记录吗?')) return;
1714
+
1715
+ try {
1716
+ const resp = await fetch(`${API_BASE}/tokens/${tokenId}`, {
1717
+ method: 'DELETE',
1718
+ headers: getAuthHeaders()
1719
+ });
1720
+
1721
+ if (!resp.ok) throw new Error('Failed to delete token');
1722
+
1723
+ const result = await resp.json();
1724
+ alert(result.message || '删除成功');
1725
+ loadTokenRecords();
1726
+ } catch (e) {
1727
+ alert('删除失败: ' + e.message);
1728
+ }
1729
+ }
1730
+
1731
+ async function triggerGeneration(tokenId) {
1732
+ if (!confirm('确定要手动触发生成任务吗?')) return;
1733
+
1734
+ try {
1735
+ const resp = await fetch(`${API_BASE}/tokens/${tokenId}/trigger`, {
1736
+ method: 'POST',
1737
+ headers: getAuthHeaders()
1738
+ });
1739
+
1740
+ if (!resp.ok) throw new Error('Failed to trigger generation');
1741
+
1742
+ alert('生成任务已触发,请稍后查看结果');
1743
+ setTimeout(() => {
1744
+ loadTokenData();
1745
+ }, 2000);
1746
+ } catch (e) {
1747
+ alert('触发失败: ' + e.message);
1748
+ }
1749
+ }
1750
+
1751
+ let currentEditingToken = null;
1752
+
1753
+ function showTokenConfigModal(record) {
1754
+ currentEditingToken = record;
1755
+ document.getElementById('tokenConfigModal').classList.remove('hidden');
1756
+
1757
+ // 填充表单数据
1758
+ document.getElementById('configTokenId').value = record.id;
1759
+ document.getElementById('configDescription').value = record.description || '';
1760
+ document.getElementById('configThreshold').value = record.threshold || 10;
1761
+ document.getElementById('configBatch').value = record.generate_batch || 30;
1762
+
1763
+ // 设置开关状态
1764
+ setConfigSwitch('configAutoGenerate', record.auto_generate);
1765
+ setConfigSwitch('configIsActive', record.is_active);
1766
+ }
1767
+
1768
+ function closeTokenConfigModal() {
1769
+ document.getElementById('tokenConfigModal').classList.add('hidden');
1770
+ currentEditingToken = null;
1771
+ }
1772
+
1773
+ function setConfigSwitch(switchId, isOn) {
1774
+ const switchBtn = document.getElementById(switchId);
1775
+ const indicator = switchBtn.querySelector('span');
1776
+
1777
+ if (isOn) {
1778
+ switchBtn.classList.remove('bg-gray-200', 'dark:bg-gray-600');
1779
+ switchBtn.classList.add('bg-primary');
1780
+ indicator.classList.remove('translate-x-1');
1781
+ indicator.classList.add('translate-x-6');
1782
+ switchBtn.dataset.checked = 'true';
1783
+ } else {
1784
+ switchBtn.classList.add('bg-gray-200', 'dark:bg-gray-600');
1785
+ switchBtn.classList.remove('bg-primary');
1786
+ indicator.classList.add('translate-x-1');
1787
+ indicator.classList.remove('translate-x-6');
1788
+ switchBtn.dataset.checked = 'false';
1789
+ }
1790
+ }
1791
+
1792
+ function toggleConfigSwitch(switchId) {
1793
+ const switchBtn = document.getElementById(switchId);
1794
+ const isChecked = switchBtn.dataset.checked === 'true';
1795
+ setConfigSwitch(switchId, !isChecked);
1796
+ }
1797
+
1798
+ // Token config form handler
1799
+ document.getElementById('tokenConfigForm').addEventListener('submit', async (e) => {
1800
+ e.preventDefault();
1801
+
1802
+ const tokenId = document.getElementById('configTokenId').value;
1803
+ const config = {
1804
+ description: document.getElementById('configDescription').value,
1805
+ threshold: parseInt(document.getElementById('configThreshold').value),
1806
+ generate_batch: parseInt(document.getElementById('configBatch').value),
1807
+ auto_generate: document.getElementById('configAutoGenerate').dataset.checked === 'true',
1808
+ is_active: document.getElementById('configIsActive').dataset.checked === 'true'
1809
+ };
1810
+
1811
+ try {
1812
+ const resp = await fetch(`${API_BASE}/tokens/${tokenId}`, {
1813
+ method: 'PUT',
1814
+ headers: {
1815
+ 'Content-Type': 'application/json',
1816
+ ...getAuthHeaders()
1817
+ },
1818
+ body: JSON.stringify(config)
1819
+ });
1820
+
1821
+ if (!resp.ok) throw new Error('Failed to update token config');
1822
+
1823
+ closeTokenConfigModal();
1824
+ loadTokenRecords();
1825
+ } catch (e) {
1826
+ alert('更新配置失败: ' + e.message);
1827
+ }
1828
+ });
1829
+
1830
+ async function refreshTokenData() {
1831
+ await loadTokenData();
1832
+ }
1833
+
1834
+ // OAuth RT获取功能
1835
+ function startOAuthForRT() {
1836
+ // 打开新窗口进行OAuth认证
1837
+ const width = 600;
1838
+ const height = 700;
1839
+ const left = (window.screen.width - width) / 2;
1840
+ const top = (window.screen.height - height) / 2;
1841
+
1842
+ const authWindow = window.open(
1843
+ '/api/oauth/start-rt',
1844
+ 'ZenCoderOAuth',
1845
+ `width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,scrollbars=yes,resizable=yes`
1846
+ );
1847
+
1848
+ // 监听OAuth完成消息
1849
+ window.addEventListener('message', function handleOAuthMessage(event) {
1850
+ // 验证消息来源
1851
+ if (event.origin !== window.location.origin) return;
1852
+
1853
+ if (event.data.type === 'oauth-rt-complete') {
1854
+ // 关闭认证窗口
1855
+ if (authWindow && !authWindow.closed) {
1856
+ authWindow.close();
1857
+ }
1858
+
1859
+ // 移除事件监听器
1860
+ window.removeEventListener('message', handleOAuthMessage);
1861
+
1862
+ if (event.data.success && (event.data.accessToken || event.data.refreshToken)) {
1863
+ // 自动填充到对应的输入框
1864
+ const currentMode = currentAddMode;
1865
+ if (currentMode === 'credential') {
1866
+ // 优先填充access_token
1867
+ if (event.data.accessToken) {
1868
+ document.getElementById('credential_access_token').value = event.data.accessToken;
1869
+ }
1870
+ // 同时填充refresh_token
1871
+ if (event.data.refreshToken) {
1872
+ document.getElementById('credential_refresh_token').value = event.data.refreshToken;
1873
+ }
1874
+ // 显示成功提示
1875
+ showToast('Token 获取成功!', 'success');
1876
+ } else {
1877
+ // 生成模式
1878
+ if (event.data.accessToken) {
1879
+ document.getElementById('generate_access_token').value = event.data.accessToken;
1880
+ }
1881
+ if (event.data.refreshToken) {
1882
+ document.getElementById('generate_refresh_token').value = event.data.refreshToken;
1883
+ }
1884
+ showToast('Master Token 获取成功!', 'success');
1885
+ }
1886
+ } else {
1887
+ showToast(event.data.error || 'OAuth认证失败', 'error');
1888
+ }
1889
+ }
1890
+ });
1891
+ }
1892
+
1893
+ // Toast提示功能
1894
+ function showToast(message, type = 'info') {
1895
+ // 创建toast元素
1896
+ const toast = document.createElement('div');
1897
+ toast.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg transition-all duration-300 transform translate-x-96 z-50`;
1898
+
1899
+ // 根据类型设置样式
1900
+ const styles = {
1901
+ success: 'bg-green-600 text-white',
1902
+ error: 'bg-red-600 text-white',
1903
+ warning: 'bg-yellow-500 text-white',
1904
+ info: 'bg-blue-600 text-white'
1905
+ };
1906
+
1907
+ toast.className += ` ${styles[type] || styles.info}`;
1908
+ toast.innerHTML = `
1909
+ <div class="flex items-center gap-2">
1910
+ <span class="text-sm font-medium">${message}</span>
1911
+ </div>
1912
+ `;
1913
+
1914
+ document.body.appendChild(toast);
1915
+
1916
+ // 动画显示
1917
+ setTimeout(() => {
1918
+ toast.classList.remove('translate-x-96');
1919
+ toast.classList.add('translate-x-0');
1920
+ }, 10);
1921
+
1922
+ // 3秒后自动消失
1923
+ setTimeout(() => {
1924
+ toast.classList.remove('translate-x-0');
1925
+ toast.classList.add('translate-x-96');
1926
+ setTimeout(() => {
1927
+ document.body.removeChild(toast);
1928
+ }, 300);
1929
+ }, 3000);
1930
+ }
1931
+
1932
+ // --- Account Info Modal Management ---
1933
+ function showAccountInfoModal(accountInfo) {
1934
+ document.getElementById('accountInfoModal').classList.remove('hidden');
1935
+
1936
+ // 填充账号信息
1937
+ document.getElementById('accountInfoEmail').textContent = accountInfo.email || '未知';
1938
+ document.getElementById('accountInfoPlan').textContent = accountInfo.plan || 'Free';
1939
+
1940
+ // 格式化Token过期时间
1941
+ let tokenExpiryText = '未知';
1942
+ if (accountInfo.token_expiry && !accountInfo.token_expiry.startsWith('0001')) {
1943
+ const expiryDate = new Date(accountInfo.token_expiry);
1944
+ const now = new Date();
1945
+ const diffMs = expiryDate - now;
1946
+
1947
+ if (diffMs < 0) {
1948
+ tokenExpiryText = '已过期';
1949
+ } else if (diffMs < 1000 * 60 * 60 * 24) {
1950
+ // 24小时内过期
1951
+ const hours = Math.floor(diffMs / (1000 * 60 * 60));
1952
+ tokenExpiryText = `${hours}小时后过期`;
1953
+ } else {
1954
+ // 显示具体日期
1955
+ tokenExpiryText = expiryDate.toLocaleDateString('zh-CN') + ' ' + expiryDate.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'});
1956
+ }
1957
+ }
1958
+ document.getElementById('accountInfoTokenExpiry').textContent = tokenExpiryText;
1959
+
1960
+ // 格式化订阅开始时间
1961
+ let subscriptionStartText = '未知';
1962
+ if (accountInfo.subscription_start_date && !accountInfo.subscription_start_date.startsWith('0001')) {
1963
+ const startDate = new Date(accountInfo.subscription_start_date);
1964
+ subscriptionStartText = startDate.toLocaleDateString('zh-CN') + ' ' + startDate.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'});
1965
+ }
1966
+ document.getElementById('accountInfoSubscriptionStart').textContent = subscriptionStartText;
1967
+ }
1968
+
1969
+ function closeAccountInfoModal() {
1970
+ document.getElementById('accountInfoModal').classList.add('hidden');
1971
+ // 刷新账号列表
1972
+ loadAccounts();
1973
+ }
1974
+
1975
+ // Page Initialization
1976
+ window.addEventListener('load', function() {
1977
+ console.log('Page loaded, initializing...');
1978
+ initTheme();
1979
+ initAdminAuth();
1980
+ });