fogact 1.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +244 -0
- package/README.zh-CN.md +244 -0
- package/bin/cli.js +9 -0
- package/bin/web-server.js +1434 -0
- package/config/upstream.example.json +14 -0
- package/frontend/activate.html +249 -0
- package/frontend/admin/admin-panel-v2.js +1899 -0
- package/frontend/admin/index.html +705 -0
- package/frontend/assets/market-ui.css +1876 -0
- package/frontend/color-test.html +136 -0
- package/frontend/index.html +191 -0
- package/frontend/user/assets/AnnouncementDetail-Dvxmwz0A.js +12 -0
- package/frontend/user/assets/Announcements-CS1tF2mx.js +11 -0
- package/frontend/user/assets/CardBind-CsCxihhP.js +21 -0
- package/frontend/user/assets/CardContent.vue_vue_type_script_setup_true_lang-D2L-uqSl.js +1 -0
- package/frontend/user/assets/CardDescription.vue_vue_type_script_setup_true_lang-D-v5Pl7F.js +1 -0
- package/frontend/user/assets/CardTitle.vue_vue_type_script_setup_true_lang-a0CCN6D5.js +1 -0
- package/frontend/user/assets/Dashboard-rPsmltm5.js +51 -0
- package/frontend/user/assets/DashboardLayout-BUCWGlXC.css +1 -0
- package/frontend/user/assets/DashboardLayout-DDkxHYFj.js +80 -0
- package/frontend/user/assets/Input.vue_vue_type_script_setup_true_lang-B0SyPmYb.js +6 -0
- package/frontend/user/assets/Label.vue_vue_type_script_setup_true_lang-CxYORSgN.js +1 -0
- package/frontend/user/assets/Progress.vue_vue_type_script_setup_true_lang-2_QbPsEQ.js +1 -0
- package/frontend/user/assets/QuotaPack-B_tJ7Psm.js +6 -0
- package/frontend/user/assets/Renewal-BSDhDmwv.js +6 -0
- package/frontend/user/assets/ScrollArea.vue_vue_type_script_setup_true_lang-DMYwcfpz.js +1 -0
- package/frontend/user/assets/Separator.vue_vue_type_script_setup_true_lang-Ckg8EXj_.js +1 -0
- package/frontend/user/assets/Settings-CBdAa3lw.js +11 -0
- package/frontend/user/assets/TooltipTrigger.vue_vue_type_script_setup_true_lang-DtSBjzGo.js +16 -0
- package/frontend/user/assets/Welcome-7IfzEli4.css +1 -0
- package/frontend/user/assets/Welcome-Dtfp6oER.js +1 -0
- package/frontend/user/assets/_plugin-vue_export-helper-5cjT4u0R.js +16 -0
- package/frontend/user/assets/activity-wYWtyqTJ.js +6 -0
- package/frontend/user/assets/announcement-35mOnjRL.js +16 -0
- package/frontend/user/assets/calendar-BFNuCata.js +6 -0
- package/frontend/user/assets/chart-vendor-CULJE59K.js +37 -0
- package/frontend/user/assets/chevron-down-kDbuU1Py.js +6 -0
- package/frontend/user/assets/chevron-right-BayASIm0.js +6 -0
- package/frontend/user/assets/eye-CY62vip0.js +6 -0
- package/frontend/user/assets/gauge-C5NQ-mV8.js +6 -0
- package/frontend/user/assets/index-B8QSyYhS.css +1 -0
- package/frontend/user/assets/index-Da98HOxL.js +91 -0
- package/frontend/user/assets/link-2-DT5R5nGO.js +6 -0
- package/frontend/user/assets/package-rUbExUEn.js +6 -0
- package/frontend/user/assets/plus-CQc6C8wG.js +11 -0
- package/frontend/user/assets/refresh-cw-Y9hCloPL.js +6 -0
- package/frontend/user/assets/useUserPageRefresh-BYZvpNR9.js +1 -0
- package/frontend/user/assets/zap-l5zbZqrM.js +11 -0
- package/frontend/user/index.html +67 -0
- package/install.sh +402 -0
- package/lib/commands/activate.js +144 -0
- package/lib/commands/restore.js +102 -0
- package/lib/commands/test.js +40 -0
- package/lib/config/claude.js +81 -0
- package/lib/config/codex.js +164 -0
- package/lib/config/upstream.js +79 -0
- package/lib/index.js +164 -0
- package/lib/platforms/claude-code.js +35 -0
- package/lib/platforms/codex-cli.js +35 -0
- package/lib/platforms/editor-codex.js +138 -0
- package/lib/platforms/index.js +32 -0
- package/lib/platforms/openclaw.js +118 -0
- package/lib/platforms/opencode.js +89 -0
- package/lib/services/activation-orchestrator.js +666 -0
- package/lib/services/backup-service.js +162 -0
- package/lib/services/cliproxy-api.js +174 -0
- package/lib/services/database.js +461 -0
- package/lib/services/newapi.js +97 -0
- package/lib/services/node-service.js +49 -0
- package/lib/utils/json-file.js +33 -0
- package/package.json +53 -0
|
@@ -0,0 +1,1899 @@
|
|
|
1
|
+
const AppState = {
|
|
2
|
+
currentTab: 'dashboard',
|
|
3
|
+
currentPanel: 'dashboard',
|
|
4
|
+
currentService: 'all',
|
|
5
|
+
users: [],
|
|
6
|
+
codes: [],
|
|
7
|
+
stats: {},
|
|
8
|
+
settings: null,
|
|
9
|
+
filters: {
|
|
10
|
+
users: { status: '', search: '' },
|
|
11
|
+
codes: { status: '', search: '' }
|
|
12
|
+
},
|
|
13
|
+
bindings: {
|
|
14
|
+
users: false,
|
|
15
|
+
codes: false,
|
|
16
|
+
settings: false,
|
|
17
|
+
shell: false
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const API = {
|
|
22
|
+
async request(url, options = {}) {
|
|
23
|
+
const response = await fetch(url, {
|
|
24
|
+
credentials: 'include',
|
|
25
|
+
headers: {
|
|
26
|
+
'Content-Type': 'application/json',
|
|
27
|
+
...(options.headers || {})
|
|
28
|
+
},
|
|
29
|
+
...options
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const data = await response.json().catch(() => ({ success: false, message: '响应解析失败' }));
|
|
33
|
+
if (response.status === 401) {
|
|
34
|
+
throw new Error(data.message || '未授权');
|
|
35
|
+
}
|
|
36
|
+
return data;
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
checkAuth() {
|
|
40
|
+
return this.request('/api/check-auth', { method: 'GET' });
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
login(password) {
|
|
44
|
+
return this.request('/api/login', {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
body: JSON.stringify({ password })
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
logout() {
|
|
51
|
+
return this.request('/api/logout', { method: 'POST' });
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
getStats(params = {}) {
|
|
55
|
+
const query = new URLSearchParams();
|
|
56
|
+
if (params.service) query.set('service', params.service);
|
|
57
|
+
const suffix = query.toString() ? `?${query.toString()}` : '';
|
|
58
|
+
return this.request(`/api/stats${suffix}`, { method: 'GET' });
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
getUsers(params = {}) {
|
|
62
|
+
const query = new URLSearchParams();
|
|
63
|
+
if (params.status) query.set('status', params.status);
|
|
64
|
+
if (params.search) query.set('search', params.search);
|
|
65
|
+
if (params.service) query.set('service', params.service);
|
|
66
|
+
return this.request(`/api/users?${query.toString()}`, { method: 'GET' });
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
createUser(userData) {
|
|
70
|
+
return this.request('/api/users', {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
body: JSON.stringify(userData)
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
updateUser(id, userData) {
|
|
77
|
+
return this.request(`/api/users/${id}`, {
|
|
78
|
+
method: 'PUT',
|
|
79
|
+
body: JSON.stringify(userData)
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
deleteUser(id) {
|
|
84
|
+
return this.request(`/api/users/${id}`, { method: 'DELETE' });
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
getCodes(params = {}) {
|
|
88
|
+
const query = new URLSearchParams();
|
|
89
|
+
if (params.status) query.set('status', params.status);
|
|
90
|
+
if (params.search) query.set('search', params.search);
|
|
91
|
+
if (params.service) query.set('service', params.service);
|
|
92
|
+
return this.request(`/api/codes?${query.toString()}`, { method: 'GET' });
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
createCode(codeData) {
|
|
96
|
+
return this.request('/api/codes', {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
body: JSON.stringify(codeData)
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
updateCode(id, codeData) {
|
|
103
|
+
return this.request(`/api/codes/${id}`, {
|
|
104
|
+
method: 'PUT',
|
|
105
|
+
body: JSON.stringify(codeData)
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
deleteCode(id) {
|
|
110
|
+
return this.request(`/api/codes/${id}`, { method: 'DELETE' });
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
getActivity() {
|
|
114
|
+
return this.request('/api/activity', { method: 'GET' });
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
getSettings() {
|
|
118
|
+
return this.request('/api/settings', { method: 'GET' });
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
updateSettings(settings) {
|
|
122
|
+
return this.request('/api/settings', {
|
|
123
|
+
method: 'PUT',
|
|
124
|
+
body: JSON.stringify(settings)
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
testUpstream(upstream) {
|
|
129
|
+
return this.request('/api/settings/upstream/test', {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
body: JSON.stringify({ upstream })
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
function debounce(fn, wait = 250) {
|
|
137
|
+
let timer = null;
|
|
138
|
+
return (...args) => {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
timer = setTimeout(() => fn(...args), wait);
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function escapeHtml(value) {
|
|
145
|
+
return String(value ?? '')
|
|
146
|
+
.replace(/&/g, '&')
|
|
147
|
+
.replace(/</g, '<')
|
|
148
|
+
.replace(/>/g, '>')
|
|
149
|
+
.replace(/"/g, '"')
|
|
150
|
+
.replace(/'/g, ''');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function formatDate(value, withTime = false) {
|
|
154
|
+
if (!value) return '-';
|
|
155
|
+
const date = new Date(value);
|
|
156
|
+
if (Number.isNaN(date.getTime())) return '-';
|
|
157
|
+
return withTime ? date.toLocaleString('zh-CN') : date.toLocaleDateString('zh-CN');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function getUserStatusMeta(user) {
|
|
161
|
+
const key = user.statusKey || 'inactive';
|
|
162
|
+
const meta = {
|
|
163
|
+
active: { label: user.statusLabel || '活跃', className: 'bg-green-50 text-green-700' },
|
|
164
|
+
inactive: { label: user.statusLabel || '待激活', className: 'bg-amber-50 text-amber-700' },
|
|
165
|
+
disabled: { label: user.statusLabel || '已禁用', className: 'bg-error-container text-on-error-container' }
|
|
166
|
+
};
|
|
167
|
+
return meta[key] || meta.inactive;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function getCodeStatusMeta(code) {
|
|
171
|
+
const key = code.status || 'unused';
|
|
172
|
+
const meta = {
|
|
173
|
+
unused: { label: code.statusLabel || '未使用', className: 'bg-green-50 text-green-700' },
|
|
174
|
+
used: { label: code.statusLabel || '已使用', className: 'bg-surface-container text-on-surface-variant' },
|
|
175
|
+
expired: { label: code.statusLabel || '已过期', className: 'bg-error-container text-on-error-container' }
|
|
176
|
+
};
|
|
177
|
+
return meta[key] || meta.unused;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function clone(value) {
|
|
181
|
+
return JSON.parse(JSON.stringify(value));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const SERVICE_OPTIONS = [
|
|
185
|
+
{ key: 'codex', label: 'Codex', navLabel: 'Codex', icon: 'terminal' },
|
|
186
|
+
{ key: 'claude', label: 'Claude Code', navLabel: 'ClaudeCode', icon: 'code_blocks' }
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
const ROUTES = {
|
|
190
|
+
dashboard: { key: 'dashboard', panel: 'dashboard', title: '概览', service: 'all', section: '仪表盘' },
|
|
191
|
+
users: { key: 'users', panel: 'users', title: '用户管理', service: 'all', section: '仪表盘' },
|
|
192
|
+
codes: { key: 'codes', panel: 'codes', title: '激活码管理', service: 'all', section: '仪表盘' },
|
|
193
|
+
'codex-users': { key: 'codex-users', panel: 'users', title: 'Codex 用户管理', service: 'codex', section: 'Codex' },
|
|
194
|
+
'codex-codes': { key: 'codex-codes', panel: 'codes', title: 'Codex 激活码创建', service: 'codex', section: 'Codex' },
|
|
195
|
+
'claude-users': { key: 'claude-users', panel: 'users', title: 'ClaudeCode 用户管理', service: 'claude', section: 'ClaudeCode' },
|
|
196
|
+
'claude-codes': { key: 'claude-codes', panel: 'codes', title: 'ClaudeCode 激活码创建', service: 'claude', section: 'ClaudeCode' },
|
|
197
|
+
logs: { key: 'logs', panel: 'logs', title: '系统日志', service: 'all', section: '系统' },
|
|
198
|
+
settings: { key: 'settings', panel: 'settings', title: '系统设置', service: 'all', section: '系统' }
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
function getRoute(tabName) {
|
|
202
|
+
return ROUTES[tabName] || ROUTES.dashboard;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function getActiveService() {
|
|
206
|
+
return AppState.currentService && AppState.currentService !== 'all' ? AppState.currentService : '';
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function getServiceOption(keyOrLabel) {
|
|
210
|
+
const value = String(keyOrLabel || '').toLowerCase();
|
|
211
|
+
if (value.includes('codex')) return SERVICE_OPTIONS.find((item) => item.key === 'codex');
|
|
212
|
+
if (value.includes('claude')) return SERVICE_OPTIONS.find((item) => item.key === 'claude');
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function getActiveServiceOption() {
|
|
217
|
+
return getServiceOption(getActiveService());
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function getServiceOptionsForScope(currentValue = '') {
|
|
221
|
+
const active = getActiveServiceOption();
|
|
222
|
+
const selected = getServiceOption(currentValue);
|
|
223
|
+
if (active) return [active];
|
|
224
|
+
if (selected && !SERVICE_OPTIONS.some((item) => item.key === selected.key)) {
|
|
225
|
+
return [...SERVICE_OPTIONS, selected];
|
|
226
|
+
}
|
|
227
|
+
return SERVICE_OPTIONS;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const DEFAULT_SETTINGS = {
|
|
231
|
+
site: {
|
|
232
|
+
siteName: 'FogIDC Activator',
|
|
233
|
+
siteDescription: '统一管理用户、激活码与订阅配置。',
|
|
234
|
+
siteUrl: 'https://example.com',
|
|
235
|
+
logoUrl: '',
|
|
236
|
+
forceHttps: true,
|
|
237
|
+
stopRegister: false
|
|
238
|
+
},
|
|
239
|
+
security: {
|
|
240
|
+
sessionTimeout: 24,
|
|
241
|
+
loginNotice: '请妥善保管管理员密码。',
|
|
242
|
+
operationConfirm: true,
|
|
243
|
+
ipWhitelist: '',
|
|
244
|
+
auditMode: true
|
|
245
|
+
},
|
|
246
|
+
subscription: {
|
|
247
|
+
subscribeUrl: 'https://example.com/sub',
|
|
248
|
+
tokenLength: 32,
|
|
249
|
+
defaultQuota: 1000000,
|
|
250
|
+
defaultDuration: 30,
|
|
251
|
+
autoDisableExpired: true
|
|
252
|
+
},
|
|
253
|
+
invite: {
|
|
254
|
+
inviteEnabled: true,
|
|
255
|
+
inviteReward: 10,
|
|
256
|
+
commissionRate: 15,
|
|
257
|
+
settleCycle: 'monthly'
|
|
258
|
+
},
|
|
259
|
+
email: {
|
|
260
|
+
smtpHost: '',
|
|
261
|
+
smtpPort: 587,
|
|
262
|
+
senderName: 'FogIDC Activator',
|
|
263
|
+
senderEmail: '',
|
|
264
|
+
enableTls: true
|
|
265
|
+
},
|
|
266
|
+
telegram: {
|
|
267
|
+
botToken: '',
|
|
268
|
+
chatId: '',
|
|
269
|
+
notifyNewUser: true,
|
|
270
|
+
notifyLowQuota: true
|
|
271
|
+
},
|
|
272
|
+
app: {
|
|
273
|
+
appName: 'CLIProxy Client',
|
|
274
|
+
appDownloadUrl: '',
|
|
275
|
+
iosUrl: '',
|
|
276
|
+
androidUrl: '',
|
|
277
|
+
latestVersion: '1.0.0'
|
|
278
|
+
},
|
|
279
|
+
upstream: {
|
|
280
|
+
provider: 'newapi',
|
|
281
|
+
baseUrl: '',
|
|
282
|
+
apiKey: '',
|
|
283
|
+
apiKeyMasked: '',
|
|
284
|
+
apiKeyConfigured: false,
|
|
285
|
+
claudeBaseUrl: '',
|
|
286
|
+
codexBaseUrl: '',
|
|
287
|
+
timeoutMs: 10000,
|
|
288
|
+
configPath: '',
|
|
289
|
+
configured: false
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const UI = {
|
|
294
|
+
initSidebar() {
|
|
295
|
+
document.querySelectorAll('.nav-item').forEach((item) => {
|
|
296
|
+
item.addEventListener('click', (event) => {
|
|
297
|
+
event.preventDefault();
|
|
298
|
+
this.switchTab(item.dataset.tab);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
this.updateNavActive(AppState.currentTab);
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
updateNavActive(tab) {
|
|
305
|
+
document.querySelectorAll('.nav-item').forEach((item) => {
|
|
306
|
+
const active = item.dataset.tab === tab;
|
|
307
|
+
item.classList.toggle('text-primary', active);
|
|
308
|
+
item.classList.toggle('font-bold', active);
|
|
309
|
+
item.classList.toggle('bg-surface-container-lowest', active);
|
|
310
|
+
item.classList.toggle('shadow-sm', active);
|
|
311
|
+
item.classList.toggle('text-on-surface-variant', !active);
|
|
312
|
+
});
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
switchTab(tabName) {
|
|
316
|
+
const route = getRoute(tabName);
|
|
317
|
+
AppState.currentTab = route.key;
|
|
318
|
+
AppState.currentPanel = route.panel;
|
|
319
|
+
AppState.currentService = route.service || 'all';
|
|
320
|
+
|
|
321
|
+
this.updateNavActive(route.key);
|
|
322
|
+
document.getElementById('page-title').textContent = route.title;
|
|
323
|
+
document.querySelectorAll('.tab-panel').forEach((panel) => panel.classList.add('hidden'));
|
|
324
|
+
document.getElementById(`${route.panel}-panel`)?.classList.remove('hidden');
|
|
325
|
+
this.updatePanelContext(route);
|
|
326
|
+
window.location.hash = route.key;
|
|
327
|
+
return this.loadTabData(route.panel);
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
updatePanelContext(route) {
|
|
331
|
+
const service = getActiveServiceOption();
|
|
332
|
+
const scopeText = service ? service.navLabel : '全局';
|
|
333
|
+
const usersTitle = document.getElementById('users-panel-title');
|
|
334
|
+
const usersSubtitle = document.getElementById('users-panel-subtitle');
|
|
335
|
+
const codesTitle = document.getElementById('codes-panel-title');
|
|
336
|
+
const codesSubtitle = document.getElementById('codes-panel-subtitle');
|
|
337
|
+
const addUserBtn = document.getElementById('add-user-btn-label');
|
|
338
|
+
const addCodeBtn = document.getElementById('add-code-btn-label');
|
|
339
|
+
|
|
340
|
+
if (usersTitle) usersTitle.textContent = service ? `${service.navLabel} 用户列表` : '全局用户列表';
|
|
341
|
+
if (usersSubtitle) usersSubtitle.textContent = service ? `只显示并创建 ${service.label} 用户` : '汇总所有平台用户';
|
|
342
|
+
if (codesTitle) codesTitle.textContent = service ? `${service.navLabel} 激活码` : '全局激活码列表';
|
|
343
|
+
if (codesSubtitle) codesSubtitle.textContent = service ? `此处生成的 CDK 只能用于 ${service.label} 模型` : '查看所有平台 CDK';
|
|
344
|
+
if (addUserBtn) addUserBtn.textContent = service ? `添加 ${scopeText} 用户` : '添加用户';
|
|
345
|
+
if (addCodeBtn) addCodeBtn.textContent = service ? `生成 ${scopeText} CDK` : '生成激活码';
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
async loadTabData(panelName) {
|
|
349
|
+
if (panelName === 'dashboard') return Dashboard.render();
|
|
350
|
+
if (panelName === 'users') return UserManagement.render();
|
|
351
|
+
if (panelName === 'codes') return CodeManagement.render();
|
|
352
|
+
if (panelName === 'logs') return LogsManagement.render();
|
|
353
|
+
if (panelName === 'settings') return SettingsManagement.render();
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
initThemeToggle() {
|
|
357
|
+
const button = document.getElementById('theme-toggle');
|
|
358
|
+
const html = document.documentElement;
|
|
359
|
+
const savedTheme = localStorage.getItem('admin_theme') || 'light';
|
|
360
|
+
|
|
361
|
+
if (savedTheme === 'dark') {
|
|
362
|
+
html.classList.add('dark');
|
|
363
|
+
button.querySelector('.material-symbols-outlined').textContent = 'dark_mode';
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
button.addEventListener('click', () => {
|
|
367
|
+
html.classList.toggle('dark');
|
|
368
|
+
const isDark = html.classList.contains('dark');
|
|
369
|
+
button.querySelector('.material-symbols-outlined').textContent = isDark ? 'dark_mode' : 'light_mode';
|
|
370
|
+
localStorage.setItem('admin_theme', isDark ? 'dark' : 'light');
|
|
371
|
+
});
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
showNotification(message, type = 'info') {
|
|
375
|
+
const palette = {
|
|
376
|
+
success: 'bg-green-50 border-green-500 text-green-800',
|
|
377
|
+
error: 'bg-error-container border-error text-on-error-container',
|
|
378
|
+
info: 'bg-primary-fixed border-primary text-on-primary-fixed'
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const icons = {
|
|
382
|
+
success: 'check_circle',
|
|
383
|
+
error: 'error',
|
|
384
|
+
info: 'info'
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const node = document.createElement('div');
|
|
388
|
+
node.className = `fixed top-4 right-4 z-50 rounded-xl border px-5 py-3 shadow-lg ${palette[type] || palette.info} flex items-center gap-2`;
|
|
389
|
+
node.innerHTML = `
|
|
390
|
+
<span class="material-symbols-outlined">${icons[type] || icons.info}</span>
|
|
391
|
+
<span>${escapeHtml(message)}</span>
|
|
392
|
+
`;
|
|
393
|
+
document.body.appendChild(node);
|
|
394
|
+
setTimeout(() => node.remove(), 2500);
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
showModal(title, content, actions, onMount) {
|
|
398
|
+
this.hideModal();
|
|
399
|
+
const modal = document.createElement('div');
|
|
400
|
+
modal.className = 'fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4 modal-backdrop';
|
|
401
|
+
modal.innerHTML = `
|
|
402
|
+
<div class="bg-surface-container-lowest rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden modal-content">
|
|
403
|
+
<div class="flex items-center justify-between border-b border-outline-variant/20 p-6">
|
|
404
|
+
<h2 class="text-xl font-bold text-on-surface">${title}</h2>
|
|
405
|
+
<button data-close-modal class="p-2 rounded-xl hover:bg-surface-container transition-all">
|
|
406
|
+
<span class="material-symbols-outlined text-on-surface-variant">close</span>
|
|
407
|
+
</button>
|
|
408
|
+
</div>
|
|
409
|
+
<div class="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">${content}</div>
|
|
410
|
+
${actions ? `<div class="flex justify-end gap-3 border-t border-outline-variant/20 p-6">${actions}</div>` : ''}
|
|
411
|
+
</div>
|
|
412
|
+
`;
|
|
413
|
+
|
|
414
|
+
modal.addEventListener('click', (event) => {
|
|
415
|
+
if (event.target === modal || event.target.closest('[data-close-modal]')) {
|
|
416
|
+
this.hideModal();
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
document.getElementById('modal-root').appendChild(modal);
|
|
421
|
+
|
|
422
|
+
if (onMount) {
|
|
423
|
+
setTimeout(() => onMount(), 50);
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
|
|
427
|
+
hideModal() {
|
|
428
|
+
const modal = document.querySelector('#modal-root .fixed');
|
|
429
|
+
if (modal) {
|
|
430
|
+
modal.classList.add('exit');
|
|
431
|
+
modal.querySelector('.modal-content')?.classList.add('exit');
|
|
432
|
+
setTimeout(() => modal.remove(), 150);
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
closeModal() {
|
|
437
|
+
this.hideModal();
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const Dashboard = {
|
|
442
|
+
async render() {
|
|
443
|
+
try {
|
|
444
|
+
const [statsResult, activityResult] = await Promise.all([API.getStats({ service: getActiveService() }), API.getActivity()]);
|
|
445
|
+
if (statsResult.success) {
|
|
446
|
+
AppState.stats = statsResult.data || {};
|
|
447
|
+
this.renderStats();
|
|
448
|
+
}
|
|
449
|
+
if (activityResult.success) {
|
|
450
|
+
this.renderActivity(activityResult.data || []);
|
|
451
|
+
}
|
|
452
|
+
this.bindQuickActions();
|
|
453
|
+
} catch (error) {
|
|
454
|
+
if (error.message === '未授权') {
|
|
455
|
+
showLoginForm();
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
console.error(error);
|
|
459
|
+
UI.showNotification('概览加载失败', 'error');
|
|
460
|
+
}
|
|
461
|
+
},
|
|
462
|
+
|
|
463
|
+
renderStats() {
|
|
464
|
+
const stats = AppState.stats;
|
|
465
|
+
const cards = [
|
|
466
|
+
{ label: '总用户数', value: stats.totalUsers || 0, icon: 'group', color: 'primary', sub: '全部用户' },
|
|
467
|
+
{ label: '活跃用户', value: stats.activeUsers || 0, icon: 'trending_up', color: 'tertiary', sub: '当前活跃状态' },
|
|
468
|
+
{ label: '总激活码', value: stats.totalCodes || 0, icon: 'key', color: 'secondary', sub: `未使用 ${stats.unusedCodes || 0}` },
|
|
469
|
+
{ label: '已过期', value: stats.expiredCodes || 0, icon: 'schedule', color: 'error', sub: stats.systemStatus || '运行正常' }
|
|
470
|
+
];
|
|
471
|
+
|
|
472
|
+
const container = document.querySelector('#dashboard-panel .grid');
|
|
473
|
+
container.innerHTML = cards.map((card, index) => `
|
|
474
|
+
<div class="bg-surface-container-lowest rounded-2xl p-6 border border-outline-variant/20 interactive-card">
|
|
475
|
+
<div class="flex items-start justify-between">
|
|
476
|
+
<div>
|
|
477
|
+
<p class="text-xs font-bold uppercase tracking-wider text-on-surface-variant mb-2">${card.label}</p>
|
|
478
|
+
<h2 class="text-3xl font-bold text-on-surface">${card.value}</h2>
|
|
479
|
+
<p class="text-xs text-on-surface-variant mt-2">${card.sub}</p>
|
|
480
|
+
</div>
|
|
481
|
+
<div class="w-12 h-12 rounded-xl bg-${card.color}-fixed/20 flex items-center justify-center">
|
|
482
|
+
<span class="material-symbols-outlined text-${card.color} text-2xl">${card.icon}</span>
|
|
483
|
+
</div>
|
|
484
|
+
</div>
|
|
485
|
+
</div>
|
|
486
|
+
`).join('');
|
|
487
|
+
},
|
|
488
|
+
|
|
489
|
+
renderActivity(activities) {
|
|
490
|
+
const container = document.getElementById('recent-activity');
|
|
491
|
+
if (!activities.length) {
|
|
492
|
+
container.innerHTML = '<p class="text-sm text-on-surface-variant text-center py-4">暂无活动记录</p>';
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
container.innerHTML = activities.slice(0, 6).map((activity, index) => `
|
|
497
|
+
<div class="flex items-start gap-3 p-3 rounded-xl hover:bg-surface-container-low transition-all">
|
|
498
|
+
<div class="w-8 h-8 rounded-full bg-primary-fixed/20 flex items-center justify-center flex-shrink-0">
|
|
499
|
+
<span class="material-symbols-outlined text-primary text-sm">history</span>
|
|
500
|
+
</div>
|
|
501
|
+
<div class="flex-1 min-w-0">
|
|
502
|
+
<p class="text-sm font-medium text-on-surface">${escapeHtml(activity.action || '-')}</p>
|
|
503
|
+
<p class="text-xs text-on-surface-variant mt-1">${escapeHtml(activity.user || '-')} · ${formatDate(activity.timestamp, true)}</p>
|
|
504
|
+
</div>
|
|
505
|
+
<span class="px-2 py-1 rounded-lg text-xs font-medium ${activity.status === 'success' ? 'bg-green-50 text-green-700' : 'bg-error-container text-on-error-container'}">
|
|
506
|
+
${activity.status === 'success' ? '成功' : '失败'}
|
|
507
|
+
</span>
|
|
508
|
+
</div>
|
|
509
|
+
`).join('');
|
|
510
|
+
},
|
|
511
|
+
|
|
512
|
+
bindQuickActions() {
|
|
513
|
+
document.getElementById('quick-add-user')?.addEventListener('click', () => {
|
|
514
|
+
UI.switchTab('users');
|
|
515
|
+
UserManagement.showUserModal();
|
|
516
|
+
}, { once: true });
|
|
517
|
+
|
|
518
|
+
document.getElementById('quick-add-code')?.addEventListener('click', () => {
|
|
519
|
+
UI.switchTab('codex-codes');
|
|
520
|
+
CodeManagement.showCreateCodeModal();
|
|
521
|
+
}, { once: true });
|
|
522
|
+
|
|
523
|
+
document.getElementById('quick-settings')?.addEventListener('click', () => {
|
|
524
|
+
UI.switchTab('settings');
|
|
525
|
+
}, { once: true });
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
const UserManagement = {
|
|
530
|
+
async render() {
|
|
531
|
+
this.bindEvents();
|
|
532
|
+
await this.loadUsers();
|
|
533
|
+
},
|
|
534
|
+
|
|
535
|
+
bindEvents() {
|
|
536
|
+
if (AppState.bindings.users) return;
|
|
537
|
+
AppState.bindings.users = true;
|
|
538
|
+
|
|
539
|
+
document.getElementById('add-user-btn')?.addEventListener('click', () => this.showUserModal());
|
|
540
|
+
document.getElementById('user-status-filter')?.addEventListener('change', (event) => {
|
|
541
|
+
AppState.filters.users.status = event.target.value;
|
|
542
|
+
this.loadUsers();
|
|
543
|
+
});
|
|
544
|
+
document.getElementById('user-search')?.addEventListener('input', debounce((event) => {
|
|
545
|
+
AppState.filters.users.search = event.target.value.trim();
|
|
546
|
+
this.loadUsers();
|
|
547
|
+
}));
|
|
548
|
+
},
|
|
549
|
+
|
|
550
|
+
async loadUsers() {
|
|
551
|
+
try {
|
|
552
|
+
const result = await API.getUsers({ ...AppState.filters.users, service: getActiveService() });
|
|
553
|
+
if (!result.success) {
|
|
554
|
+
UI.showNotification(result.message || '加载用户失败', 'error');
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
AppState.users = result.data || [];
|
|
558
|
+
this.renderTable();
|
|
559
|
+
} catch (error) {
|
|
560
|
+
if (error.message === '未授权') {
|
|
561
|
+
showLoginForm();
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
console.error(error);
|
|
565
|
+
UI.showNotification('加载用户失败', 'error');
|
|
566
|
+
}
|
|
567
|
+
},
|
|
568
|
+
|
|
569
|
+
renderTable() {
|
|
570
|
+
const container = document.getElementById('users-table-container');
|
|
571
|
+
if (!AppState.users.length) {
|
|
572
|
+
container.innerHTML = '<p class="text-on-surface-variant text-center py-8">暂无用户数据</p>';
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
container.innerHTML = `
|
|
577
|
+
<table class="w-full">
|
|
578
|
+
<thead>
|
|
579
|
+
<tr class="bg-surface-container-high">
|
|
580
|
+
<th class="text-left px-4 py-3 text-xs font-bold uppercase tracking-wider text-on-surface-variant">用户名</th>
|
|
581
|
+
<th class="text-left px-4 py-3 text-xs font-bold uppercase tracking-wider text-on-surface-variant">邮箱</th>
|
|
582
|
+
<th class="text-left px-4 py-3 text-xs font-bold uppercase tracking-wider text-on-surface-variant">服务类型</th>
|
|
583
|
+
<th class="text-left px-4 py-3 text-xs font-bold uppercase tracking-wider text-on-surface-variant">状态</th>
|
|
584
|
+
<th class="text-left px-4 py-3 text-xs font-bold uppercase tracking-wider text-on-surface-variant">创建时间</th>
|
|
585
|
+
<th class="text-right px-4 py-3 text-xs font-bold uppercase tracking-wider text-on-surface-variant">操作</th>
|
|
586
|
+
</tr>
|
|
587
|
+
</thead>
|
|
588
|
+
<tbody>
|
|
589
|
+
${AppState.users.map((user, index) => {
|
|
590
|
+
const status = getUserStatusMeta(user);
|
|
591
|
+
return `
|
|
592
|
+
<tr class="${index % 2 === 0 ? 'bg-surface-container-lowest' : 'bg-surface-container-low'} hover:bg-surface-container transition-all">
|
|
593
|
+
<td class="px-4 py-3 text-sm font-medium text-on-surface">${escapeHtml(user.username)}</td>
|
|
594
|
+
<td class="px-4 py-3 text-sm text-on-surface-variant">${escapeHtml(user.email)}</td>
|
|
595
|
+
<td class="px-4 py-3 text-sm text-on-surface-variant">${escapeHtml(user.serviceLabel || user.service)}</td>
|
|
596
|
+
<td class="px-4 py-3"><span class="px-2 py-1 rounded-lg text-xs font-medium ${status.className}">${status.label}</span></td>
|
|
597
|
+
<td class="px-4 py-3 text-sm text-on-surface-variant">${formatDate(user.registeredAt || user.createdAt)}</td>
|
|
598
|
+
<td class="px-4 py-3 text-right">
|
|
599
|
+
<button data-user-edit="${escapeHtml(user.id)}" class="p-1 text-primary hover:bg-primary-fixed/20 rounded-lg transition-all">
|
|
600
|
+
<span class="material-symbols-outlined text-sm">edit</span>
|
|
601
|
+
</button>
|
|
602
|
+
<button data-user-delete="${escapeHtml(user.id)}" class="p-1 text-error hover:bg-error-container/20 rounded-lg transition-all ml-1">
|
|
603
|
+
<span class="material-symbols-outlined text-sm">delete</span>
|
|
604
|
+
</button>
|
|
605
|
+
</td>
|
|
606
|
+
</tr>
|
|
607
|
+
`;
|
|
608
|
+
}).join('')}
|
|
609
|
+
</tbody>
|
|
610
|
+
</table>
|
|
611
|
+
`;
|
|
612
|
+
|
|
613
|
+
container.querySelectorAll('[data-user-edit]').forEach((button) => {
|
|
614
|
+
button.addEventListener('click', () => this.editUser(button.dataset.userEdit));
|
|
615
|
+
});
|
|
616
|
+
container.querySelectorAll('[data-user-delete]').forEach((button) => {
|
|
617
|
+
button.addEventListener('click', () => this.deleteUser(button.dataset.userDelete));
|
|
618
|
+
});
|
|
619
|
+
},
|
|
620
|
+
|
|
621
|
+
showUserModal(user = null) {
|
|
622
|
+
const isEdit = Boolean(user);
|
|
623
|
+
const content = `
|
|
624
|
+
<div class="space-y-4">
|
|
625
|
+
<div>
|
|
626
|
+
<label class="block text-sm font-medium text-on-surface mb-2">用户名</label>
|
|
627
|
+
<input id="user-username" type="text" class="w-full px-4 py-2 bg-surface-container-low border-none rounded-xl text-sm outline-none" value="${escapeHtml(user?.username || '')}" />
|
|
628
|
+
</div>
|
|
629
|
+
<div>
|
|
630
|
+
<label class="block text-sm font-medium text-on-surface mb-2">邮箱</label>
|
|
631
|
+
<input id="user-email" type="email" class="w-full px-4 py-2 bg-surface-container-low border-none rounded-xl text-sm outline-none" value="${escapeHtml(user?.email || '')}" />
|
|
632
|
+
</div>
|
|
633
|
+
<div>
|
|
634
|
+
<label class="block text-sm font-medium text-on-surface mb-2">服务类型</label>
|
|
635
|
+
<select id="user-service" ${getActiveService() ? 'disabled' : ''} class="w-full px-4 py-2 bg-surface-container-low border-none rounded-xl text-sm outline-none">
|
|
636
|
+
${getServiceOptionsForScope(user?.serviceLabel || user?.service).map((service) => `<option value="${service.label}" ${user?.serviceKey === service.key || user?.serviceLabel === service.label || user?.service === service.label ? 'selected' : ''}>${service.label}</option>`).join('')}
|
|
637
|
+
</select>
|
|
638
|
+
${getActiveService() ? '<p class="mt-2 text-xs text-on-surface-variant">当前平台上下文已锁定,保存后只归属该平台。</p>' : ''}
|
|
639
|
+
</div>
|
|
640
|
+
<div>
|
|
641
|
+
<label class="block text-sm font-medium text-on-surface mb-2">状态</label>
|
|
642
|
+
<select id="user-status" class="w-full px-4 py-2 bg-surface-container-low border-none rounded-xl text-sm outline-none">
|
|
643
|
+
<option value="活跃" ${user?.statusLabel === '活跃' ? 'selected' : ''}>活跃</option>
|
|
644
|
+
<option value="待激活" ${!user || user?.statusLabel === '待激活' ? 'selected' : ''}>待激活</option>
|
|
645
|
+
<option value="已禁用" ${user?.statusLabel === '已禁用' ? 'selected' : ''}>已禁用</option>
|
|
646
|
+
</select>
|
|
647
|
+
</div>
|
|
648
|
+
</div>
|
|
649
|
+
`;
|
|
650
|
+
|
|
651
|
+
const actions = `
|
|
652
|
+
<button data-close-modal class="px-4 py-2 text-on-surface-variant hover:bg-surface-container rounded-xl transition-all">取消</button>
|
|
653
|
+
<button id="save-user-btn" class="px-4 py-2 bg-primary text-on-primary rounded-xl font-bold hover:shadow-md transition-all">${isEdit ? '保存修改' : '创建用户'}</button>
|
|
654
|
+
`;
|
|
655
|
+
|
|
656
|
+
UI.showModal(isEdit ? '编辑用户' : '添加用户', content, actions);
|
|
657
|
+
document.getElementById('save-user-btn')?.addEventListener('click', () => this.saveUser(user?.id || null));
|
|
658
|
+
},
|
|
659
|
+
|
|
660
|
+
editUser(id) {
|
|
661
|
+
const user = AppState.users.find((item) => item.id === id);
|
|
662
|
+
if (user) this.showUserModal(user);
|
|
663
|
+
},
|
|
664
|
+
|
|
665
|
+
async saveUser(id) {
|
|
666
|
+
const payload = {
|
|
667
|
+
username: document.getElementById('user-username').value.trim(),
|
|
668
|
+
email: document.getElementById('user-email').value.trim(),
|
|
669
|
+
service: getActiveServiceOption()?.label || document.getElementById('user-service').value,
|
|
670
|
+
status: document.getElementById('user-status').value
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
if (!payload.username || !payload.email) {
|
|
674
|
+
UI.showNotification('用户名和邮箱不能为空', 'error');
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
try {
|
|
679
|
+
const result = id ? await API.updateUser(id, payload) : await API.createUser(payload);
|
|
680
|
+
if (!result.success) {
|
|
681
|
+
UI.showNotification(result.message || '保存失败', 'error');
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
UI.hideModal();
|
|
685
|
+
UI.showNotification(id ? '用户已更新' : '用户已创建', 'success');
|
|
686
|
+
await this.loadUsers();
|
|
687
|
+
await Dashboard.render();
|
|
688
|
+
} catch (error) {
|
|
689
|
+
if (error.message === '未授权') {
|
|
690
|
+
showLoginForm();
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
console.error(error);
|
|
694
|
+
UI.showNotification('保存失败', 'error');
|
|
695
|
+
}
|
|
696
|
+
},
|
|
697
|
+
|
|
698
|
+
async deleteUser(id) {
|
|
699
|
+
if (!confirm('确定要删除这个用户吗?')) return;
|
|
700
|
+
try {
|
|
701
|
+
const result = await API.deleteUser(id);
|
|
702
|
+
if (!result.success) {
|
|
703
|
+
UI.showNotification(result.message || '删除失败', 'error');
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
UI.showNotification('用户已删除', 'success');
|
|
707
|
+
await this.loadUsers();
|
|
708
|
+
await Dashboard.render();
|
|
709
|
+
} catch (error) {
|
|
710
|
+
if (error.message === '未授权') {
|
|
711
|
+
showLoginForm();
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
console.error(error);
|
|
715
|
+
UI.showNotification('删除失败', 'error');
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
const CodeManagement = {
|
|
721
|
+
async render() {
|
|
722
|
+
this.bindEvents();
|
|
723
|
+
await this.loadCodes();
|
|
724
|
+
},
|
|
725
|
+
|
|
726
|
+
bindEvents() {
|
|
727
|
+
if (AppState.bindings.codes) return;
|
|
728
|
+
AppState.bindings.codes = true;
|
|
729
|
+
|
|
730
|
+
document.getElementById('add-code-btn')?.addEventListener('click', () => this.showCreateCodeModal());
|
|
731
|
+
document.getElementById('export-codes-btn')?.addEventListener('click', () => this.exportCodes());
|
|
732
|
+
document.getElementById('code-status-filter')?.addEventListener('change', (event) => {
|
|
733
|
+
AppState.filters.codes.status = event.target.value;
|
|
734
|
+
this.loadCodes();
|
|
735
|
+
});
|
|
736
|
+
document.getElementById('code-search')?.addEventListener('input', debounce((event) => {
|
|
737
|
+
AppState.filters.codes.search = event.target.value.trim();
|
|
738
|
+
this.loadCodes();
|
|
739
|
+
}));
|
|
740
|
+
},
|
|
741
|
+
|
|
742
|
+
async loadCodes() {
|
|
743
|
+
try {
|
|
744
|
+
const result = await API.getCodes({ ...AppState.filters.codes, service: getActiveService() });
|
|
745
|
+
if (!result.success) {
|
|
746
|
+
UI.showNotification(result.message || '加载激活码失败', 'error');
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
AppState.codes = result.data || [];
|
|
750
|
+
this.renderTable();
|
|
751
|
+
} catch (error) {
|
|
752
|
+
if (error.message === '未授权') {
|
|
753
|
+
showLoginForm();
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
console.error(error);
|
|
757
|
+
UI.showNotification('加载激活码失败', 'error');
|
|
758
|
+
}
|
|
759
|
+
},
|
|
760
|
+
|
|
761
|
+
renderTable() {
|
|
762
|
+
const container = document.getElementById('codes-table-container');
|
|
763
|
+
if (!AppState.codes.length) {
|
|
764
|
+
container.innerHTML = '<p class="text-on-surface-variant text-center py-8">暂无激活码数据</p>';
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
container.innerHTML = `
|
|
769
|
+
<table class="w-full">
|
|
770
|
+
<thead>
|
|
771
|
+
<tr class="bg-surface-container-high">
|
|
772
|
+
<th class="text-left px-4 py-3 text-xs font-bold uppercase tracking-wider text-on-surface-variant">激活码</th>
|
|
773
|
+
<th class="text-left px-4 py-3 text-xs font-bold uppercase tracking-wider text-on-surface-variant">服务类型</th>
|
|
774
|
+
<th class="text-left px-4 py-3 text-xs font-bold uppercase tracking-wider text-on-surface-variant">状态</th>
|
|
775
|
+
<th class="text-left px-4 py-3 text-xs font-bold uppercase tracking-wider text-on-surface-variant">创建时间</th>
|
|
776
|
+
<th class="text-left px-4 py-3 text-xs font-bold uppercase tracking-wider text-on-surface-variant">过期时间</th>
|
|
777
|
+
<th class="text-right px-4 py-3 text-xs font-bold uppercase tracking-wider text-on-surface-variant">操作</th>
|
|
778
|
+
</tr>
|
|
779
|
+
</thead>
|
|
780
|
+
<tbody>
|
|
781
|
+
${AppState.codes.map((code, index) => {
|
|
782
|
+
const status = getCodeStatusMeta(code);
|
|
783
|
+
return `
|
|
784
|
+
<tr class="${index % 2 === 0 ? 'bg-surface-container-lowest' : 'bg-surface-container-low'} hover:bg-surface-container transition-all">
|
|
785
|
+
<td class="px-4 py-3 text-sm font-mono text-on-surface">
|
|
786
|
+
<div class="flex items-center gap-2">
|
|
787
|
+
<span>${escapeHtml(code.code)}</span>
|
|
788
|
+
<button data-code-copy="${escapeHtml(code.code)}" class="p-1 text-on-surface-variant hover:text-primary hover:bg-primary-fixed/20 rounded-lg transition-all" title="复制激活码">
|
|
789
|
+
<span class="material-symbols-outlined text-sm">content_copy</span>
|
|
790
|
+
</button>
|
|
791
|
+
</div>
|
|
792
|
+
</td>
|
|
793
|
+
<td class="px-4 py-3 text-sm text-on-surface-variant">${escapeHtml(code.serviceLabel || code.service)}</td>
|
|
794
|
+
<td class="px-4 py-3"><span class="px-2 py-1 rounded-lg text-xs font-medium ${status.className}">${status.label}</span></td>
|
|
795
|
+
<td class="px-4 py-3 text-sm text-on-surface-variant">${formatDate(code.createdAt)}</td>
|
|
796
|
+
<td class="px-4 py-3 text-sm text-on-surface-variant">${formatDate(code.expiresAt)}</td>
|
|
797
|
+
<td class="px-4 py-3 text-right">
|
|
798
|
+
<button data-code-edit="${escapeHtml(code.id)}" class="p-1 text-primary hover:bg-primary-fixed/20 rounded-lg transition-all">
|
|
799
|
+
<span class="material-symbols-outlined text-sm">edit</span>
|
|
800
|
+
</button>
|
|
801
|
+
<button data-code-delete="${escapeHtml(code.id)}" class="p-1 text-error hover:bg-error-container/20 rounded-lg transition-all ml-1">
|
|
802
|
+
<span class="material-symbols-outlined text-sm">delete</span>
|
|
803
|
+
</button>
|
|
804
|
+
</td>
|
|
805
|
+
</tr>
|
|
806
|
+
`;
|
|
807
|
+
}).join('')}
|
|
808
|
+
</tbody>
|
|
809
|
+
</table>
|
|
810
|
+
`;
|
|
811
|
+
|
|
812
|
+
container.querySelectorAll('[data-code-edit]').forEach((button) => {
|
|
813
|
+
button.addEventListener('click', () => this.showEditCodeModal(button.dataset.codeEdit));
|
|
814
|
+
});
|
|
815
|
+
container.querySelectorAll('[data-code-delete]').forEach((button) => {
|
|
816
|
+
button.addEventListener('click', () => this.deleteCode(button.dataset.codeDelete));
|
|
817
|
+
});
|
|
818
|
+
container.querySelectorAll('[data-code-copy]').forEach((button) => {
|
|
819
|
+
button.addEventListener('click', () => this.copyCode(button.dataset.codeCopy));
|
|
820
|
+
});
|
|
821
|
+
},
|
|
822
|
+
|
|
823
|
+
async copyCode(code) {
|
|
824
|
+
try {
|
|
825
|
+
// 尝试使用现代 Clipboard API
|
|
826
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
827
|
+
await navigator.clipboard.writeText(code);
|
|
828
|
+
UI.showNotification('激活码已复制到剪贴板', 'success');
|
|
829
|
+
} else {
|
|
830
|
+
// 降级方案:使用传统方法
|
|
831
|
+
const textarea = document.createElement('textarea');
|
|
832
|
+
textarea.value = code;
|
|
833
|
+
textarea.style.position = 'fixed';
|
|
834
|
+
textarea.style.left = '-999999px';
|
|
835
|
+
textarea.style.top = '-999999px';
|
|
836
|
+
document.body.appendChild(textarea);
|
|
837
|
+
textarea.focus();
|
|
838
|
+
textarea.select();
|
|
839
|
+
|
|
840
|
+
try {
|
|
841
|
+
const successful = document.execCommand('copy');
|
|
842
|
+
if (successful) {
|
|
843
|
+
UI.showNotification('激活码已复制到剪贴板', 'success');
|
|
844
|
+
} else {
|
|
845
|
+
throw new Error('execCommand failed');
|
|
846
|
+
}
|
|
847
|
+
} finally {
|
|
848
|
+
document.body.removeChild(textarea);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
} catch (error) {
|
|
852
|
+
console.error('复制失败:', error);
|
|
853
|
+
// 显示激活码让用户手动复制
|
|
854
|
+
this.showCopyFallback(code);
|
|
855
|
+
}
|
|
856
|
+
},
|
|
857
|
+
|
|
858
|
+
showCopyFallback(code) {
|
|
859
|
+
const content = `
|
|
860
|
+
<div class="space-y-4">
|
|
861
|
+
<p class="text-sm text-on-surface-variant">请手动复制以下激活码:</p>
|
|
862
|
+
<div class="bg-surface-container-low rounded-xl p-4">
|
|
863
|
+
<input
|
|
864
|
+
type="text"
|
|
865
|
+
value="${escapeHtml(code)}"
|
|
866
|
+
readonly
|
|
867
|
+
id="fallback-code-input"
|
|
868
|
+
class="w-full bg-transparent border-none text-center font-mono text-lg text-on-surface outline-none"
|
|
869
|
+
onclick="this.select()"
|
|
870
|
+
/>
|
|
871
|
+
</div>
|
|
872
|
+
<p class="text-xs text-on-surface-variant text-center">点击输入框可全选激活码</p>
|
|
873
|
+
</div>
|
|
874
|
+
`;
|
|
875
|
+
|
|
876
|
+
UI.showModal('复制激活码', content, null, () => {
|
|
877
|
+
const input = document.getElementById('fallback-code-input');
|
|
878
|
+
if (input) {
|
|
879
|
+
input.select();
|
|
880
|
+
input.focus();
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
},
|
|
884
|
+
|
|
885
|
+
exportCodes() {
|
|
886
|
+
if (!AppState.codes.length) {
|
|
887
|
+
UI.showNotification('没有可导出的激活码', 'warning');
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const content = `
|
|
892
|
+
<div class="space-y-4">
|
|
893
|
+
<p class="text-sm text-on-surface-variant">选择导出格式:</p>
|
|
894
|
+
<div class="space-y-2">
|
|
895
|
+
<button id="export-txt" class="w-full px-4 py-3 bg-surface-container-low hover:bg-surface-container rounded-xl text-left transition-all">
|
|
896
|
+
<div class="font-medium text-on-surface">纯文本 (.txt)</div>
|
|
897
|
+
<div class="text-xs text-on-surface-variant mt-1">每行一个激活码,适合批量导入</div>
|
|
898
|
+
</button>
|
|
899
|
+
<button id="export-csv" class="w-full px-4 py-3 bg-surface-container-low hover:bg-surface-container rounded-xl text-left transition-all">
|
|
900
|
+
<div class="font-medium text-on-surface">CSV 表格 (.csv)</div>
|
|
901
|
+
<div class="text-xs text-on-surface-variant mt-1">包含完整信息,可用 Excel 打开</div>
|
|
902
|
+
</button>
|
|
903
|
+
<button id="export-json" class="w-full px-4 py-3 bg-surface-container-low hover:bg-surface-container rounded-xl text-left transition-all">
|
|
904
|
+
<div class="font-medium text-on-surface">JSON 数据 (.json)</div>
|
|
905
|
+
<div class="text-xs text-on-surface-variant mt-1">完整数据结构,适合程序处理</div>
|
|
906
|
+
</button>
|
|
907
|
+
</div>
|
|
908
|
+
<div class="text-xs text-on-surface-variant">
|
|
909
|
+
当前筛选条件下共 ${AppState.codes.length} 个激活码
|
|
910
|
+
</div>
|
|
911
|
+
</div>
|
|
912
|
+
`;
|
|
913
|
+
|
|
914
|
+
UI.showModal('导出激活码', content, null, () => {
|
|
915
|
+
document.getElementById('export-txt')?.addEventListener('click', () => {
|
|
916
|
+
this.downloadCodes('txt');
|
|
917
|
+
UI.closeModal();
|
|
918
|
+
});
|
|
919
|
+
document.getElementById('export-csv')?.addEventListener('click', () => {
|
|
920
|
+
this.downloadCodes('csv');
|
|
921
|
+
UI.closeModal();
|
|
922
|
+
});
|
|
923
|
+
document.getElementById('export-json')?.addEventListener('click', () => {
|
|
924
|
+
this.downloadCodes('json');
|
|
925
|
+
UI.closeModal();
|
|
926
|
+
});
|
|
927
|
+
});
|
|
928
|
+
},
|
|
929
|
+
|
|
930
|
+
downloadCodes(format) {
|
|
931
|
+
const timestamp = new Date().toISOString().slice(0, 10);
|
|
932
|
+
let content = '';
|
|
933
|
+
let filename = '';
|
|
934
|
+
let mimeType = '';
|
|
935
|
+
|
|
936
|
+
switch (format) {
|
|
937
|
+
case 'txt':
|
|
938
|
+
content = AppState.codes.map(c => c.code).join('\n');
|
|
939
|
+
filename = `activation-codes-${timestamp}.txt`;
|
|
940
|
+
mimeType = 'text/plain';
|
|
941
|
+
break;
|
|
942
|
+
|
|
943
|
+
case 'csv':
|
|
944
|
+
const headers = ['激活码', '服务类型', '状态', '创建时间', '过期时间', '使用者'];
|
|
945
|
+
const rows = AppState.codes.map(c => [
|
|
946
|
+
c.code,
|
|
947
|
+
c.serviceLabel || c.service,
|
|
948
|
+
c.statusLabel || c.status,
|
|
949
|
+
formatDate(c.createdAt),
|
|
950
|
+
formatDate(c.expiresAt),
|
|
951
|
+
c.usedBy || ''
|
|
952
|
+
]);
|
|
953
|
+
content = [headers, ...rows].map(row => row.join(',')).join('\n');
|
|
954
|
+
filename = `activation-codes-${timestamp}.csv`;
|
|
955
|
+
mimeType = 'text/csv;charset=utf-8;';
|
|
956
|
+
break;
|
|
957
|
+
|
|
958
|
+
case 'json':
|
|
959
|
+
content = JSON.stringify(AppState.codes, null, 2);
|
|
960
|
+
filename = `activation-codes-${timestamp}.json`;
|
|
961
|
+
mimeType = 'application/json';
|
|
962
|
+
break;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const blob = new Blob(['\ufeff' + content], { type: mimeType });
|
|
966
|
+
const url = URL.createObjectURL(blob);
|
|
967
|
+
const link = document.createElement('a');
|
|
968
|
+
link.href = url;
|
|
969
|
+
link.download = filename;
|
|
970
|
+
document.body.appendChild(link);
|
|
971
|
+
link.click();
|
|
972
|
+
document.body.removeChild(link);
|
|
973
|
+
URL.revokeObjectURL(url);
|
|
974
|
+
|
|
975
|
+
UI.showNotification(`已导出 ${AppState.codes.length} 个激活码`, 'success');
|
|
976
|
+
},
|
|
977
|
+
|
|
978
|
+
showCreateCodeModal() {
|
|
979
|
+
const serviceChoices = getServiceOptionsForScope();
|
|
980
|
+
const content = `
|
|
981
|
+
<div class="space-y-4">
|
|
982
|
+
<!-- CDK类型 -->
|
|
983
|
+
<div>
|
|
984
|
+
<label class="block text-sm font-medium text-on-surface mb-2">CDK 类型</label>
|
|
985
|
+
<select id="code-type" class="w-full px-4 py-2 bg-surface-container-low border-none rounded-xl text-sm outline-none">
|
|
986
|
+
<option value="monthly">月度订阅</option>
|
|
987
|
+
<option value="fixed">固定额度</option>
|
|
988
|
+
</select>
|
|
989
|
+
</div>
|
|
990
|
+
|
|
991
|
+
<!-- 服务渠道 -->
|
|
992
|
+
<div>
|
|
993
|
+
<label class="block text-sm font-medium text-on-surface mb-2">服务渠道</label>
|
|
994
|
+
<div class="grid grid-cols-2 gap-2" id="service-channels">
|
|
995
|
+
${serviceChoices.map((service, index) => `
|
|
996
|
+
<label class="flex items-center gap-2 p-3 bg-surface-container-low rounded-xl ${getActiveService() ? 'cursor-not-allowed opacity-80' : 'cursor-pointer hover:bg-surface-container'} transition-all">
|
|
997
|
+
<input type="checkbox" value="${service.label}" class="service-checkbox" ${index === 0 || getActiveService() ? 'checked' : ''} ${getActiveService() ? 'disabled' : ''} />
|
|
998
|
+
<span class="material-symbols-outlined text-sm text-primary">${service.icon}</span>
|
|
999
|
+
<span class="text-sm text-on-surface">${service.label}</span>
|
|
1000
|
+
</label>
|
|
1001
|
+
`).join('')}
|
|
1002
|
+
</div>
|
|
1003
|
+
<p class="text-xs text-on-surface-variant mt-2">
|
|
1004
|
+
${getActiveServiceOption() ? `当前层级锁定为 ${getActiveServiceOption().label},生成的激活码只能激活该平台模型。` : '全局创建时可选择 Codex 或 Claude Code。'}
|
|
1005
|
+
</p>
|
|
1006
|
+
</div>
|
|
1007
|
+
|
|
1008
|
+
<!-- 月度订阅配置 -->
|
|
1009
|
+
<div id="monthly-config">
|
|
1010
|
+
<div class="space-y-3">
|
|
1011
|
+
<div>
|
|
1012
|
+
<label class="block text-sm font-medium text-on-surface mb-2">每日额度(次数)</label>
|
|
1013
|
+
<input id="code-daily-quota" type="number" min="1" value="100" class="w-full px-4 py-2 bg-surface-container-low border-none rounded-xl text-sm outline-none" />
|
|
1014
|
+
</div>
|
|
1015
|
+
<div>
|
|
1016
|
+
<label class="block text-sm font-medium text-on-surface mb-2">额度刷新时间</label>
|
|
1017
|
+
<input id="code-refresh-time" type="time" value="00:00" class="w-full px-4 py-2 bg-surface-container-low border-none rounded-xl text-sm outline-none" />
|
|
1018
|
+
</div>
|
|
1019
|
+
<div>
|
|
1020
|
+
<label class="block text-sm font-medium text-on-surface mb-2">订阅时长(月)</label>
|
|
1021
|
+
<input id="code-duration-months" type="number" min="1" value="1" class="w-full px-4 py-2 bg-surface-container-low border-none rounded-xl text-sm outline-none" />
|
|
1022
|
+
</div>
|
|
1023
|
+
</div>
|
|
1024
|
+
</div>
|
|
1025
|
+
|
|
1026
|
+
<!-- 固定额度配置 -->
|
|
1027
|
+
<div id="fixed-config" style="display:none;">
|
|
1028
|
+
<div class="space-y-3">
|
|
1029
|
+
<div>
|
|
1030
|
+
<label class="block text-sm font-medium text-on-surface mb-2">总额度(次数)</label>
|
|
1031
|
+
<input id="code-total-quota" type="number" min="1" value="1000" class="w-full px-4 py-2 bg-surface-container-low border-none rounded-xl text-sm outline-none" />
|
|
1032
|
+
</div>
|
|
1033
|
+
<div>
|
|
1034
|
+
<label class="block text-sm font-medium text-on-surface mb-2">有效期(天)</label>
|
|
1035
|
+
<input id="code-duration-days" type="number" min="1" value="30" class="w-full px-4 py-2 bg-surface-container-low border-none rounded-xl text-sm outline-none" />
|
|
1036
|
+
</div>
|
|
1037
|
+
</div>
|
|
1038
|
+
</div>
|
|
1039
|
+
|
|
1040
|
+
<!-- 生成数量 -->
|
|
1041
|
+
<div>
|
|
1042
|
+
<label class="block text-sm font-medium text-on-surface mb-2">生成数量</label>
|
|
1043
|
+
<input id="code-count" type="number" min="1" max="100" value="1" class="w-full px-4 py-2 bg-surface-container-low border-none rounded-xl text-sm outline-none" />
|
|
1044
|
+
<p class="text-xs text-on-surface-variant mt-1">最多一次生成 100 个</p>
|
|
1045
|
+
</div>
|
|
1046
|
+
|
|
1047
|
+
<!-- 备注 -->
|
|
1048
|
+
<div>
|
|
1049
|
+
<label class="block text-sm font-medium text-on-surface mb-2">备注</label>
|
|
1050
|
+
<textarea id="code-note" rows="2" placeholder="可选,用于标记此批次激活码的用途" class="w-full px-4 py-2 bg-surface-container-low border-none rounded-xl text-sm outline-none"></textarea>
|
|
1051
|
+
</div>
|
|
1052
|
+
</div>
|
|
1053
|
+
`;
|
|
1054
|
+
|
|
1055
|
+
const actions = `
|
|
1056
|
+
<button data-close-modal class="px-4 py-2 text-on-surface-variant hover:bg-surface-container rounded-xl transition-all">取消</button>
|
|
1057
|
+
<button id="create-code-btn" class="px-4 py-2 bg-primary text-on-primary rounded-xl font-bold hover:shadow-md transition-all">生成激活码</button>
|
|
1058
|
+
`;
|
|
1059
|
+
|
|
1060
|
+
UI.showModal('生成激活码', content, actions);
|
|
1061
|
+
|
|
1062
|
+
// 类型切换逻辑
|
|
1063
|
+
const typeSelect = document.getElementById('code-type');
|
|
1064
|
+
const monthlyConfig = document.getElementById('monthly-config');
|
|
1065
|
+
const fixedConfig = document.getElementById('fixed-config');
|
|
1066
|
+
|
|
1067
|
+
typeSelect.addEventListener('change', () => {
|
|
1068
|
+
if (typeSelect.value === 'monthly') {
|
|
1069
|
+
monthlyConfig.style.display = 'block';
|
|
1070
|
+
fixedConfig.style.display = 'none';
|
|
1071
|
+
} else {
|
|
1072
|
+
monthlyConfig.style.display = 'none';
|
|
1073
|
+
fixedConfig.style.display = 'block';
|
|
1074
|
+
}
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
document.getElementById('create-code-btn')?.addEventListener('click', () => this.createCode());
|
|
1078
|
+
},
|
|
1079
|
+
|
|
1080
|
+
showEditCodeModal(id) {
|
|
1081
|
+
const code = AppState.codes.find((item) => item.id === id);
|
|
1082
|
+
if (!code) return;
|
|
1083
|
+
|
|
1084
|
+
const content = `
|
|
1085
|
+
<div class="space-y-4">
|
|
1086
|
+
<div>
|
|
1087
|
+
<label class="block text-sm font-medium text-on-surface mb-2">激活码</label>
|
|
1088
|
+
<input type="text" class="w-full px-4 py-2 bg-surface-container-low border-none rounded-xl text-sm outline-none" value="${escapeHtml(code.code)}" disabled />
|
|
1089
|
+
</div>
|
|
1090
|
+
<div>
|
|
1091
|
+
<label class="block text-sm font-medium text-on-surface mb-2">服务类型</label>
|
|
1092
|
+
<select id="edit-code-service" ${getActiveService() ? 'disabled' : ''} class="w-full px-4 py-2 bg-surface-container-low border-none rounded-xl text-sm outline-none">
|
|
1093
|
+
${getServiceOptionsForScope(code.serviceLabel || code.service).map((service) => `<option value="${service.label}" ${code.serviceKey === service.key || code.serviceLabel === service.label || code.service === service.label ? 'selected' : ''}>${service.label}</option>`).join('')}
|
|
1094
|
+
</select>
|
|
1095
|
+
</div>
|
|
1096
|
+
<div>
|
|
1097
|
+
<label class="block text-sm font-medium text-on-surface mb-2">状态</label>
|
|
1098
|
+
<select id="edit-code-status" class="w-full px-4 py-2 bg-surface-container-low border-none rounded-xl text-sm outline-none">
|
|
1099
|
+
<option value="unused" ${code.status === 'unused' ? 'selected' : ''}>未使用</option>
|
|
1100
|
+
<option value="used" ${code.status === 'used' ? 'selected' : ''}>已使用</option>
|
|
1101
|
+
<option value="expired" ${code.status === 'expired' ? 'selected' : ''}>已过期</option>
|
|
1102
|
+
</select>
|
|
1103
|
+
</div>
|
|
1104
|
+
<div>
|
|
1105
|
+
<label class="block text-sm font-medium text-on-surface mb-2">过期时间</label>
|
|
1106
|
+
<input id="edit-code-expire" type="date" value="${code.expiresAt ? new Date(code.expiresAt).toISOString().split('T')[0] : ''}" class="w-full px-4 py-2 bg-surface-container-low border-none rounded-xl text-sm outline-none" />
|
|
1107
|
+
</div>
|
|
1108
|
+
<div>
|
|
1109
|
+
<label class="block text-sm font-medium text-on-surface mb-2">备注</label>
|
|
1110
|
+
<textarea id="edit-code-note" rows="3" class="w-full px-4 py-2 bg-surface-container-low border-none rounded-xl text-sm outline-none">${escapeHtml(code.notes || '')}</textarea>
|
|
1111
|
+
</div>
|
|
1112
|
+
</div>
|
|
1113
|
+
`;
|
|
1114
|
+
|
|
1115
|
+
const actions = `
|
|
1116
|
+
<button data-close-modal class="px-4 py-2 text-on-surface-variant hover:bg-surface-container rounded-xl transition-all">取消</button>
|
|
1117
|
+
<button id="update-code-btn" class="px-4 py-2 bg-primary text-on-primary rounded-xl font-bold hover:shadow-md transition-all">保存修改</button>
|
|
1118
|
+
`;
|
|
1119
|
+
|
|
1120
|
+
UI.showModal('编辑激活码', content, actions);
|
|
1121
|
+
document.getElementById('update-code-btn')?.addEventListener('click', () => this.updateCode(id));
|
|
1122
|
+
},
|
|
1123
|
+
|
|
1124
|
+
async createCode() {
|
|
1125
|
+
const codeType = document.getElementById('code-type').value;
|
|
1126
|
+
const lockedService = getActiveServiceOption();
|
|
1127
|
+
const serviceCheckboxes = document.querySelectorAll('.service-checkbox:checked');
|
|
1128
|
+
const services = lockedService ? [lockedService.label] : Array.from(serviceCheckboxes).map(cb => cb.value);
|
|
1129
|
+
const count = parseInt(document.getElementById('code-count').value, 10);
|
|
1130
|
+
const note = document.getElementById('code-note').value.trim();
|
|
1131
|
+
|
|
1132
|
+
if (!services.length) {
|
|
1133
|
+
UI.showNotification('请至少选择一个服务渠道', 'error');
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
if (!count || count < 1 || count > 100) {
|
|
1138
|
+
UI.showNotification('生成数量必须在 1-100 之间', 'error');
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
let payload = {};
|
|
1143
|
+
|
|
1144
|
+
if (codeType === 'monthly') {
|
|
1145
|
+
const dailyQuota = parseInt(document.getElementById('code-daily-quota').value, 10);
|
|
1146
|
+
const refreshTime = document.getElementById('code-refresh-time').value;
|
|
1147
|
+
const durationMonths = parseInt(document.getElementById('code-duration-months').value, 10);
|
|
1148
|
+
|
|
1149
|
+
if (!dailyQuota || dailyQuota < 1) {
|
|
1150
|
+
UI.showNotification('请输入有效的每日额度', 'error');
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
const expiresAt = new Date();
|
|
1155
|
+
expiresAt.setMonth(expiresAt.getMonth() + durationMonths);
|
|
1156
|
+
|
|
1157
|
+
payload = {
|
|
1158
|
+
type: 'monthly',
|
|
1159
|
+
service: lockedService?.label || services[0],
|
|
1160
|
+
services,
|
|
1161
|
+
count,
|
|
1162
|
+
note,
|
|
1163
|
+
duration: durationMonths * 30,
|
|
1164
|
+
quota: {
|
|
1165
|
+
type: 'monthly',
|
|
1166
|
+
dailyQuota,
|
|
1167
|
+
refreshTime,
|
|
1168
|
+
used: 0,
|
|
1169
|
+
total: dailyQuota
|
|
1170
|
+
}
|
|
1171
|
+
};
|
|
1172
|
+
} else {
|
|
1173
|
+
const totalQuota = parseInt(document.getElementById('code-total-quota').value, 10);
|
|
1174
|
+
const durationDays = parseInt(document.getElementById('code-duration-days').value, 10);
|
|
1175
|
+
|
|
1176
|
+
if (!totalQuota || totalQuota < 1) {
|
|
1177
|
+
UI.showNotification('请输入有效的总额度', 'error');
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
payload = {
|
|
1182
|
+
type: 'fixed',
|
|
1183
|
+
service: lockedService?.label || services[0],
|
|
1184
|
+
services,
|
|
1185
|
+
count,
|
|
1186
|
+
note,
|
|
1187
|
+
duration: durationDays,
|
|
1188
|
+
quota: {
|
|
1189
|
+
type: 'fixed',
|
|
1190
|
+
total: totalQuota,
|
|
1191
|
+
used: 0
|
|
1192
|
+
}
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
try {
|
|
1197
|
+
const result = await API.createCode(payload);
|
|
1198
|
+
if (!result.success) {
|
|
1199
|
+
UI.showNotification(result.message || '创建失败', 'error');
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
UI.hideModal();
|
|
1203
|
+
UI.showNotification(`成功生成 ${count} 个${codeType === 'monthly' ? '月度' : '固定额度'}激活码`, 'success');
|
|
1204
|
+
await this.loadCodes();
|
|
1205
|
+
await Dashboard.render();
|
|
1206
|
+
} catch (error) {
|
|
1207
|
+
if (error.message === '未授权') {
|
|
1208
|
+
showLoginForm();
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
console.error(error);
|
|
1212
|
+
UI.showNotification('创建失败', 'error');
|
|
1213
|
+
}
|
|
1214
|
+
},
|
|
1215
|
+
|
|
1216
|
+
async updateCode(id) {
|
|
1217
|
+
const expiresAt = document.getElementById('edit-code-expire').value;
|
|
1218
|
+
const payload = {
|
|
1219
|
+
service: getActiveServiceOption()?.label || document.getElementById('edit-code-service').value,
|
|
1220
|
+
status: document.getElementById('edit-code-status').value,
|
|
1221
|
+
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
|
|
1222
|
+
notes: document.getElementById('edit-code-note').value.trim()
|
|
1223
|
+
};
|
|
1224
|
+
|
|
1225
|
+
try {
|
|
1226
|
+
const result = await API.updateCode(id, payload);
|
|
1227
|
+
if (!result.success) {
|
|
1228
|
+
UI.showNotification(result.message || '更新失败', 'error');
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
UI.hideModal();
|
|
1232
|
+
UI.showNotification('激活码已更新', 'success');
|
|
1233
|
+
await this.loadCodes();
|
|
1234
|
+
await Dashboard.render();
|
|
1235
|
+
} catch (error) {
|
|
1236
|
+
if (error.message === '未授权') {
|
|
1237
|
+
showLoginForm();
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
console.error(error);
|
|
1241
|
+
UI.showNotification('更新失败', 'error');
|
|
1242
|
+
}
|
|
1243
|
+
},
|
|
1244
|
+
|
|
1245
|
+
async deleteCode(id) {
|
|
1246
|
+
if (!confirm('确定要删除这个激活码吗?')) return;
|
|
1247
|
+
try {
|
|
1248
|
+
const result = await API.deleteCode(id);
|
|
1249
|
+
if (!result.success) {
|
|
1250
|
+
UI.showNotification(result.message || '删除失败', 'error');
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
UI.showNotification('激活码已删除', 'success');
|
|
1254
|
+
await this.loadCodes();
|
|
1255
|
+
await Dashboard.render();
|
|
1256
|
+
} catch (error) {
|
|
1257
|
+
if (error.message === '未授权') {
|
|
1258
|
+
showLoginForm();
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
console.error(error);
|
|
1262
|
+
UI.showNotification('删除失败', 'error');
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
};
|
|
1266
|
+
|
|
1267
|
+
const LogsManagement = {
|
|
1268
|
+
render() {
|
|
1269
|
+
document.getElementById('logs-container').innerHTML = '<p class="text-on-surface-variant text-center py-8">日志功能开发中...</p>';
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
1272
|
+
|
|
1273
|
+
const SettingsManagement = {
|
|
1274
|
+
storageKey: 'cliproxy_admin_settings_v1',
|
|
1275
|
+
|
|
1276
|
+
sections: [
|
|
1277
|
+
{
|
|
1278
|
+
key: 'site',
|
|
1279
|
+
title: '站点设置',
|
|
1280
|
+
icon: 'language',
|
|
1281
|
+
description: '站点名称、描述、主域名与注册开关。',
|
|
1282
|
+
fields: [
|
|
1283
|
+
{ key: 'siteName', label: '站点名称', type: 'text' },
|
|
1284
|
+
{ key: 'siteDescription', label: '站点描述', type: 'textarea', rows: 3 },
|
|
1285
|
+
{ key: 'siteUrl', label: '站点地址', type: 'url' },
|
|
1286
|
+
{ key: 'logoUrl', label: 'LOGO URL', type: 'url' },
|
|
1287
|
+
{ key: 'forceHttps', label: '强制 HTTPS', type: 'checkbox' },
|
|
1288
|
+
{ key: 'stopRegister', label: '停止新用户注册', type: 'checkbox' }
|
|
1289
|
+
]
|
|
1290
|
+
},
|
|
1291
|
+
{
|
|
1292
|
+
key: 'security',
|
|
1293
|
+
title: '安全设置',
|
|
1294
|
+
icon: 'security',
|
|
1295
|
+
description: '会话超时、操作确认、登录提示和审计策略。',
|
|
1296
|
+
fields: [
|
|
1297
|
+
{ key: 'sessionTimeout', label: '会话超时(小时)', type: 'number', min: 1 },
|
|
1298
|
+
{ key: 'loginNotice', label: '登录提示', type: 'textarea', rows: 3 },
|
|
1299
|
+
{ key: 'operationConfirm', label: '高风险操作二次确认', type: 'checkbox' },
|
|
1300
|
+
{ key: 'ipWhitelist', label: 'IP 白名单', type: 'textarea', rows: 3, placeholder: '每行一个 IP 或网段' },
|
|
1301
|
+
{ key: 'auditMode', label: '启用审计模式', type: 'checkbox' }
|
|
1302
|
+
]
|
|
1303
|
+
},
|
|
1304
|
+
{
|
|
1305
|
+
key: 'subscription',
|
|
1306
|
+
title: '订阅设置',
|
|
1307
|
+
icon: 'rss_feed',
|
|
1308
|
+
description: '订阅域名、Token 长度、默认配额与有效期。',
|
|
1309
|
+
fields: [
|
|
1310
|
+
{ key: 'subscribeUrl', label: '订阅地址', type: 'url' },
|
|
1311
|
+
{ key: 'tokenLength', label: 'Token 长度', type: 'number', min: 8 },
|
|
1312
|
+
{ key: 'defaultQuota', label: '默认配额', type: 'number', min: 0 },
|
|
1313
|
+
{ key: 'defaultDuration', label: '默认有效期(天)', type: 'number', min: 1 },
|
|
1314
|
+
{ key: 'autoDisableExpired', label: '自动禁用过期订阅', type: 'checkbox' }
|
|
1315
|
+
]
|
|
1316
|
+
},
|
|
1317
|
+
{
|
|
1318
|
+
key: 'upstream',
|
|
1319
|
+
title: '上游 API 设置',
|
|
1320
|
+
icon: 'hub',
|
|
1321
|
+
description: '配置商户/上游 NewAPI 地址、API Key 和服务专用入口。',
|
|
1322
|
+
fields: [
|
|
1323
|
+
{
|
|
1324
|
+
key: 'provider',
|
|
1325
|
+
label: '上游类型',
|
|
1326
|
+
type: 'select',
|
|
1327
|
+
options: [
|
|
1328
|
+
{ value: 'newapi', label: 'NewAPI / OneAPI 兼容' }
|
|
1329
|
+
]
|
|
1330
|
+
},
|
|
1331
|
+
{ key: 'baseUrl', label: '上游 API 地址', type: 'url', placeholder: 'https://newapi.example.com' },
|
|
1332
|
+
{ key: 'apiKey', label: '上游 API Key', type: 'password', placeholder: '留空则保留当前密钥' },
|
|
1333
|
+
{ key: 'claudeBaseUrl', label: 'Claude 专用地址(可选)', type: 'url', placeholder: '默认使用上游 API 地址' },
|
|
1334
|
+
{ key: 'codexBaseUrl', label: 'Codex 专用地址(可选)', type: 'url', placeholder: '例如 https://newapi.example.com/v1' },
|
|
1335
|
+
{ key: 'timeoutMs', label: '请求超时(毫秒)', type: 'number', min: 1000 }
|
|
1336
|
+
]
|
|
1337
|
+
},
|
|
1338
|
+
{
|
|
1339
|
+
key: 'invite',
|
|
1340
|
+
title: '邀请与佣金',
|
|
1341
|
+
icon: 'person_add',
|
|
1342
|
+
description: '邀请开关、邀请奖励、佣金比例和结算周期。',
|
|
1343
|
+
fields: [
|
|
1344
|
+
{ key: 'inviteEnabled', label: '启用邀请体系', type: 'checkbox' },
|
|
1345
|
+
{ key: 'inviteReward', label: '邀请奖励', type: 'number', min: 0 },
|
|
1346
|
+
{ key: 'commissionRate', label: '佣金比例(%)', type: 'number', min: 0, max: 100 },
|
|
1347
|
+
{
|
|
1348
|
+
key: 'settleCycle',
|
|
1349
|
+
label: '结算周期',
|
|
1350
|
+
type: 'select',
|
|
1351
|
+
options: [
|
|
1352
|
+
{ value: 'weekly', label: '每周' },
|
|
1353
|
+
{ value: 'monthly', label: '每月' },
|
|
1354
|
+
{ value: 'quarterly', label: '每季度' }
|
|
1355
|
+
]
|
|
1356
|
+
}
|
|
1357
|
+
]
|
|
1358
|
+
},
|
|
1359
|
+
{
|
|
1360
|
+
key: 'email',
|
|
1361
|
+
title: '邮件设置',
|
|
1362
|
+
icon: 'email',
|
|
1363
|
+
description: 'SMTP 服务、发件人和 TLS 开关。',
|
|
1364
|
+
fields: [
|
|
1365
|
+
{ key: 'smtpHost', label: 'SMTP 主机', type: 'text' },
|
|
1366
|
+
{ key: 'smtpPort', label: 'SMTP 端口', type: 'number', min: 1 },
|
|
1367
|
+
{ key: 'senderName', label: '发件人名称', type: 'text' },
|
|
1368
|
+
{ key: 'senderEmail', label: '发件邮箱', type: 'email' },
|
|
1369
|
+
{ key: 'enableTls', label: '启用 TLS', type: 'checkbox' }
|
|
1370
|
+
]
|
|
1371
|
+
},
|
|
1372
|
+
{
|
|
1373
|
+
key: 'telegram',
|
|
1374
|
+
title: 'Telegram 设置',
|
|
1375
|
+
icon: 'send',
|
|
1376
|
+
description: 'Bot Token、Chat ID 和告警推送策略。',
|
|
1377
|
+
fields: [
|
|
1378
|
+
{ key: 'botToken', label: 'Bot Token', type: 'text' },
|
|
1379
|
+
{ key: 'chatId', label: 'Chat ID', type: 'text' },
|
|
1380
|
+
{ key: 'notifyNewUser', label: '新用户注册通知', type: 'checkbox' },
|
|
1381
|
+
{ key: 'notifyLowQuota', label: '低配额告警通知', type: 'checkbox' }
|
|
1382
|
+
]
|
|
1383
|
+
},
|
|
1384
|
+
{
|
|
1385
|
+
key: 'app',
|
|
1386
|
+
title: '应用设置',
|
|
1387
|
+
icon: 'phone_android',
|
|
1388
|
+
description: '客户端名称、版本和下载地址。',
|
|
1389
|
+
fields: [
|
|
1390
|
+
{ key: 'appName', label: '应用名称', type: 'text' },
|
|
1391
|
+
{ key: 'latestVersion', label: '当前版本号', type: 'text' },
|
|
1392
|
+
{ key: 'appDownloadUrl', label: '统一下载地址', type: 'url' },
|
|
1393
|
+
{ key: 'iosUrl', label: 'iOS 下载地址', type: 'url' },
|
|
1394
|
+
{ key: 'androidUrl', label: 'Android 下载地址', type: 'url' }
|
|
1395
|
+
]
|
|
1396
|
+
}
|
|
1397
|
+
],
|
|
1398
|
+
|
|
1399
|
+
mergeSettings(base, incoming) {
|
|
1400
|
+
const next = clone(DEFAULT_SETTINGS);
|
|
1401
|
+
Object.keys(base || {}).forEach((key) => {
|
|
1402
|
+
if (next[key] && typeof base[key] === 'object') {
|
|
1403
|
+
next[key] = { ...next[key], ...base[key] };
|
|
1404
|
+
}
|
|
1405
|
+
});
|
|
1406
|
+
Object.keys(incoming || {}).forEach((key) => {
|
|
1407
|
+
if (next[key] && typeof incoming[key] === 'object') {
|
|
1408
|
+
next[key] = { ...next[key], ...incoming[key] };
|
|
1409
|
+
}
|
|
1410
|
+
});
|
|
1411
|
+
return next;
|
|
1412
|
+
},
|
|
1413
|
+
|
|
1414
|
+
async ensureState() {
|
|
1415
|
+
if (AppState.settings) return;
|
|
1416
|
+
|
|
1417
|
+
let localSettings = {};
|
|
1418
|
+
try {
|
|
1419
|
+
const saved = JSON.parse(localStorage.getItem(this.storageKey) || 'null');
|
|
1420
|
+
if (saved && typeof saved === 'object') {
|
|
1421
|
+
localSettings = saved;
|
|
1422
|
+
}
|
|
1423
|
+
} catch (error) {
|
|
1424
|
+
console.error('Failed to parse settings cache:', error);
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
AppState.settings = this.mergeSettings(localSettings, {});
|
|
1428
|
+
|
|
1429
|
+
try {
|
|
1430
|
+
const result = await API.getSettings();
|
|
1431
|
+
if (result.success && result.data) {
|
|
1432
|
+
AppState.settings = this.mergeSettings(AppState.settings, result.data);
|
|
1433
|
+
this.persistLocal();
|
|
1434
|
+
}
|
|
1435
|
+
} catch (error) {
|
|
1436
|
+
if (error.message === '未授权') {
|
|
1437
|
+
showLoginForm();
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
console.error('Failed to load server settings:', error);
|
|
1441
|
+
UI.showNotification('服务端设置加载失败,已使用本地缓存', 'error');
|
|
1442
|
+
}
|
|
1443
|
+
},
|
|
1444
|
+
|
|
1445
|
+
persistLocal() {
|
|
1446
|
+
localStorage.setItem(this.storageKey, JSON.stringify(AppState.settings));
|
|
1447
|
+
},
|
|
1448
|
+
|
|
1449
|
+
async render() {
|
|
1450
|
+
await this.ensureState();
|
|
1451
|
+
const container = document.getElementById('settings-container');
|
|
1452
|
+
const summary = this.buildSummary();
|
|
1453
|
+
const cards = this.sections.map((section) => this.renderCard(section)).join('');
|
|
1454
|
+
|
|
1455
|
+
container.innerHTML = `
|
|
1456
|
+
<div class="space-y-6">
|
|
1457
|
+
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
1458
|
+
${summary}
|
|
1459
|
+
</div>
|
|
1460
|
+
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
|
1461
|
+
${cards}
|
|
1462
|
+
</div>
|
|
1463
|
+
</div>
|
|
1464
|
+
`;
|
|
1465
|
+
|
|
1466
|
+
container.querySelectorAll('[data-settings-card]').forEach((button) => {
|
|
1467
|
+
button.addEventListener('click', () => this.openSection(button.dataset.settingsCard));
|
|
1468
|
+
});
|
|
1469
|
+
},
|
|
1470
|
+
|
|
1471
|
+
buildSummary() {
|
|
1472
|
+
const settings = AppState.settings;
|
|
1473
|
+
const metrics = [
|
|
1474
|
+
{
|
|
1475
|
+
label: '站点状态',
|
|
1476
|
+
value: settings.site.stopRegister ? '已停注册' : '开放中',
|
|
1477
|
+
sub: settings.site.siteName || '未命名站点'
|
|
1478
|
+
},
|
|
1479
|
+
{
|
|
1480
|
+
label: '安全级别',
|
|
1481
|
+
value: settings.security.auditMode ? '审计开启' : '基础模式',
|
|
1482
|
+
sub: `会话 ${settings.security.sessionTimeout} 小时`
|
|
1483
|
+
},
|
|
1484
|
+
{
|
|
1485
|
+
label: '通知渠道',
|
|
1486
|
+
value: settings.telegram.botToken ? '已配置' : '未配置',
|
|
1487
|
+
sub: settings.email.smtpHost ? '邮件已接通' : '邮件未接通'
|
|
1488
|
+
},
|
|
1489
|
+
{
|
|
1490
|
+
label: '上游 API',
|
|
1491
|
+
value: settings.upstream.configured || settings.upstream.apiKeyConfigured ? '已配置' : '未配置',
|
|
1492
|
+
sub: settings.upstream.baseUrl || '等待填写上游地址'
|
|
1493
|
+
}
|
|
1494
|
+
];
|
|
1495
|
+
|
|
1496
|
+
return metrics.map((item) => `
|
|
1497
|
+
<div class="rounded-2xl border border-outline-variant/20 bg-surface-container-low p-5">
|
|
1498
|
+
<div class="text-xs font-bold uppercase tracking-wider text-on-surface-variant mb-2">${item.label}</div>
|
|
1499
|
+
<div class="text-2xl font-bold text-on-surface">${escapeHtml(item.value)}</div>
|
|
1500
|
+
<div class="text-sm text-on-surface-variant mt-2">${escapeHtml(item.sub)}</div>
|
|
1501
|
+
</div>
|
|
1502
|
+
`).join('');
|
|
1503
|
+
},
|
|
1504
|
+
|
|
1505
|
+
renderCard(section) {
|
|
1506
|
+
const status = this.getSectionStatus(section.key);
|
|
1507
|
+
return `
|
|
1508
|
+
<button
|
|
1509
|
+
data-settings-card="${section.key}"
|
|
1510
|
+
class="text-left rounded-2xl border border-outline-variant/20 bg-surface-container-low p-5 hover:bg-surface-container transition-all hover:shadow-lg">
|
|
1511
|
+
<div class="flex items-start justify-between gap-4">
|
|
1512
|
+
<div class="flex items-start gap-4">
|
|
1513
|
+
<div class="w-12 h-12 rounded-2xl bg-primary-fixed/30 text-primary flex items-center justify-center">
|
|
1514
|
+
<span class="material-symbols-outlined">${section.icon}</span>
|
|
1515
|
+
</div>
|
|
1516
|
+
<div>
|
|
1517
|
+
<div class="text-lg font-bold text-on-surface">${section.title}</div>
|
|
1518
|
+
<div class="text-sm text-on-surface-variant mt-1">${section.description}</div>
|
|
1519
|
+
<div class="text-sm text-on-surface mt-3">${escapeHtml(this.getSectionPreview(section.key))}</div>
|
|
1520
|
+
</div>
|
|
1521
|
+
</div>
|
|
1522
|
+
<div class="flex items-center gap-2">
|
|
1523
|
+
<span class="px-2 py-1 rounded-lg text-xs font-medium ${status.className}">${status.label}</span>
|
|
1524
|
+
<span class="material-symbols-outlined text-on-surface-variant">chevron_right</span>
|
|
1525
|
+
</div>
|
|
1526
|
+
</div>
|
|
1527
|
+
</button>
|
|
1528
|
+
`;
|
|
1529
|
+
},
|
|
1530
|
+
|
|
1531
|
+
getSectionStatus(key) {
|
|
1532
|
+
const settings = AppState.settings[key];
|
|
1533
|
+
const filled = Object.values(settings).filter((value) => {
|
|
1534
|
+
if (typeof value === 'boolean') return value;
|
|
1535
|
+
return String(value || '').trim() !== '';
|
|
1536
|
+
}).length;
|
|
1537
|
+
|
|
1538
|
+
if (filled >= Math.ceil(Object.keys(settings).length / 2)) {
|
|
1539
|
+
return { label: '已配置', className: 'bg-green-50 text-green-700' };
|
|
1540
|
+
}
|
|
1541
|
+
return { label: '待完善', className: 'bg-amber-50 text-amber-700' };
|
|
1542
|
+
},
|
|
1543
|
+
|
|
1544
|
+
getSectionPreview(key) {
|
|
1545
|
+
const settings = AppState.settings[key];
|
|
1546
|
+
const previewMap = {
|
|
1547
|
+
site: `${settings.siteName} · ${settings.siteUrl || '未配置域名'}`,
|
|
1548
|
+
security: `超时 ${settings.sessionTimeout} 小时 · ${settings.auditMode ? '审计开启' : '审计关闭'}`,
|
|
1549
|
+
subscription: `默认 ${settings.defaultQuota} 配额 · ${settings.defaultDuration} 天`,
|
|
1550
|
+
upstream: `${settings.baseUrl || '未配置上游'} · ${settings.apiKeyConfigured ? settings.apiKeyMasked : 'Key 未配置'}`,
|
|
1551
|
+
invite: `${settings.inviteEnabled ? '邀请开启' : '邀请关闭'} · ${settings.commissionRate}% 佣金`,
|
|
1552
|
+
email: settings.smtpHost ? `${settings.smtpHost}:${settings.smtpPort}` : 'SMTP 未配置',
|
|
1553
|
+
telegram: settings.botToken ? 'Bot Token 已配置' : 'Telegram 未配置',
|
|
1554
|
+
app: `${settings.appName} · ${settings.latestVersion}`
|
|
1555
|
+
};
|
|
1556
|
+
|
|
1557
|
+
return previewMap[key] || '未配置';
|
|
1558
|
+
},
|
|
1559
|
+
|
|
1560
|
+
openSection(key) {
|
|
1561
|
+
const section = this.sections.find((item) => item.key === key);
|
|
1562
|
+
if (!section) return;
|
|
1563
|
+
|
|
1564
|
+
const values = AppState.settings[key];
|
|
1565
|
+
const content = `
|
|
1566
|
+
<div class="space-y-4">
|
|
1567
|
+
${section.fields.map((field) => this.renderField(key, field, values[field.key])).join('')}
|
|
1568
|
+
</div>
|
|
1569
|
+
`;
|
|
1570
|
+
|
|
1571
|
+
const actions = `
|
|
1572
|
+
<button data-close-modal class="px-4 py-2 text-on-surface-variant hover:bg-surface-container rounded-xl transition-all">取消</button>
|
|
1573
|
+
${key === 'upstream' ? '<button id="test-upstream-btn" class="px-4 py-2 bg-surface-container text-on-surface rounded-xl font-bold hover:shadow-md transition-all">测试连接</button>' : ''}
|
|
1574
|
+
<button id="save-settings-btn" class="px-4 py-2 bg-primary text-on-primary rounded-xl font-bold hover:shadow-md transition-all">保存设置</button>
|
|
1575
|
+
`;
|
|
1576
|
+
|
|
1577
|
+
UI.showModal(section.title, content, actions);
|
|
1578
|
+
document.getElementById('test-upstream-btn')?.addEventListener('click', () => this.testUpstream());
|
|
1579
|
+
document.getElementById('save-settings-btn')?.addEventListener('click', () => this.saveSection(key));
|
|
1580
|
+
},
|
|
1581
|
+
|
|
1582
|
+
renderField(sectionKey, field, value) {
|
|
1583
|
+
const inputId = `setting-${sectionKey}-${field.key}`;
|
|
1584
|
+
const label = `<label class="block text-sm font-medium text-on-surface mb-2" for="${inputId}">${field.label}</label>`;
|
|
1585
|
+
|
|
1586
|
+
if (field.type === 'checkbox') {
|
|
1587
|
+
return `
|
|
1588
|
+
<label class="flex items-center justify-between rounded-xl bg-surface-container-low px-4 py-3">
|
|
1589
|
+
<span class="text-sm font-medium text-on-surface">${field.label}</span>
|
|
1590
|
+
<input id="${inputId}" type="checkbox" ${value ? 'checked' : ''} class="w-4 h-4 rounded border-outline-variant" />
|
|
1591
|
+
</label>
|
|
1592
|
+
`;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
if (field.type === 'password') {
|
|
1596
|
+
const current = sectionKey === 'upstream' ? AppState.settings.upstream.apiKeyMasked : '';
|
|
1597
|
+
return `
|
|
1598
|
+
<div>
|
|
1599
|
+
${label}
|
|
1600
|
+
<input
|
|
1601
|
+
id="${inputId}"
|
|
1602
|
+
type="password"
|
|
1603
|
+
value=""
|
|
1604
|
+
placeholder="${field.placeholder || current || ''}"
|
|
1605
|
+
class="w-full px-4 py-3 bg-surface-container-low border-none rounded-xl text-sm outline-none"
|
|
1606
|
+
autocomplete="new-password"
|
|
1607
|
+
/>
|
|
1608
|
+
${current ? `<p class="mt-2 text-xs text-on-surface-variant">当前已保存:${escapeHtml(current)};留空则继续使用当前 Key。</p>` : ''}
|
|
1609
|
+
</div>
|
|
1610
|
+
`;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
if (field.type === 'textarea') {
|
|
1614
|
+
return `
|
|
1615
|
+
<div>
|
|
1616
|
+
${label}
|
|
1617
|
+
<textarea
|
|
1618
|
+
id="${inputId}"
|
|
1619
|
+
rows="${field.rows || 4}"
|
|
1620
|
+
placeholder="${field.placeholder || ''}"
|
|
1621
|
+
class="w-full px-4 py-3 bg-surface-container-low border-none rounded-xl text-sm outline-none"
|
|
1622
|
+
>${escapeHtml(value || '')}</textarea>
|
|
1623
|
+
</div>
|
|
1624
|
+
`;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
if (field.type === 'select') {
|
|
1628
|
+
return `
|
|
1629
|
+
<div>
|
|
1630
|
+
${label}
|
|
1631
|
+
<select id="${inputId}" class="w-full px-4 py-3 bg-surface-container-low border-none rounded-xl text-sm outline-none">
|
|
1632
|
+
${field.options.map((option) => `
|
|
1633
|
+
<option value="${option.value}" ${value === option.value ? 'selected' : ''}>${option.label}</option>
|
|
1634
|
+
`).join('')}
|
|
1635
|
+
</select>
|
|
1636
|
+
</div>
|
|
1637
|
+
`;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
return `
|
|
1641
|
+
<div>
|
|
1642
|
+
${label}
|
|
1643
|
+
<input
|
|
1644
|
+
id="${inputId}"
|
|
1645
|
+
type="${field.type || 'text'}"
|
|
1646
|
+
min="${field.min ?? ''}"
|
|
1647
|
+
max="${field.max ?? ''}"
|
|
1648
|
+
value="${escapeHtml(value ?? '')}"
|
|
1649
|
+
placeholder="${field.placeholder || ''}"
|
|
1650
|
+
class="w-full px-4 py-3 bg-surface-container-low border-none rounded-xl text-sm outline-none"
|
|
1651
|
+
/>
|
|
1652
|
+
</div>
|
|
1653
|
+
`;
|
|
1654
|
+
},
|
|
1655
|
+
|
|
1656
|
+
collectSectionValues(key) {
|
|
1657
|
+
const section = this.sections.find((item) => item.key === key);
|
|
1658
|
+
if (!section) return null;
|
|
1659
|
+
|
|
1660
|
+
const nextSection = {};
|
|
1661
|
+
section.fields.forEach((field) => {
|
|
1662
|
+
const element = document.getElementById(`setting-${key}-${field.key}`);
|
|
1663
|
+
if (!element) return;
|
|
1664
|
+
|
|
1665
|
+
if (field.type === 'checkbox') {
|
|
1666
|
+
nextSection[field.key] = element.checked;
|
|
1667
|
+
} else if (field.type === 'number') {
|
|
1668
|
+
nextSection[field.key] = Number(element.value || 0);
|
|
1669
|
+
} else {
|
|
1670
|
+
nextSection[field.key] = element.value.trim();
|
|
1671
|
+
}
|
|
1672
|
+
});
|
|
1673
|
+
|
|
1674
|
+
return nextSection;
|
|
1675
|
+
},
|
|
1676
|
+
|
|
1677
|
+
async testUpstream() {
|
|
1678
|
+
const nextSection = this.collectSectionValues('upstream');
|
|
1679
|
+
if (!nextSection) return;
|
|
1680
|
+
|
|
1681
|
+
const button = document.getElementById('test-upstream-btn');
|
|
1682
|
+
const previousText = button?.textContent || '测试连接';
|
|
1683
|
+
if (button) {
|
|
1684
|
+
button.disabled = true;
|
|
1685
|
+
button.textContent = '测试中...';
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
try {
|
|
1689
|
+
const payload = { ...AppState.settings.upstream, ...nextSection };
|
|
1690
|
+
const result = await API.testUpstream(payload);
|
|
1691
|
+
UI.showNotification(result.message || (result.success ? '上游连接成功' : '上游连接失败'), result.success ? 'success' : 'error');
|
|
1692
|
+
} catch (error) {
|
|
1693
|
+
if (error.message === '未授权') {
|
|
1694
|
+
showLoginForm();
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
console.error(error);
|
|
1698
|
+
UI.showNotification('上游连接测试失败', 'error');
|
|
1699
|
+
} finally {
|
|
1700
|
+
if (button) {
|
|
1701
|
+
button.disabled = false;
|
|
1702
|
+
button.textContent = previousText;
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
},
|
|
1706
|
+
|
|
1707
|
+
async saveSection(key) {
|
|
1708
|
+
const section = this.sections.find((item) => item.key === key);
|
|
1709
|
+
if (!section) return;
|
|
1710
|
+
|
|
1711
|
+
const nextSection = this.collectSectionValues(key);
|
|
1712
|
+
if (!nextSection) return;
|
|
1713
|
+
|
|
1714
|
+
AppState.settings[key] = nextSection;
|
|
1715
|
+
const saveButton = document.getElementById('save-settings-btn');
|
|
1716
|
+
const previousText = saveButton?.textContent || '保存设置';
|
|
1717
|
+
if (saveButton) {
|
|
1718
|
+
saveButton.disabled = true;
|
|
1719
|
+
saveButton.textContent = '保存中...';
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
try {
|
|
1723
|
+
if (key === 'upstream') {
|
|
1724
|
+
const result = await API.updateSettings({ upstream: nextSection });
|
|
1725
|
+
if (!result.success) {
|
|
1726
|
+
UI.showNotification(result.message || '保存失败', 'error');
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
if (result.data?.upstream) {
|
|
1730
|
+
AppState.settings.upstream = { ...AppState.settings.upstream, ...result.data.upstream, apiKey: '' };
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
this.persistLocal();
|
|
1735
|
+
UI.hideModal();
|
|
1736
|
+
await this.render();
|
|
1737
|
+
UI.showNotification(`${section.title}已保存`, 'success');
|
|
1738
|
+
} catch (error) {
|
|
1739
|
+
if (error.message === '未授权') {
|
|
1740
|
+
showLoginForm();
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
console.error(error);
|
|
1744
|
+
UI.showNotification(`${section.title}保存失败`, 'error');
|
|
1745
|
+
} finally {
|
|
1746
|
+
if (saveButton) {
|
|
1747
|
+
saveButton.disabled = false;
|
|
1748
|
+
saveButton.textContent = previousText;
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
};
|
|
1753
|
+
|
|
1754
|
+
function bindShellActions() {
|
|
1755
|
+
if (AppState.bindings.shell) return;
|
|
1756
|
+
AppState.bindings.shell = true;
|
|
1757
|
+
|
|
1758
|
+
document.getElementById('refresh-btn')?.addEventListener('click', async () => {
|
|
1759
|
+
await UI.loadTabData(AppState.currentTab);
|
|
1760
|
+
UI.showNotification('数据已刷新', 'success');
|
|
1761
|
+
});
|
|
1762
|
+
|
|
1763
|
+
document.getElementById('logout-btn')?.addEventListener('click', async () => {
|
|
1764
|
+
if (!confirm('确定要退出登录吗?')) return;
|
|
1765
|
+
await API.logout().catch(() => null);
|
|
1766
|
+
location.reload();
|
|
1767
|
+
});
|
|
1768
|
+
|
|
1769
|
+
document.getElementById('global-search')?.addEventListener('input', debounce((event) => {
|
|
1770
|
+
const keyword = event.target.value.trim();
|
|
1771
|
+
if (AppState.currentPanel === 'users') {
|
|
1772
|
+
document.getElementById('user-search').value = keyword;
|
|
1773
|
+
AppState.filters.users.search = keyword;
|
|
1774
|
+
UserManagement.loadUsers();
|
|
1775
|
+
} else if (AppState.currentPanel === 'codes') {
|
|
1776
|
+
document.getElementById('code-search').value = keyword;
|
|
1777
|
+
AppState.filters.codes.search = keyword;
|
|
1778
|
+
CodeManagement.loadCodes();
|
|
1779
|
+
}
|
|
1780
|
+
}));
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
function showLoginAlert(message) {
|
|
1784
|
+
const alertNode = document.getElementById('login-alert');
|
|
1785
|
+
alertNode.textContent = message;
|
|
1786
|
+
alertNode.classList.remove('hidden');
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
function showLoginForm() {
|
|
1790
|
+
const overlay = document.getElementById('login-overlay');
|
|
1791
|
+
const container = document.getElementById('app-container');
|
|
1792
|
+
overlay.style.display = 'flex';
|
|
1793
|
+
container.style.display = 'none';
|
|
1794
|
+
|
|
1795
|
+
const loginForm = document.getElementById('login-form');
|
|
1796
|
+
const freshForm = loginForm.cloneNode(true);
|
|
1797
|
+
loginForm.parentNode.replaceChild(freshForm, loginForm);
|
|
1798
|
+
document.getElementById('login-alert').classList.add('hidden');
|
|
1799
|
+
|
|
1800
|
+
freshForm.addEventListener('submit', async (event) => {
|
|
1801
|
+
event.preventDefault();
|
|
1802
|
+
const password = document.getElementById('login-password').value.trim();
|
|
1803
|
+
if (!password) {
|
|
1804
|
+
showLoginAlert('请输入密码');
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
const submit = document.getElementById('login-submit');
|
|
1809
|
+
submit.disabled = true;
|
|
1810
|
+
submit.textContent = '登录中...';
|
|
1811
|
+
|
|
1812
|
+
try {
|
|
1813
|
+
const result = await API.login(password);
|
|
1814
|
+
if (!result.success) {
|
|
1815
|
+
showLoginAlert(result.message || '登录失败');
|
|
1816
|
+
submit.disabled = false;
|
|
1817
|
+
submit.textContent = '登录';
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
overlay.style.display = 'none';
|
|
1822
|
+
container.style.display = 'flex';
|
|
1823
|
+
bindShellActions();
|
|
1824
|
+
await UI.switchTab(window.location.hash.slice(1) || AppState.currentTab);
|
|
1825
|
+
} catch (error) {
|
|
1826
|
+
console.error(error);
|
|
1827
|
+
showLoginAlert('登录失败,请重试');
|
|
1828
|
+
submit.disabled = false;
|
|
1829
|
+
submit.textContent = '登录';
|
|
1830
|
+
}
|
|
1831
|
+
});
|
|
1832
|
+
|
|
1833
|
+
document.getElementById('login-password')?.focus();
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
async function init() {
|
|
1837
|
+
UI.initSidebar();
|
|
1838
|
+
UI.initThemeToggle();
|
|
1839
|
+
|
|
1840
|
+
try {
|
|
1841
|
+
const auth = await API.checkAuth();
|
|
1842
|
+
if (!auth.authenticated) {
|
|
1843
|
+
showLoginForm();
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
document.getElementById('login-overlay').style.display = 'none';
|
|
1848
|
+
const appContainer = document.getElementById('app-container');
|
|
1849
|
+
appContainer.style.display = 'flex';
|
|
1850
|
+
bindShellActions();
|
|
1851
|
+
await UI.switchTab(window.location.hash.slice(1) || AppState.currentTab);
|
|
1852
|
+
} catch (error) {
|
|
1853
|
+
console.error(error);
|
|
1854
|
+
showLoginForm();
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
// === 微交互系统 ===
|
|
1859
|
+
|
|
1860
|
+
// 真实波纹效果
|
|
1861
|
+
function createRipple(event) {
|
|
1862
|
+
const button = event.currentTarget;
|
|
1863
|
+
const ripple = document.createElement('span');
|
|
1864
|
+
const rect = button.getBoundingClientRect();
|
|
1865
|
+
const size = Math.max(rect.width, rect.height);
|
|
1866
|
+
const x = event.clientX - rect.left - size / 2;
|
|
1867
|
+
const y = event.clientY - rect.top - size / 2;
|
|
1868
|
+
|
|
1869
|
+
ripple.style.width = ripple.style.height = `${size}px`;
|
|
1870
|
+
ripple.style.left = `${x}px`;
|
|
1871
|
+
ripple.style.top = `${y}px`;
|
|
1872
|
+
ripple.classList.add('ripple-effect');
|
|
1873
|
+
|
|
1874
|
+
button.appendChild(ripple);
|
|
1875
|
+
|
|
1876
|
+
ripple.addEventListener('animationend', () => {
|
|
1877
|
+
ripple.remove();
|
|
1878
|
+
});
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
// 给所有按钮添加波纹效果
|
|
1882
|
+
function initRippleEffects() {
|
|
1883
|
+
document.addEventListener('click', (e) => {
|
|
1884
|
+
const button = e.target.closest('button');
|
|
1885
|
+
if (button && !button.disabled) {
|
|
1886
|
+
createRipple(e);
|
|
1887
|
+
}
|
|
1888
|
+
});
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
// 初始化所有微交互
|
|
1892
|
+
function initMicroInteractions() {
|
|
1893
|
+
initRippleEffects();
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
1897
|
+
init();
|
|
1898
|
+
initMicroInteractions();
|
|
1899
|
+
});
|