ZHIWEI666 commited on
Commit
a4d34e4
·
verified ·
1 Parent(s): ee88a99

Upload 3 files

Browse files
Files changed (3) hide show
  1. 管理界面/app.js +741 -0
  2. 管理界面/index.html +378 -0
  3. 管理界面/style.css +983 -0
管理界面/app.js ADDED
@@ -0,0 +1,741 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ===================================================================
2
+ * RanKing 插件管理控制台 · 应用脚本(原生 JS)
3
+ * 视图切换:登录 → 列表 → 创建/编辑/Key 展示/确认对话框
4
+ * =================================================================== */
5
+
6
+ (function () {
7
+ 'use strict';
8
+
9
+ // -------------------- 配置 --------------------
10
+ // 默认调用同源后端;如需指定接口域名可在 window.RANKING_API_BASE 设置
11
+ const API_BASE = (typeof window !== 'undefined' && window.RANKING_API_BASE) || '';
12
+ const TOKEN_KEY = 'ranking_admin_token';
13
+ const USER_KEY = 'ranking_admin_user';
14
+ const ALL_PERMS = ['verify', 'balance', 'deduct', 'refund'];
15
+
16
+ // -------------------- 状态 --------------------
17
+ const state = {
18
+ token: sessionStorage.getItem(TOKEN_KEY) || '',
19
+ user: safeParse(sessionStorage.getItem(USER_KEY)),
20
+ plugins: [],
21
+ filtered: [],
22
+ searchKeyword: '',
23
+ confirmHandler: null,
24
+ };
25
+
26
+ // -------------------- 工具 --------------------
27
+ function $(selector, root) { return (root || document).querySelector(selector); }
28
+ function $$(selector, root) { return Array.from((root || document).querySelectorAll(selector)); }
29
+
30
+ function safeParse(str) {
31
+ if (!str) return null;
32
+ try { return JSON.parse(str); } catch (e) { return null; }
33
+ }
34
+
35
+ function escapeHtml(value) {
36
+ if (value == null) return '';
37
+ return String(value)
38
+ .replace(/&/g, '&')
39
+ .replace(/</g, '&lt;')
40
+ .replace(/>/g, '&gt;')
41
+ .replace(/"/g, '&quot;')
42
+ .replace(/'/g, '&#39;');
43
+ }
44
+
45
+ function formatDate(value) {
46
+ if (!value) return '—';
47
+ const date = (typeof value === 'number') ? new Date(value * (value < 1e12 ? 1000 : 1)) : new Date(value);
48
+ if (isNaN(date.getTime())) return String(value);
49
+ const pad = (n) => String(n).padStart(2, '0');
50
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
51
+ }
52
+
53
+ function formatNumber(value, fallback) {
54
+ if (value === null || value === undefined || value === '') return fallback || '—';
55
+ const num = Number(value);
56
+ if (isNaN(num)) return String(value);
57
+ return num.toLocaleString('zh-CN');
58
+ }
59
+
60
+ function maskKey(key) {
61
+ if (!key) return '—';
62
+ const str = String(key);
63
+ if (str.includes('*')) return str; // 后端已脱敏
64
+ if (str.length <= 12) return str.slice(0, 4) + '***';
65
+ return str.slice(0, 12) + '***';
66
+ }
67
+
68
+ function setBusy(btn, busy) {
69
+ if (!btn) return;
70
+ if (busy) {
71
+ btn.setAttribute('aria-busy', 'true');
72
+ btn.disabled = true;
73
+ } else {
74
+ btn.removeAttribute('aria-busy');
75
+ btn.disabled = false;
76
+ }
77
+ }
78
+
79
+ function showError(node, msg) {
80
+ if (!node) return;
81
+ if (!msg) { node.hidden = true; node.textContent = ''; return; }
82
+ node.hidden = false;
83
+ node.textContent = msg;
84
+ }
85
+
86
+ // -------------------- API 客户端 --------------------
87
+ async function apiRequest(method, path, body) {
88
+ const headers = { 'Accept': 'application/json' };
89
+ if (state.token) headers['Authorization'] = 'Bearer ' + state.token;
90
+ const init = { method, headers };
91
+ if (body !== undefined && body !== null) {
92
+ headers['Content-Type'] = 'application/json';
93
+ init.body = JSON.stringify(body);
94
+ }
95
+ let response;
96
+ try {
97
+ response = await fetch(API_BASE + path, init);
98
+ } catch (err) {
99
+ throw new ApiError('网络异常,请检查连接后重试', 0, err);
100
+ }
101
+
102
+ // 401 处理:自动跳回登录
103
+ if (response.status === 401) {
104
+ handleUnauthorized();
105
+ throw new ApiError('登录已失效,请重新登录', 401);
106
+ }
107
+
108
+ let payload = null;
109
+ const text = await response.text();
110
+ if (text) {
111
+ try { payload = JSON.parse(text); } catch (e) { payload = { message: text }; }
112
+ }
113
+
114
+ if (!response.ok) {
115
+ const msg = (payload && (payload.message || payload.error || payload.detail)) || `请求失败(${response.status})`;
116
+ throw new ApiError(msg, response.status, payload);
117
+ }
118
+ return payload;
119
+ }
120
+
121
+ class ApiError extends Error {
122
+ constructor(message, status, raw) {
123
+ super(message);
124
+ this.status = status || 0;
125
+ this.raw = raw;
126
+ }
127
+ }
128
+
129
+ // -------------------- 视图切换 --------------------
130
+ function showView(name) {
131
+ ['view-login', 'view-main', 'view-forbidden'].forEach((id) => {
132
+ const el = document.getElementById(id);
133
+ if (el) el.hidden = (id !== name);
134
+ });
135
+ window.scrollTo({ top: 0 });
136
+ }
137
+
138
+ function handleUnauthorized() {
139
+ clearSession();
140
+ showView('view-login');
141
+ toast('登录状态已过期,请重新登录', 'warning');
142
+ }
143
+
144
+ function clearSession() {
145
+ state.token = '';
146
+ state.user = null;
147
+ sessionStorage.removeItem(TOKEN_KEY);
148
+ sessionStorage.removeItem(USER_KEY);
149
+ }
150
+
151
+ function persistSession(token, user) {
152
+ state.token = token || '';
153
+ state.user = user || null;
154
+ if (token) sessionStorage.setItem(TOKEN_KEY, token);
155
+ if (user) sessionStorage.setItem(USER_KEY, JSON.stringify(user));
156
+ }
157
+
158
+ // -------------------- 登录 --------------------
159
+ function bindLogin() {
160
+ const form = $('#login-form');
161
+ const errBox = $('#login-error');
162
+ if (!form) return;
163
+
164
+ form.addEventListener('submit', async (event) => {
165
+ event.preventDefault();
166
+ showError(errBox, '');
167
+
168
+ const account = $('#login-account').value.trim();
169
+ const password = $('#login-password').value;
170
+ if (!account || !password) {
171
+ showError(errBox, '请输入账号和密码');
172
+ return;
173
+ }
174
+
175
+ const submit = $('#login-submit');
176
+ setBusy(submit, true);
177
+ try {
178
+ const data = await apiRequest('POST', '/api/users/login', { account, password });
179
+ if (!data || !data.token) {
180
+ throw new ApiError('登录响应缺少 token', 0, data);
181
+ }
182
+ persistSession(data.token, data.user || null);
183
+
184
+ // 校验是否为管理员:尝试访问插件列表(403 → 非管理员)
185
+ try {
186
+ await loadPlugins();
187
+ syncUserChip();
188
+ showView('view-main');
189
+ toast('登录成功', 'success');
190
+ } catch (err) {
191
+ if (err instanceof ApiError && (err.status === 403 || err.status === 401)) {
192
+ clearSession();
193
+ showView('view-forbidden');
194
+ } else {
195
+ // 其它错误:保留登录态但提示
196
+ syncUserChip();
197
+ showView('view-main');
198
+ toast(err.message || '加载插件列表失败', 'error');
199
+ }
200
+ }
201
+ } catch (err) {
202
+ showError(errBox, err.message || '登录失败');
203
+ } finally {
204
+ setBusy(submit, false);
205
+ }
206
+ });
207
+ }
208
+
209
+ function syncUserChip() {
210
+ const user = state.user || {};
211
+ const name = user.nickname || user.username || user.account || user.name || 'admin';
212
+ const nameEl = $('#user-name');
213
+ const avatarEl = $('#user-avatar');
214
+ if (nameEl) nameEl.textContent = name;
215
+ if (avatarEl) avatarEl.textContent = (name[0] || 'A').toUpperCase();
216
+ }
217
+
218
+ // -------------------- 插件列表 --------------------
219
+ function setListState(stateName, message) {
220
+ $('#list-loading').hidden = stateName !== 'loading';
221
+ $('#list-empty').hidden = stateName !== 'empty';
222
+ $('#list-error').hidden = stateName !== 'error';
223
+ $('#plugin-grid').hidden = stateName !== 'data';
224
+ if (stateName === 'error' && message) {
225
+ $('#list-error-msg').textContent = message;
226
+ }
227
+ }
228
+
229
+ async function loadPlugins() {
230
+ setListState('loading');
231
+ const data = await apiRequest('GET', '/api/admin/plugins');
232
+ const list = Array.isArray(data) ? data
233
+ : Array.isArray(data && data.plugins) ? data.plugins
234
+ : Array.isArray(data && data.items) ? data.items
235
+ : Array.isArray(data && data.data) ? data.data
236
+ : [];
237
+ state.plugins = list;
238
+ applyFilter();
239
+ }
240
+
241
+ function applyFilter() {
242
+ const kw = state.searchKeyword.trim().toLowerCase();
243
+ state.filtered = !kw ? state.plugins.slice() : state.plugins.filter((p) => {
244
+ const id = String(p.plugin_id || p.id || '').toLowerCase();
245
+ const name = String(p.name || '').toLowerCase();
246
+ return id.includes(kw) || name.includes(kw);
247
+ });
248
+ renderList();
249
+ renderStats();
250
+ }
251
+
252
+ function renderStats() {
253
+ const total = state.plugins.length;
254
+ const enabled = state.plugins.filter((p) => !!p.enabled).length;
255
+ $('#stat-total').textContent = total;
256
+ $('#stat-enabled').textContent = enabled;
257
+ $('#stat-disabled').textContent = total - enabled;
258
+ }
259
+
260
+ function renderList() {
261
+ const grid = $('#plugin-grid');
262
+ const tpl = $('#tpl-plugin-card');
263
+ if (!grid || !tpl) return;
264
+
265
+ if (!state.plugins.length) {
266
+ setListState('empty');
267
+ return;
268
+ }
269
+ setListState('data');
270
+ grid.innerHTML = '';
271
+
272
+ if (!state.filtered.length) {
273
+ grid.innerHTML = '<div class="state-block" style="grid-column: 1/-1;">' +
274
+ '<div class="empty-mark">∅</div><h3>未匹配到插件</h3><p>尝试调整搜索关键字。</p></div>';
275
+ return;
276
+ }
277
+
278
+ const frag = document.createDocumentFragment();
279
+ state.filtered.forEach((plugin) => {
280
+ const node = tpl.content.firstElementChild.cloneNode(true);
281
+ const id = plugin.plugin_id || plugin.id || '';
282
+ const name = plugin.name || id;
283
+
284
+ $('.js-name', node).textContent = name;
285
+ $('.js-id', node).textContent = id;
286
+ $('.js-desc', node).textContent = plugin.description || '';
287
+ $('.js-key', node).textContent = plugin.plugin_key || plugin.key || maskKey(id);
288
+
289
+ const badge = $('.js-status', node);
290
+ if (plugin.enabled) {
291
+ badge.textContent = '启用中';
292
+ badge.classList.add('is-on');
293
+ } else {
294
+ badge.textContent = '已禁用';
295
+ badge.classList.add('is-off');
296
+ }
297
+
298
+ $('.js-rate', node).textContent = formatNumber(plugin.rate_limit, '—');
299
+ $('.js-tx', node).textContent = formatNumber(plugin.max_deduct_per_tx, '—');
300
+ $('.js-day', node).textContent = formatNumber(plugin.max_deduct_per_day, '—');
301
+ $('.js-created', node).textContent = formatDate(plugin.created_at || plugin.create_time || plugin.createdAt);
302
+
303
+ const permsEl = $('.js-perms', node);
304
+ const perms = Array.isArray(plugin.permissions) ? plugin.permissions : [];
305
+ if (perms.length) {
306
+ permsEl.innerHTML = perms.map((p) => `<span class="perm-chip">${escapeHtml(p)}</span>`).join('');
307
+ } else {
308
+ permsEl.textContent = '—';
309
+ }
310
+
311
+ const toggleBtn = $('.js-toggle', node);
312
+ toggleBtn.textContent = plugin.enabled ? '禁用' : '启用';
313
+
314
+ // 操作绑定
315
+ $$('button[data-act]', node).forEach((btn) => {
316
+ btn.addEventListener('click', () => onCardAction(btn.dataset.act, plugin, btn));
317
+ });
318
+
319
+ frag.appendChild(node);
320
+ });
321
+ grid.appendChild(frag);
322
+ }
323
+
324
+ function onCardAction(act, plugin, btn) {
325
+ const id = plugin.plugin_id || plugin.id;
326
+ if (act === 'edit') return openEditModal(plugin);
327
+ if (act === 'rotate') return rotateKey(plugin, btn);
328
+ if (act === 'toggle') return togglePlugin(plugin, btn);
329
+ if (act === 'delete') return deletePlugin(plugin, btn);
330
+ }
331
+
332
+ // -------------------- 模态框 --------------------
333
+ function openModal(id) {
334
+ const el = document.getElementById(id);
335
+ if (!el) return;
336
+ el.hidden = false;
337
+ document.body.style.overflow = 'hidden';
338
+ }
339
+ function closeModal(id) {
340
+ const el = document.getElementById(id);
341
+ if (!el) return;
342
+ el.hidden = true;
343
+ if (!$$('.modal-mask').some((m) => !m.hidden)) {
344
+ document.body.style.overflow = '';
345
+ }
346
+ }
347
+
348
+ function bindModalCommon() {
349
+ // data-close 关闭、点击遮罩关闭、Esc 关闭
350
+ document.addEventListener('click', (e) => {
351
+ const closer = e.target.closest('[data-close]');
352
+ if (closer) {
353
+ closeModal(closer.dataset.close);
354
+ return;
355
+ }
356
+ if (e.target.classList && e.target.classList.contains('modal-mask')) {
357
+ closeModal(e.target.id);
358
+ }
359
+ });
360
+ document.addEventListener('keydown', (e) => {
361
+ if (e.key === 'Escape') {
362
+ const open = $$('.modal-mask').filter((m) => !m.hidden).pop();
363
+ if (open) closeModal(open.id);
364
+ }
365
+ });
366
+ }
367
+
368
+ // -------------------- 创建插件 --------------------
369
+ function openCreateModal() {
370
+ const form = $('#form-create');
371
+ form.reset();
372
+ // 重置默认权限
373
+ $$('#form-create input[name="permissions"]').forEach((cb) => {
374
+ cb.checked = (cb.value !== 'refund');
375
+ });
376
+ showError($('#form-create-error'), '');
377
+ openModal('modal-create');
378
+ setTimeout(() => { const el = form.querySelector('[name="plugin_id"]'); if (el) el.focus(); }, 50);
379
+ }
380
+
381
+ function bindCreate() {
382
+ $('#btn-create').addEventListener('click', openCreateModal);
383
+
384
+ const form = $('#form-create');
385
+ form.addEventListener('submit', async (event) => {
386
+ event.preventDefault();
387
+ const errBox = $('#form-create-error');
388
+ showError(errBox, '');
389
+
390
+ const fd = new FormData(form);
391
+ const pluginId = String(fd.get('plugin_id') || '').trim();
392
+ const name = String(fd.get('name') || '').trim();
393
+ if (!/^[A-Za-z0-9_]{3,50}$/.test(pluginId)) {
394
+ showError(errBox, 'plugin_id 仅允许英文/数字/下划线,长度 3-50。');
395
+ return;
396
+ }
397
+ if (!name) {
398
+ showError(errBox, '请填写插件名称。');
399
+ return;
400
+ }
401
+
402
+ const payload = {
403
+ plugin_id: pluginId,
404
+ name: name,
405
+ description: String(fd.get('description') || '').trim(),
406
+ rate_limit: toIntOr(fd.get('rate_limit'), 100),
407
+ max_deduct_per_tx: toIntOr(fd.get('max_deduct_per_tx'), 100),
408
+ max_deduct_per_day: toIntOr(fd.get('max_deduct_per_day'), 1000),
409
+ permissions: fd.getAll('permissions'),
410
+ };
411
+
412
+ const submit = $('#btn-create-submit');
413
+ setBusy(submit, true);
414
+ try {
415
+ const data = await apiRequest('POST', '/api/admin/plugins', payload);
416
+ closeModal('modal-create');
417
+ const fullKey = data && (data.plugin_key || data.key);
418
+ if (fullKey) {
419
+ showKeyModal(fullKey, '请妥善保存此 Key,关闭后将无法再次查看。');
420
+ } else {
421
+ toast('插件已创建', 'success');
422
+ }
423
+ await loadPlugins();
424
+ } catch (err) {
425
+ showError(errBox, err.message || '创建失败');
426
+ } finally {
427
+ setBusy(submit, false);
428
+ }
429
+ });
430
+ }
431
+
432
+ function toIntOr(value, fallback) {
433
+ const num = parseInt(value, 10);
434
+ return isNaN(num) ? fallback : num;
435
+ }
436
+
437
+ // -------------------- 编辑插件 --------------------
438
+ function openEditModal(plugin) {
439
+ const form = $('#form-edit');
440
+ form.reset();
441
+ showError($('#form-edit-error'), '');
442
+
443
+ const id = plugin.plugin_id || plugin.id;
444
+ form.dataset.pluginId = id;
445
+
446
+ form.querySelector('[name="plugin_id"]').value = id;
447
+ form.querySelector('[name="name"]').value = plugin.name || '';
448
+ form.querySelector('[name="description"]').value = plugin.description || '';
449
+ form.querySelector('[name="rate_limit"]').value = plugin.rate_limit ?? 100;
450
+ form.querySelector('[name="max_deduct_per_tx"]').value = plugin.max_deduct_per_tx ?? 100;
451
+ form.querySelector('[name="max_deduct_per_day"]').value = plugin.max_deduct_per_day ?? 1000;
452
+
453
+ const perms = Array.isArray(plugin.permissions) ? plugin.permissions : [];
454
+ $$('#form-edit input[name="permissions"]').forEach((cb) => {
455
+ cb.checked = perms.includes(cb.value);
456
+ });
457
+
458
+ $('#edit-key-display').textContent = plugin.plugin_key || maskKey(id);
459
+
460
+ openModal('modal-edit');
461
+ }
462
+
463
+ function bindEdit() {
464
+ const form = $('#form-edit');
465
+ form.addEventListener('submit', async (event) => {
466
+ event.preventDefault();
467
+ const errBox = $('#form-edit-error');
468
+ showError(errBox, '');
469
+
470
+ const id = form.dataset.pluginId;
471
+ if (!id) { showError(errBox, '插件标识缺失'); return; }
472
+
473
+ const fd = new FormData(form);
474
+ const name = String(fd.get('name') || '').trim();
475
+ if (!name) { showError(errBox, '请填写插件名称。'); return; }
476
+
477
+ const payload = {
478
+ name: name,
479
+ description: String(fd.get('description') || '').trim(),
480
+ rate_limit: toIntOr(fd.get('rate_limit'), 100),
481
+ max_deduct_per_tx: toIntOr(fd.get('max_deduct_per_tx'), 100),
482
+ max_deduct_per_day: toIntOr(fd.get('max_deduct_per_day'), 1000),
483
+ permissions: fd.getAll('permissions'),
484
+ };
485
+
486
+ const submit = $('#btn-edit-submit');
487
+ setBusy(submit, true);
488
+ try {
489
+ await apiRequest('PUT', '/api/admin/plugins/' + encodeURIComponent(id), payload);
490
+ closeModal('modal-edit');
491
+ toast('修改已保存', 'success');
492
+ await loadPlugins();
493
+ } catch (err) {
494
+ showError(errBox, err.message || '保存失败');
495
+ } finally {
496
+ setBusy(submit, false);
497
+ }
498
+ });
499
+ }
500
+
501
+ // -------------------- 轮换 / 切换 / 删除 --------------------
502
+ function rotateKey(plugin, btn) {
503
+ const id = plugin.plugin_id || plugin.id;
504
+ confirmAction({
505
+ title: '轮换 Key',
506
+ message: `轮换后旧 Key 立即失效,确定继续?\n插件:${plugin.name || id}`,
507
+ okText: '确认轮换',
508
+ danger: false,
509
+ onConfirm: async () => {
510
+ const data = await apiRequest('POST', '/api/admin/plugins/' + encodeURIComponent(id) + '/rotate-key');
511
+ const fullKey = data && (data.plugin_key || data.key);
512
+ if (fullKey) {
513
+ showKeyModal(fullKey, '新 Key 已生成,请妥善保存。旧 Key 已失效。');
514
+ } else {
515
+ toast('Key 已轮换', 'success');
516
+ }
517
+ await loadPlugins();
518
+ },
519
+ });
520
+ }
521
+
522
+ function togglePlugin(plugin, btn) {
523
+ const id = plugin.plugin_id || plugin.id;
524
+ setBusy(btn, true);
525
+ apiRequest('POST', '/api/admin/plugins/' + encodeURIComponent(id) + '/toggle')
526
+ .then(async () => {
527
+ toast(plugin.enabled ? '插件已禁用' : '插件已启用', 'success');
528
+ await loadPlugins();
529
+ })
530
+ .catch((err) => {
531
+ toast(err.message || '切换状态失败', 'error');
532
+ })
533
+ .finally(() => setBusy(btn, false));
534
+ }
535
+
536
+ function deletePlugin(plugin, btn) {
537
+ const id = plugin.plugin_id || plugin.id;
538
+ confirmAction({
539
+ title: '删除插件',
540
+ message: `确定删除插件「${plugin.name || id}」?此操作不可逆。`,
541
+ okText: '确认删除',
542
+ danger: true,
543
+ onConfirm: async () => {
544
+ await apiRequest('DELETE', '/api/admin/plugins/' + encodeURIComponent(id));
545
+ toast('插件已删除', 'success');
546
+ await loadPlugins();
547
+ },
548
+ });
549
+ }
550
+
551
+ // -------------------- 通用确认对话框 --------------------
552
+ function confirmAction({ title, message, okText, danger, onConfirm }) {
553
+ $('#modal-confirm-title').textContent = title || '请确认';
554
+ $('#modal-confirm-msg').textContent = message || '确认执行此操作?';
555
+ const okBtn = $('#btn-confirm-ok');
556
+ $('.btn-label', okBtn).textContent = okText || '确定';
557
+ okBtn.classList.toggle('btn-danger', !!danger);
558
+ okBtn.classList.toggle('btn-primary', !danger);
559
+ state.confirmHandler = onConfirm;
560
+ openModal('modal-confirm');
561
+ }
562
+
563
+ function bindConfirm() {
564
+ const okBtn = $('#btn-confirm-ok');
565
+ okBtn.addEventListener('click', async () => {
566
+ const handler = state.confirmHandler;
567
+ if (!handler) { closeModal('modal-confirm'); return; }
568
+ setBusy(okBtn, true);
569
+ try {
570
+ await handler();
571
+ closeModal('modal-confirm');
572
+ } catch (err) {
573
+ toast(err.message || '操作失败', 'error');
574
+ } finally {
575
+ setBusy(okBtn, false);
576
+ state.confirmHandler = null;
577
+ }
578
+ });
579
+ }
580
+
581
+ // -------------------- Key 展示模态框 --------------------
582
+ function showKeyModal(key, hint) {
583
+ $('#key-display').textContent = key;
584
+ // 重置复制按钮状态
585
+ const copyBtn = $('#btn-copy-key');
586
+ copyBtn.classList.remove('is-copied');
587
+ $('.btn-label', copyBtn).textContent = '复制 Key';
588
+ // 自定义提示文案
589
+ const warn = $('.key-warn span');
590
+ if (warn && hint) warn.textContent = hint;
591
+ openModal('modal-key');
592
+ }
593
+
594
+ function bindKeyModal() {
595
+ $('#btn-copy-key').addEventListener('click', async () => {
596
+ const key = $('#key-display').textContent;
597
+ const ok = await copyToClipboard(key);
598
+ const btn = $('#btn-copy-key');
599
+ const label = $('.btn-label', btn);
600
+ if (ok) {
601
+ btn.classList.add('is-copied');
602
+ label.textContent = '已复制';
603
+ toast('Key 已复制到剪贴板', 'success');
604
+ setTimeout(() => {
605
+ btn.classList.remove('is-copied');
606
+ label.textContent = '复制 Key';
607
+ }, 2000);
608
+ } else {
609
+ toast('复制失败,请手动选择文本复制', 'error');
610
+ }
611
+ });
612
+ }
613
+
614
+ async function copyToClipboard(text) {
615
+ try {
616
+ if (navigator.clipboard && window.isSecureContext) {
617
+ await navigator.clipboard.writeText(text);
618
+ return true;
619
+ }
620
+ } catch (e) { /* fallthrough */ }
621
+ // 兼容回退
622
+ try {
623
+ const ta = document.createElement('textarea');
624
+ ta.value = text;
625
+ ta.style.position = 'fixed';
626
+ ta.style.opacity = '0';
627
+ document.body.appendChild(ta);
628
+ ta.select();
629
+ const ok = document.execCommand('copy');
630
+ document.body.removeChild(ta);
631
+ return ok;
632
+ } catch (e) {
633
+ return false;
634
+ }
635
+ }
636
+
637
+ // -------------------- Toast --------------------
638
+ function toast(message, type) {
639
+ const container = $('#toast-container');
640
+ if (!container) return;
641
+ const node = document.createElement('div');
642
+ node.className = 'toast toast-' + (type || 'info');
643
+ const icon = type === 'success' ? '✓' : type === 'error' ? '!' : type === 'warning' ? '!' : 'i';
644
+ node.innerHTML = `<span class="toast-icon">${icon}</span><span class="toast-msg"></span>`;
645
+ node.querySelector('.toast-msg').textContent = message;
646
+ container.appendChild(node);
647
+ setTimeout(() => {
648
+ node.classList.add('is-leaving');
649
+ setTimeout(() => { if (node.parentNode) node.parentNode.removeChild(node); }, 220);
650
+ }, type === 'error' ? 4200 : 2800);
651
+ }
652
+
653
+ // -------------------- 主视图绑定 --------------------
654
+ function bindMain() {
655
+ $('#btn-logout').addEventListener('click', () => {
656
+ confirmAction({
657
+ title: '退出登录',
658
+ message: '确定要退出当前登录吗?',
659
+ okText: '退出',
660
+ danger: false,
661
+ onConfirm: async () => {
662
+ clearSession();
663
+ showView('view-login');
664
+ toast('已退出登录', 'success');
665
+ },
666
+ });
667
+ });
668
+
669
+ $('#btn-refresh').addEventListener('click', async () => {
670
+ const btn = $('#btn-refresh');
671
+ setBusy(btn, true);
672
+ try {
673
+ await loadPlugins();
674
+ toast('已刷新', 'success');
675
+ } catch (err) {
676
+ if (!(err instanceof ApiError && err.status === 401)) {
677
+ setListState('error', err.message || '加载失败');
678
+ toast(err.message || '加载失败', 'error');
679
+ }
680
+ } finally {
681
+ setBusy(btn, false);
682
+ }
683
+ });
684
+
685
+ $('#btn-retry').addEventListener('click', async () => {
686
+ try { await loadPlugins(); }
687
+ catch (err) {
688
+ if (!(err instanceof ApiError && err.status === 401)) {
689
+ setListState('error', err.message || '加载失败');
690
+ }
691
+ }
692
+ });
693
+
694
+ const search = $('#search-input');
695
+ let timer = null;
696
+ search.addEventListener('input', () => {
697
+ state.searchKeyword = search.value;
698
+ clearTimeout(timer);
699
+ timer = setTimeout(applyFilter, 120);
700
+ });
701
+
702
+ $('#btn-relogin').addEventListener('click', () => {
703
+ clearSession();
704
+ showView('view-login');
705
+ });
706
+ }
707
+
708
+ // -------------------- 启动 --------------------
709
+ function bootstrap() {
710
+ bindLogin();
711
+ bindMain();
712
+ bindCreate();
713
+ bindEdit();
714
+ bindConfirm();
715
+ bindKeyModal();
716
+ bindModalCommon();
717
+
718
+ if (state.token) {
719
+ // 已有 token,尝试直接进入主界面
720
+ syncUserChip();
721
+ showView('view-main');
722
+ loadPlugins().catch((err) => {
723
+ if (err instanceof ApiError && (err.status === 401)) return; // 已自动跳转
724
+ if (err instanceof ApiError && err.status === 403) {
725
+ clearSession();
726
+ showView('view-forbidden');
727
+ return;
728
+ }
729
+ setListState('error', err.message || '加载失败');
730
+ });
731
+ } else {
732
+ showView('view-login');
733
+ }
734
+ }
735
+
736
+ if (document.readyState === 'loading') {
737
+ document.addEventListener('DOMContentLoaded', bootstrap);
738
+ } else {
739
+ bootstrap();
740
+ }
741
+ })();
管理界面/index.html ADDED
@@ -0,0 +1,378 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
6
+ <meta name="theme-color" content="#4F46E5">
7
+ <title>RanKing · 插件管理控制台</title>
8
+ <link rel="stylesheet" href="./style.css">
9
+ </head>
10
+ <body>
11
+ <!-- ============ 登录视图 ============ -->
12
+ <section id="view-login" class="view view-login" hidden>
13
+ <div class="login-shell">
14
+ <div class="login-aside">
15
+ <div class="brand">
16
+ <div class="brand-mark" aria-hidden="true">
17
+ <span class="brand-mark-dot"></span>
18
+ <span class="brand-mark-bar"></span>
19
+ </div>
20
+ <div class="brand-text">
21
+ <div class="brand-title">RanKing</div>
22
+ <div class="brand-sub">Plugin Admin Console</div>
23
+ </div>
24
+ </div>
25
+ <div class="login-headline">
26
+ <h1>第三方插件 <br><em>统一管理控制台</em></h1>
27
+ <p>使用 RanKing 管理员账号登录,集中管理所有第三方插件的访问凭证、权限与配额。</p>
28
+ </div>
29
+ <ul class="login-meta">
30
+ <li><span class="dot dot-success"></span>Plugin Key 动态轮换</li>
31
+ <li><span class="dot dot-warning"></span>限额与权限精细化控制</li>
32
+ <li><span class="dot dot-primary"></span>双主题 · 响应式 · 原生实现</li>
33
+ </ul>
34
+ </div>
35
+
36
+ <form id="login-form" class="login-card" novalidate>
37
+ <header class="login-card-header">
38
+ <h2>管理员登录</h2>
39
+ <p>请使用具备管理员权限的 RanKing 账号</p>
40
+ </header>
41
+
42
+ <label class="field">
43
+ <span class="field-label">账号</span>
44
+ <input type="text" id="login-account" name="account"
45
+ autocomplete="username" required
46
+ placeholder="用户名 / 邮箱 / 手机号">
47
+ </label>
48
+
49
+ <label class="field">
50
+ <span class="field-label">密码</span>
51
+ <input type="password" id="login-password" name="password"
52
+ autocomplete="current-password" required
53
+ placeholder="请输入密码">
54
+ </label>
55
+
56
+ <div id="login-error" class="form-error" hidden></div>
57
+
58
+ <button type="submit" id="login-submit" class="btn btn-primary btn-block">
59
+ <span class="btn-label">登 录</span>
60
+ <span class="btn-spinner" aria-hidden="true"></span>
61
+ </button>
62
+
63
+ <footer class="login-card-footer">
64
+ <span>仅管理员可访问 · Token 仅保存于当前会话</span>
65
+ </footer>
66
+ </form>
67
+ </div>
68
+ </section>
69
+
70
+ <!-- ============ 主视图(插件列表) ============ -->
71
+ <section id="view-main" class="view view-main" hidden>
72
+ <header class="topbar">
73
+ <div class="topbar-left">
74
+ <div class="brand brand-compact">
75
+ <div class="brand-mark" aria-hidden="true">
76
+ <span class="brand-mark-dot"></span>
77
+ <span class="brand-mark-bar"></span>
78
+ </div>
79
+ <div class="brand-text">
80
+ <div class="brand-title">RanKing</div>
81
+ <div class="brand-sub">Plugin Admin</div>
82
+ </div>
83
+ </div>
84
+ <nav class="crumbs" aria-label="位置">
85
+ <span>控制台</span>
86
+ <span class="crumbs-sep">/</span>
87
+ <span class="crumbs-current">第三方插件</span>
88
+ </nav>
89
+ </div>
90
+ <div class="topbar-right">
91
+ <div class="user-chip" id="user-chip">
92
+ <span class="user-avatar" id="user-avatar">A</span>
93
+ <span class="user-name" id="user-name">admin</span>
94
+ </div>
95
+ <button type="button" id="btn-logout" class="btn btn-ghost">退出登录</button>
96
+ </div>
97
+ </header>
98
+
99
+ <main class="main-shell">
100
+ <section class="page-head">
101
+ <div class="page-head-text">
102
+ <h1>第三方插件</h1>
103
+ <p class="page-sub">管理 plugin_key、限流配额、权限与启用状态。</p>
104
+ </div>
105
+ <div class="page-head-actions">
106
+ <div class="search-wrap">
107
+ <input type="search" id="search-input" placeholder="搜索 plugin_id 或 名称…">
108
+ </div>
109
+ <button type="button" id="btn-refresh" class="btn btn-secondary" title="刷新">
110
+ <span class="btn-label">刷新</span>
111
+ </button>
112
+ <button type="button" id="btn-create" class="btn btn-primary">
113
+ <span class="btn-icon" aria-hidden="true">+</span>
114
+ <span class="btn-label">创建新插件</span>
115
+ </button>
116
+ </div>
117
+ </section>
118
+
119
+ <section class="stat-row" id="stat-row">
120
+ <div class="stat-card">
121
+ <div class="stat-label">插件总数</div>
122
+ <div class="stat-value" id="stat-total">—</div>
123
+ </div>
124
+ <div class="stat-card">
125
+ <div class="stat-label">已启用</div>
126
+ <div class="stat-value text-success" id="stat-enabled">—</div>
127
+ </div>
128
+ <div class="stat-card">
129
+ <div class="stat-label">已禁用</div>
130
+ <div class="stat-value text-warning" id="stat-disabled">—</div>
131
+ </div>
132
+ </section>
133
+
134
+ <section class="list-shell">
135
+ <div id="list-loading" class="state-block" hidden>
136
+ <div class="spinner-lg" aria-hidden="true"></div>
137
+ <p>正在加载插件列表…</p>
138
+ </div>
139
+ <div id="list-empty" class="state-block" hidden>
140
+ <div class="empty-mark">∅</div>
141
+ <h3>暂无插件</h3>
142
+ <p>点击右上角「创建新插件」开始接入第三方应用。</p>
143
+ </div>
144
+ <div id="list-error" class="state-block state-error" hidden>
145
+ <div class="empty-mark">!</div>
146
+ <h3>加载失败</h3>
147
+ <p id="list-error-msg">请稍后重试。</p>
148
+ <button type="button" class="btn btn-secondary" id="btn-retry">重新加载</button>
149
+ </div>
150
+ <div id="plugin-grid" class="plugin-grid"></div>
151
+ </section>
152
+ </main>
153
+ </section>
154
+
155
+ <!-- ============ 拒绝访问视图 ============ -->
156
+ <section id="view-forbidden" class="view view-forbidden" hidden>
157
+ <div class="forbidden-card">
158
+ <div class="forbidden-mark">⛔</div>
159
+ <h2>您不是管理员,无权访问</h2>
160
+ <p>当前登录账号没有插件管理权限。请使用具备管理员权限的账号重新登录。</p>
161
+ <button type="button" id="btn-relogin" class="btn btn-primary">返回登录</button>
162
+ </div>
163
+ </section>
164
+
165
+ <!-- ============ 创建插件 模态框 ============ -->
166
+ <div id="modal-create" class="modal-mask" hidden>
167
+ <div class="modal modal-lg" role="dialog" aria-modal="true" aria-labelledby="modal-create-title">
168
+ <header class="modal-header">
169
+ <h3 id="modal-create-title">创建新插件</h3>
170
+ <button type="button" class="modal-close" data-close="modal-create" aria-label="关闭">×</button>
171
+ </header>
172
+ <form id="form-create" class="modal-body" novalidate>
173
+ <div class="grid-2">
174
+ <label class="field">
175
+ <span class="field-label">plugin_id <span class="required">*</span></span>
176
+ <input type="text" name="plugin_id" required pattern="^[A-Za-z0-9_]{3,50}$"
177
+ placeholder="英文/数字/下划线,3-50 字符">
178
+ <span class="field-hint">用于标识插件,创建后不可修改。</span>
179
+ </label>
180
+ <label class="field">
181
+ <span class="field-label">名称 <span class="required">*</span></span>
182
+ <input type="text" name="name" required placeholder="例如:示例插件">
183
+ </label>
184
+ </div>
185
+
186
+ <label class="field">
187
+ <span class="field-label">描述</span>
188
+ <textarea name="description" rows="2" placeholder="对该插件的简要说明(可选)"></textarea>
189
+ </label>
190
+
191
+ <div class="grid-3">
192
+ <label class="field">
193
+ <span class="field-label">每分钟限流</span>
194
+ <input type="number" name="rate_limit" min="1" value="100">
195
+ </label>
196
+ <label class="field">
197
+ <span class="field-label">单笔扣款上限</span>
198
+ <input type="number" name="max_deduct_per_tx" min="0" value="100">
199
+ </label>
200
+ <label class="field">
201
+ <span class="field-label">日扣款上限</span>
202
+ <input type="number" name="max_deduct_per_day" min="0" value="1000">
203
+ </label>
204
+ </div>
205
+
206
+ <fieldset class="field perm-field">
207
+ <legend class="field-label">权限</legend>
208
+ <div class="perm-grid">
209
+ <label class="perm-item"><input type="checkbox" name="permissions" value="verify" checked><span>verify</span></label>
210
+ <label class="perm-item"><input type="checkbox" name="permissions" value="balance" checked><span>balance</span></label>
211
+ <label class="perm-item"><input type="checkbox" name="permissions" value="deduct" checked><span>deduct</span></label>
212
+ <label class="perm-item"><input type="checkbox" name="permissions" value="refund"><span>refund</span></label>
213
+ </div>
214
+ </fieldset>
215
+
216
+ <div class="form-error" id="form-create-error" hidden></div>
217
+ </form>
218
+ <footer class="modal-footer">
219
+ <button type="button" class="btn btn-ghost" data-close="modal-create">取消</button>
220
+ <button type="submit" form="form-create" id="btn-create-submit" class="btn btn-primary">
221
+ <span class="btn-label">创建插件</span>
222
+ <span class="btn-spinner" aria-hidden="true"></span>
223
+ </button>
224
+ </footer>
225
+ </div>
226
+ </div>
227
+
228
+ <!-- ============ 编辑插件 模态框 ============ -->
229
+ <div id="modal-edit" class="modal-mask" hidden>
230
+ <div class="modal modal-lg" role="dialog" aria-modal="true" aria-labelledby="modal-edit-title">
231
+ <header class="modal-header">
232
+ <h3 id="modal-edit-title">编辑插件</h3>
233
+ <button type="button" class="modal-close" data-close="modal-edit" aria-label="关闭">×</button>
234
+ </header>
235
+ <form id="form-edit" class="modal-body" novalidate>
236
+ <div class="grid-2">
237
+ <label class="field">
238
+ <span class="field-label">plugin_id</span>
239
+ <input type="text" name="plugin_id" disabled>
240
+ <span class="field-hint">不可修改。</span>
241
+ </label>
242
+ <label class="field">
243
+ <span class="field-label">名称 <span class="required">*</span></span>
244
+ <input type="text" name="name" required>
245
+ </label>
246
+ </div>
247
+
248
+ <label class="field">
249
+ <span class="field-label">描述</span>
250
+ <textarea name="description" rows="2"></textarea>
251
+ </label>
252
+
253
+ <div class="grid-3">
254
+ <label class="field">
255
+ <span class="field-label">每分钟限流</span>
256
+ <input type="number" name="rate_limit" min="1">
257
+ </label>
258
+ <label class="field">
259
+ <span class="field-label">单笔扣款上限</span>
260
+ <input type="number" name="max_deduct_per_tx" min="0">
261
+ </label>
262
+ <label class="field">
263
+ <span class="field-label">日扣款上限</span>
264
+ <input type="number" name="max_deduct_per_day" min="0">
265
+ </label>
266
+ </div>
267
+
268
+ <fieldset class="field perm-field">
269
+ <legend class="field-label">权限</legend>
270
+ <div class="perm-grid">
271
+ <label class="perm-item"><input type="checkbox" name="permissions" value="verify"><span>verify</span></label>
272
+ <label class="perm-item"><input type="checkbox" name="permissions" value="balance"><span>balance</span></label>
273
+ <label class="perm-item"><input type="checkbox" name="permissions" value="deduct"><span>deduct</span></label>
274
+ <label class="perm-item"><input type="checkbox" name="permissions" value="refund"><span>refund</span></label>
275
+ </div>
276
+ </fieldset>
277
+
278
+ <div class="field">
279
+ <span class="field-label">plugin_key</span>
280
+ <div class="static-key" id="edit-key-display">—</div>
281
+ <span class="field-hint">出于安全考虑,完整 Key 已隐藏。如需更换请使用「轮换 Key」。</span>
282
+ </div>
283
+
284
+ <div class="form-error" id="form-edit-error" hidden></div>
285
+ </form>
286
+ <footer class="modal-footer">
287
+ <button type="button" class="btn btn-ghost" data-close="modal-edit">取消</button>
288
+ <button type="submit" form="form-edit" id="btn-edit-submit" class="btn btn-primary">
289
+ <span class="btn-label">保存修改</span>
290
+ <span class="btn-spinner" aria-hidden="true"></span>
291
+ </button>
292
+ </footer>
293
+ </div>
294
+ </div>
295
+
296
+ <!-- ============ 显示 plugin_key 模态框(创建/轮换共用) ============ -->
297
+ <div id="modal-key" class="modal-mask" hidden>
298
+ <div class="modal modal-md" role="dialog" aria-modal="true" aria-labelledby="modal-key-title">
299
+ <header class="modal-header">
300
+ <h3 id="modal-key-title">插件 Key 已生成</h3>
301
+ <button type="button" class="modal-close" data-close="modal-key" aria-label="关闭">×</button>
302
+ </header>
303
+ <div class="modal-body">
304
+ <div class="key-callout">
305
+ <div class="key-callout-label">PLUGIN KEY</div>
306
+ <div class="key-callout-value" id="key-display">—</div>
307
+ <button type="button" id="btn-copy-key" class="btn btn-secondary btn-copy">
308
+ <span class="btn-label">复制 Key</span>
309
+ </button>
310
+ </div>
311
+ <div class="key-warn">
312
+ <strong>请妥善保存此 Key</strong>
313
+ <span>关闭此对话框后将无法再次查看。如不慎遗失请使用「轮换 Key」生成新凭证。</span>
314
+ </div>
315
+ </div>
316
+ <footer class="modal-footer">
317
+ <button type="button" class="btn btn-primary" data-close="modal-key">我已保存</button>
318
+ </footer>
319
+ </div>
320
+ </div>
321
+
322
+ <!-- ============ 通用确认对话框 ============ -->
323
+ <div id="modal-confirm" class="modal-mask" hidden>
324
+ <div class="modal modal-sm" role="alertdialog" aria-modal="true" aria-labelledby="modal-confirm-title">
325
+ <header class="modal-header">
326
+ <h3 id="modal-confirm-title">请确认</h3>
327
+ <button type="button" class="modal-close" data-close="modal-confirm" aria-label="关闭">×</button>
328
+ </header>
329
+ <div class="modal-body">
330
+ <p id="modal-confirm-msg" class="confirm-msg">确认执行此操作?</p>
331
+ </div>
332
+ <footer class="modal-footer">
333
+ <button type="button" class="btn btn-ghost" data-close="modal-confirm">取消</button>
334
+ <button type="button" id="btn-confirm-ok" class="btn btn-danger">
335
+ <span class="btn-label">确定</span>
336
+ <span class="btn-spinner" aria-hidden="true"></span>
337
+ </button>
338
+ </footer>
339
+ </div>
340
+ </div>
341
+
342
+ <!-- ============ Toast 容器 ============ -->
343
+ <div id="toast-container" class="toast-container" aria-live="polite" aria-atomic="true"></div>
344
+
345
+ <!-- ============ 插件卡片模板 ============ -->
346
+ <template id="tpl-plugin-card">
347
+ <article class="plugin-card">
348
+ <header class="plugin-card-head">
349
+ <div class="plugin-card-title">
350
+ <h3 class="js-name">—</h3>
351
+ <code class="js-id">—</code>
352
+ </div>
353
+ <span class="badge js-status">—</span>
354
+ </header>
355
+ <p class="plugin-desc js-desc"></p>
356
+ <div class="plugin-key-row">
357
+ <span class="key-label">KEY</span>
358
+ <code class="js-key">—</code>
359
+ </div>
360
+ <dl class="plugin-meta">
361
+ <div><dt>限流</dt><dd><span class="js-rate">—</span> <em>次/分钟</em></dd></div>
362
+ <div><dt>单笔限额</dt><dd class="js-tx">—</dd></div>
363
+ <div><dt>日限额</dt><dd class="js-day">—</dd></div>
364
+ <div><dt>权限</dt><dd class="js-perms">—</dd></div>
365
+ <div class="plugin-meta-full"><dt>创建时间</dt><dd class="js-created">—</dd></div>
366
+ </dl>
367
+ <footer class="plugin-card-foot">
368
+ <button type="button" class="btn btn-ghost btn-sm" data-act="edit">编辑</button>
369
+ <button type="button" class="btn btn-ghost btn-sm" data-act="rotate">轮换 Key</button>
370
+ <button type="button" class="btn btn-ghost btn-sm js-toggle" data-act="toggle">启用</button>
371
+ <button type="button" class="btn btn-ghost btn-sm btn-danger-text" data-act="delete">删除</button>
372
+ </footer>
373
+ </article>
374
+ </template>
375
+
376
+ <script src="./app.js"></script>
377
+ </body>
378
+ </html>
管理界面/style.css ADDED
@@ -0,0 +1,983 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ===================================================================
2
+ * RanKing 插件管理控制台 · 样式表
3
+ * 双主题(深色/浅色,跟随 prefers-color-scheme)· 响应式 · 原生实现
4
+ * =================================================================== */
5
+
6
+ /* ---------- 设计令牌(默认 = 浅色) ---------- */
7
+ :root {
8
+ color-scheme: light dark;
9
+
10
+ /* 品牌主色 */
11
+ --primary: #4F46E5;
12
+ --primary-600: #4338CA;
13
+ --primary-700: #3730A3;
14
+ --primary-soft: rgba(79, 70, 229, 0.08);
15
+ --primary-soft-2: rgba(79, 70, 229, 0.16);
16
+
17
+ --success: #10B981;
18
+ --success-soft: rgba(16, 185, 129, 0.12);
19
+ --warning: #F59E0B;
20
+ --warning-soft: rgba(245, 158, 11, 0.12);
21
+ --danger: #EF4444;
22
+ --danger-soft: rgba(239, 68, 68, 0.12);
23
+
24
+ /* 浅色色板 */
25
+ --bg: #F6F7FB;
26
+ --bg-elev: #FFFFFF;
27
+ --bg-elev-2: #FAFBFE;
28
+ --bg-tint: #F1F2F8;
29
+ --border: #E5E7EB;
30
+ --border-strong: #D1D5DB;
31
+
32
+ --text: #1F2937;
33
+ --text-soft: #4B5563;
34
+ --text-mute: #6B7280;
35
+ --text-dim: #9CA3AF;
36
+
37
+ --shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04), 0 1px 1px rgba(15, 23, 42, 0.03);
38
+ --shadow-md: 0 4px 12px rgba(15, 23, 42, 0.06), 0 2px 4px rgba(15, 23, 42, 0.04);
39
+ --shadow-lg: 0 24px 48px -12px rgba(15, 23, 42, 0.18), 0 8px 16px -8px rgba(15, 23, 42, 0.08);
40
+
41
+ --radius-sm: 6px;
42
+ --radius: 10px;
43
+ --radius-lg: 14px;
44
+ --radius-xl: 20px;
45
+
46
+ --font-sans: system-ui, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
47
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Cascadia Code", monospace;
48
+
49
+ --ease: cubic-bezier(0.4, 0, 0.2, 1);
50
+ }
51
+
52
+ /* ---------- 深色模式 ---------- */
53
+ @media (prefers-color-scheme: dark) {
54
+ :root {
55
+ --primary-soft: rgba(99, 102, 241, 0.16);
56
+ --primary-soft-2: rgba(99, 102, 241, 0.28);
57
+
58
+ --success-soft: rgba(16, 185, 129, 0.18);
59
+ --warning-soft: rgba(245, 158, 11, 0.18);
60
+ --danger-soft: rgba(239, 68, 68, 0.18);
61
+
62
+ --bg: #0B0D14;
63
+ --bg-elev: #14161F;
64
+ --bg-elev-2: #1A1D28;
65
+ --bg-tint: #1E2230;
66
+ --border: #262A38;
67
+ --border-strong: #353A4C;
68
+
69
+ --text: #E6E9F0;
70
+ --text-soft: #BFC4D1;
71
+ --text-mute: #8C93A4;
72
+ --text-dim: #5E6478;
73
+
74
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.45);
75
+ --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.45);
76
+ --shadow-lg: 0 24px 60px rgba(0, 0, 0, 0.55);
77
+ }
78
+ }
79
+
80
+ /* ---------- 基础重置 ---------- */
81
+ *,
82
+ *::before,
83
+ *::after { box-sizing: border-box; }
84
+
85
+ html, body { height: 100%; }
86
+
87
+ body {
88
+ margin: 0;
89
+ font-family: var(--font-sans);
90
+ font-size: 14px;
91
+ line-height: 1.55;
92
+ color: var(--text);
93
+ background: var(--bg);
94
+ -webkit-font-smoothing: antialiased;
95
+ -moz-osx-font-smoothing: grayscale;
96
+ background-image:
97
+ radial-gradient(1200px 600px at 100% -10%, var(--primary-soft), transparent 60%),
98
+ radial-gradient(900px 500px at -10% 110%, var(--primary-soft), transparent 60%);
99
+ background-attachment: fixed;
100
+ }
101
+
102
+ h1, h2, h3, h4 { margin: 0; font-weight: 600; letter-spacing: -0.01em; }
103
+ p { margin: 0; }
104
+ button { font-family: inherit; }
105
+ code, pre { font-family: var(--font-mono); }
106
+
107
+ [hidden] { display: none !important; }
108
+
109
+ ::selection { background: var(--primary-soft-2); color: var(--text); }
110
+
111
+ /* ---------- 按钮 ---------- */
112
+ .btn {
113
+ --_bg: var(--bg-elev);
114
+ --_color: var(--text);
115
+ --_border: var(--border-strong);
116
+
117
+ position: relative;
118
+ display: inline-flex;
119
+ align-items: center;
120
+ justify-content: center;
121
+ gap: 6px;
122
+ padding: 9px 14px;
123
+ min-height: 36px;
124
+ font-size: 13.5px;
125
+ font-weight: 500;
126
+ color: var(--_color);
127
+ background: var(--_bg);
128
+ border: 1px solid var(--_border);
129
+ border-radius: var(--radius);
130
+ cursor: pointer;
131
+ transition: transform 120ms var(--ease), background 160ms var(--ease),
132
+ border-color 160ms var(--ease), color 160ms var(--ease),
133
+ box-shadow 160ms var(--ease);
134
+ user-select: none;
135
+ white-space: nowrap;
136
+ }
137
+ .btn:hover { transform: translateY(-1px); box-shadow: var(--shadow-sm); }
138
+ .btn:active { transform: translateY(0); }
139
+ .btn:focus-visible {
140
+ outline: 2px solid var(--primary);
141
+ outline-offset: 2px;
142
+ }
143
+ .btn:disabled,
144
+ .btn[aria-busy="true"] { cursor: not-allowed; opacity: 0.7; transform: none; }
145
+
146
+ .btn-primary {
147
+ --_bg: var(--primary);
148
+ --_color: #fff;
149
+ --_border: var(--primary);
150
+ box-shadow: 0 4px 12px -4px rgba(79, 70, 229, 0.5);
151
+ }
152
+ .btn-primary:hover { --_bg: var(--primary-600); --_border: var(--primary-600); }
153
+ .btn-primary:active { --_bg: var(--primary-700); }
154
+
155
+ .btn-secondary {
156
+ --_bg: var(--bg-elev);
157
+ --_color: var(--text);
158
+ --_border: var(--border-strong);
159
+ }
160
+ .btn-secondary:hover { --_bg: var(--bg-tint); }
161
+
162
+ .btn-ghost {
163
+ --_bg: transparent;
164
+ --_color: var(--text-soft);
165
+ --_border: transparent;
166
+ padding: 8px 12px;
167
+ }
168
+ .btn-ghost:hover { --_bg: var(--bg-tint); --_color: var(--text); }
169
+
170
+ .btn-danger {
171
+ --_bg: var(--danger);
172
+ --_color: #fff;
173
+ --_border: var(--danger);
174
+ }
175
+ .btn-danger:hover { --_bg: #DC2626; --_border: #DC2626; }
176
+
177
+ .btn-danger-text { --_color: var(--danger); }
178
+ .btn-danger-text:hover { --_bg: var(--danger-soft); --_color: var(--danger); }
179
+
180
+ .btn-block { width: 100%; }
181
+ .btn-sm { padding: 6px 10px; min-height: 30px; font-size: 12.5px; }
182
+
183
+ .btn-icon { font-size: 16px; line-height: 1; }
184
+
185
+ /* 按钮 loading */
186
+ .btn .btn-spinner {
187
+ width: 14px;
188
+ height: 14px;
189
+ border: 2px solid currentColor;
190
+ border-top-color: transparent;
191
+ border-radius: 50%;
192
+ display: none;
193
+ animation: spin 720ms linear infinite;
194
+ }
195
+ .btn[aria-busy="true"] .btn-spinner { display: inline-block; }
196
+ .btn[aria-busy="true"] .btn-label { opacity: 0.7; }
197
+
198
+ @keyframes spin { to { transform: rotate(360deg); } }
199
+
200
+ /* ---------- 视图容器 ---------- */
201
+ .view { min-height: 100vh; }
202
+
203
+ /* ---------- 登录视图 ---------- */
204
+ .view-login {
205
+ display: grid;
206
+ place-items: center;
207
+ padding: 32px 20px;
208
+ }
209
+ .login-shell {
210
+ width: 100%;
211
+ max-width: 980px;
212
+ display: grid;
213
+ grid-template-columns: 1.05fr 1fr;
214
+ gap: 0;
215
+ background: var(--bg-elev);
216
+ border: 1px solid var(--border);
217
+ border-radius: var(--radius-xl);
218
+ overflow: hidden;
219
+ box-shadow: var(--shadow-lg);
220
+ animation: rise 480ms var(--ease) both;
221
+ }
222
+ @keyframes rise {
223
+ from { opacity: 0; transform: translateY(12px) scale(0.985); }
224
+ to { opacity: 1; transform: translateY(0) scale(1); }
225
+ }
226
+
227
+ .login-aside {
228
+ position: relative;
229
+ padding: 36px 36px 32px;
230
+ background:
231
+ radial-gradient(circle at 0% 0%, var(--primary-soft-2), transparent 55%),
232
+ linear-gradient(160deg, var(--primary) 0%, var(--primary-700) 100%);
233
+ color: #fff;
234
+ display: flex;
235
+ flex-direction: column;
236
+ gap: 36px;
237
+ overflow: hidden;
238
+ }
239
+ .login-aside::after {
240
+ content: "";
241
+ position: absolute;
242
+ inset: auto -40% -50% auto;
243
+ width: 360px; height: 360px;
244
+ background: radial-gradient(closest-side, rgba(255,255,255,0.18), transparent 70%);
245
+ pointer-events: none;
246
+ }
247
+
248
+ .login-headline h1 {
249
+ font-size: 30px;
250
+ line-height: 1.2;
251
+ font-weight: 700;
252
+ letter-spacing: -0.02em;
253
+ }
254
+ .login-headline h1 em {
255
+ font-style: normal;
256
+ background: linear-gradient(90deg, #fff, rgba(255,255,255,0.6));
257
+ -webkit-background-clip: text;
258
+ background-clip: text;
259
+ color: transparent;
260
+ }
261
+ .login-headline p {
262
+ margin-top: 12px;
263
+ color: rgba(255, 255, 255, 0.82);
264
+ font-size: 13.5px;
265
+ max-width: 30ch;
266
+ }
267
+ .login-meta {
268
+ list-style: none;
269
+ margin: 0;
270
+ padding: 0;
271
+ display: flex;
272
+ flex-direction: column;
273
+ gap: 10px;
274
+ color: rgba(255, 255, 255, 0.88);
275
+ font-size: 13px;
276
+ }
277
+ .login-meta li { display: flex; align-items: center; gap: 8px; }
278
+ .dot {
279
+ width: 8px; height: 8px; border-radius: 50%;
280
+ box-shadow: 0 0 0 3px rgba(255,255,255,0.12);
281
+ }
282
+ .dot-success { background: var(--success); }
283
+ .dot-warning { background: var(--warning); }
284
+ .dot-primary { background: #fff; }
285
+
286
+ /* 登录卡片 */
287
+ .login-card {
288
+ padding: 40px 36px;
289
+ display: flex;
290
+ flex-direction: column;
291
+ gap: 16px;
292
+ background: var(--bg-elev);
293
+ }
294
+ .login-card-header { margin-bottom: 4px; }
295
+ .login-card-header h2 { font-size: 20px; }
296
+ .login-card-header p { font-size: 13px; color: var(--text-mute); margin-top: 4px; }
297
+ .login-card-footer {
298
+ margin-top: 4px;
299
+ text-align: center;
300
+ color: var(--text-dim);
301
+ font-size: 12px;
302
+ }
303
+
304
+ /* ---------- 品牌 ---------- */
305
+ .brand { display: flex; align-items: center; gap: 12px; }
306
+ .brand-mark {
307
+ position: relative;
308
+ width: 36px; height: 36px;
309
+ background: rgba(255, 255, 255, 0.12);
310
+ border: 1px solid rgba(255, 255, 255, 0.24);
311
+ border-radius: 10px;
312
+ display: grid; place-items: center;
313
+ flex: none;
314
+ }
315
+ .brand-mark-dot {
316
+ width: 8px; height: 8px; border-radius: 50%;
317
+ background: #fff;
318
+ box-shadow: 0 0 12px rgba(255,255,255,0.7);
319
+ }
320
+ .brand-mark-bar {
321
+ position: absolute;
322
+ bottom: 7px; left: 50%;
323
+ width: 16px; height: 2px;
324
+ background: #fff;
325
+ transform: translateX(-50%);
326
+ border-radius: 2px;
327
+ opacity: 0.65;
328
+ }
329
+ .brand-text { display: flex; flex-direction: column; line-height: 1.1; }
330
+ .brand-title {
331
+ font-weight: 700;
332
+ font-size: 15px;
333
+ letter-spacing: 0.02em;
334
+ }
335
+ .brand-sub {
336
+ font-size: 11px;
337
+ color: rgba(255, 255, 255, 0.7);
338
+ letter-spacing: 0.08em;
339
+ text-transform: uppercase;
340
+ margin-top: 2px;
341
+ }
342
+ .brand-compact .brand-mark {
343
+ background: var(--primary-soft);
344
+ border-color: var(--primary-soft-2);
345
+ }
346
+ .brand-compact .brand-mark-dot { background: var(--primary); box-shadow: 0 0 10px var(--primary-soft-2); }
347
+ .brand-compact .brand-mark-bar { background: var(--primary); opacity: 0.7; }
348
+ .brand-compact .brand-title { color: var(--text); }
349
+ .brand-compact .brand-sub { color: var(--text-mute); }
350
+
351
+ /* ---------- 表单字段 ---------- */
352
+ .field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
353
+ .field-label {
354
+ font-size: 12.5px;
355
+ font-weight: 500;
356
+ color: var(--text-soft);
357
+ letter-spacing: 0.01em;
358
+ }
359
+ .required { color: var(--danger); margin-left: 2px; }
360
+ .field-hint { font-size: 12px; color: var(--text-dim); }
361
+
362
+ .field input[type="text"],
363
+ .field input[type="password"],
364
+ .field input[type="search"],
365
+ .field input[type="number"],
366
+ .field textarea {
367
+ width: 100%;
368
+ padding: 10px 12px;
369
+ font: inherit;
370
+ font-size: 13.5px;
371
+ color: var(--text);
372
+ background: var(--bg-elev-2);
373
+ border: 1px solid var(--border);
374
+ border-radius: var(--radius);
375
+ transition: border-color 160ms var(--ease), background 160ms var(--ease),
376
+ box-shadow 160ms var(--ease);
377
+ }
378
+ .field textarea { resize: vertical; min-height: 64px; }
379
+ .field input:hover,
380
+ .field textarea:hover { border-color: var(--border-strong); }
381
+ .field input:focus,
382
+ .field textarea:focus {
383
+ outline: none;
384
+ border-color: var(--primary);
385
+ box-shadow: 0 0 0 4px var(--primary-soft);
386
+ background: var(--bg-elev);
387
+ }
388
+ .field input:disabled {
389
+ color: var(--text-mute);
390
+ background: var(--bg-tint);
391
+ cursor: not-allowed;
392
+ }
393
+
394
+ /* 权限多选 */
395
+ .perm-field { border: none; padding: 0; margin: 0; }
396
+ .perm-field .field-label { padding: 0; }
397
+ .perm-grid {
398
+ display: grid;
399
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
400
+ gap: 8px;
401
+ }
402
+ .perm-item {
403
+ display: flex;
404
+ align-items: center;
405
+ gap: 8px;
406
+ padding: 8px 10px;
407
+ background: var(--bg-elev-2);
408
+ border: 1px solid var(--border);
409
+ border-radius: var(--radius);
410
+ cursor: pointer;
411
+ font-size: 13px;
412
+ transition: border-color 160ms var(--ease), background 160ms var(--ease);
413
+ }
414
+ .perm-item:hover { border-color: var(--border-strong); }
415
+ .perm-item input { accent-color: var(--primary); }
416
+ .perm-item input:checked + span { color: var(--primary); font-weight: 500; }
417
+
418
+ /* 表单错误 */
419
+ .form-error {
420
+ padding: 10px 12px;
421
+ background: var(--danger-soft);
422
+ color: var(--danger);
423
+ border: 1px solid color-mix(in srgb, var(--danger) 35%, transparent);
424
+ border-radius: var(--radius);
425
+ font-size: 13px;
426
+ }
427
+
428
+ /* 静态展示 key */
429
+ .static-key {
430
+ padding: 10px 12px;
431
+ background: var(--bg-tint);
432
+ border: 1px dashed var(--border-strong);
433
+ border-radius: var(--radius);
434
+ font-family: var(--font-mono);
435
+ font-size: 13px;
436
+ color: var(--text-soft);
437
+ word-break: break-all;
438
+ }
439
+
440
+ /* ---------- 顶部栏 ---------- */
441
+ .topbar {
442
+ position: sticky;
443
+ top: 0;
444
+ z-index: 50;
445
+ display: flex;
446
+ align-items: center;
447
+ justify-content: space-between;
448
+ gap: 16px;
449
+ padding: 14px 28px;
450
+ background: color-mix(in srgb, var(--bg-elev) 88%, transparent);
451
+ backdrop-filter: saturate(160%) blur(12px);
452
+ -webkit-backdrop-filter: saturate(160%) blur(12px);
453
+ border-bottom: 1px solid var(--border);
454
+ }
455
+ .topbar-left,
456
+ .topbar-right { display: flex; align-items: center; gap: 16px; }
457
+ .crumbs {
458
+ display: flex; align-items: center; gap: 6px;
459
+ font-size: 13px;
460
+ color: var(--text-mute);
461
+ padding-left: 16px;
462
+ margin-left: 4px;
463
+ border-left: 1px solid var(--border);
464
+ height: 24px;
465
+ }
466
+ .crumbs-sep { color: var(--text-dim); }
467
+ .crumbs-current { color: var(--text); font-weight: 500; }
468
+
469
+ .user-chip {
470
+ display: flex; align-items: center; gap: 8px;
471
+ padding: 4px 12px 4px 4px;
472
+ background: var(--bg-tint);
473
+ border: 1px solid var(--border);
474
+ border-radius: 999px;
475
+ font-size: 13px;
476
+ }
477
+ .user-avatar {
478
+ width: 28px; height: 28px;
479
+ border-radius: 50%;
480
+ display: grid; place-items: center;
481
+ background: var(--primary);
482
+ color: #fff;
483
+ font-size: 12px;
484
+ font-weight: 600;
485
+ text-transform: uppercase;
486
+ }
487
+ .user-name { color: var(--text); max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
488
+
489
+ /* ---------- 主体 ---------- */
490
+ .main-shell {
491
+ max-width: 1240px;
492
+ margin: 0 auto;
493
+ padding: 28px 28px 64px;
494
+ display: flex;
495
+ flex-direction: column;
496
+ gap: 22px;
497
+ }
498
+
499
+ .page-head {
500
+ display: flex;
501
+ align-items: flex-end;
502
+ justify-content: space-between;
503
+ gap: 16px;
504
+ flex-wrap: wrap;
505
+ }
506
+ .page-head h1 { font-size: 26px; letter-spacing: -0.02em; }
507
+ .page-sub { color: var(--text-mute); margin-top: 4px; font-size: 13.5px; }
508
+
509
+ .page-head-actions {
510
+ display: flex; align-items: center; gap: 10px;
511
+ flex-wrap: wrap;
512
+ }
513
+ .search-wrap input[type="search"] {
514
+ width: 260px;
515
+ padding: 9px 12px 9px 36px;
516
+ background:
517
+ var(--bg-elev) url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%239CA3AF' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'><circle cx='11' cy='11' r='7'/><path d='m20 20-3.5-3.5'/></svg>") no-repeat 12px center / 16px;
518
+ border: 1px solid var(--border);
519
+ border-radius: var(--radius);
520
+ font-size: 13px;
521
+ color: var(--text);
522
+ transition: border-color 160ms var(--ease), box-shadow 160ms var(--ease);
523
+ }
524
+ .search-wrap input[type="search"]:focus {
525
+ outline: none;
526
+ border-color: var(--primary);
527
+ box-shadow: 0 0 0 4px var(--primary-soft);
528
+ }
529
+
530
+ /* ---------- 统计行 ---------- */
531
+ .stat-row {
532
+ display: grid;
533
+ grid-template-columns: repeat(3, 1fr);
534
+ gap: 14px;
535
+ }
536
+ .stat-card {
537
+ padding: 16px 18px;
538
+ background: var(--bg-elev);
539
+ border: 1px solid var(--border);
540
+ border-radius: var(--radius-lg);
541
+ box-shadow: var(--shadow-sm);
542
+ }
543
+ .stat-label { font-size: 12.5px; color: var(--text-mute); letter-spacing: 0.02em; }
544
+ .stat-value {
545
+ font-size: 26px;
546
+ font-weight: 700;
547
+ margin-top: 4px;
548
+ letter-spacing: -0.02em;
549
+ font-variant-numeric: tabular-nums;
550
+ }
551
+ .text-success { color: var(--success); }
552
+ .text-warning { color: var(--warning); }
553
+
554
+ /* ---------- 列表区 ---------- */
555
+ .list-shell { position: relative; min-height: 200px; }
556
+
557
+ .state-block {
558
+ padding: 56px 24px;
559
+ text-align: center;
560
+ background: var(--bg-elev);
561
+ border: 1px dashed var(--border-strong);
562
+ border-radius: var(--radius-lg);
563
+ color: var(--text-mute);
564
+ }
565
+ .state-block h3 { font-size: 16px; color: var(--text); margin-bottom: 6px; }
566
+ .state-block p { font-size: 13.5px; }
567
+ .state-block .empty-mark {
568
+ font-size: 28px;
569
+ color: var(--text-dim);
570
+ margin-bottom: 8px;
571
+ }
572
+ .state-error .empty-mark { color: var(--danger); }
573
+ .state-error h3 { color: var(--danger); }
574
+ .state-block .btn { margin-top: 16px; }
575
+
576
+ .spinner-lg {
577
+ width: 28px; height: 28px;
578
+ margin: 0 auto 12px;
579
+ border: 3px solid var(--border-strong);
580
+ border-top-color: var(--primary);
581
+ border-radius: 50%;
582
+ animation: spin 720ms linear infinite;
583
+ }
584
+
585
+ /* 插件卡片网格 */
586
+ .plugin-grid {
587
+ display: grid;
588
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
589
+ gap: 14px;
590
+ }
591
+
592
+ .plugin-card {
593
+ position: relative;
594
+ padding: 18px 18px 14px;
595
+ background: var(--bg-elev);
596
+ border: 1px solid var(--border);
597
+ border-radius: var(--radius-lg);
598
+ box-shadow: var(--shadow-sm);
599
+ display: flex;
600
+ flex-direction: column;
601
+ gap: 12px;
602
+ transition: transform 200ms var(--ease), box-shadow 200ms var(--ease), border-color 200ms var(--ease);
603
+ animation: fade-in 280ms var(--ease) both;
604
+ }
605
+ .plugin-card:hover {
606
+ transform: translateY(-2px);
607
+ box-shadow: var(--shadow-md);
608
+ border-color: var(--border-strong);
609
+ }
610
+ @keyframes fade-in {
611
+ from { opacity: 0; transform: translateY(6px); }
612
+ to { opacity: 1; transform: translateY(0); }
613
+ }
614
+
615
+ .plugin-card-head {
616
+ display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
617
+ }
618
+ .plugin-card-title { min-width: 0; flex: 1; }
619
+ .plugin-card-title h3 { font-size: 15.5px; line-height: 1.3; word-break: break-word; }
620
+ .plugin-card-title code {
621
+ display: inline-block;
622
+ margin-top: 4px;
623
+ font-size: 12px;
624
+ color: var(--text-mute);
625
+ }
626
+
627
+ .badge {
628
+ flex: none;
629
+ padding: 3px 10px;
630
+ border-radius: 999px;
631
+ font-size: 11.5px;
632
+ font-weight: 600;
633
+ letter-spacing: 0.02em;
634
+ line-height: 1.6;
635
+ }
636
+ .badge.is-on { background: var(--success-soft); color: var(--success); }
637
+ .badge.is-off { background: var(--warning-soft); color: var(--warning); }
638
+
639
+ .plugin-desc {
640
+ font-size: 13px;
641
+ color: var(--text-mute);
642
+ line-height: 1.55;
643
+ overflow: hidden;
644
+ display: -webkit-box;
645
+ -webkit-line-clamp: 2;
646
+ -webkit-box-orient: vertical;
647
+ min-height: 1.55em;
648
+ }
649
+ .plugin-desc:empty::before {
650
+ content: "暂无描述";
651
+ color: var(--text-dim);
652
+ font-style: italic;
653
+ }
654
+
655
+ .plugin-key-row {
656
+ display: flex; align-items: center; gap: 8px;
657
+ padding: 8px 10px;
658
+ background: var(--bg-tint);
659
+ border: 1px solid var(--border);
660
+ border-radius: var(--radius);
661
+ font-size: 12.5px;
662
+ overflow: hidden;
663
+ }
664
+ .key-label {
665
+ font-size: 10.5px;
666
+ font-weight: 700;
667
+ color: var(--text-mute);
668
+ letter-spacing: 0.12em;
669
+ background: var(--bg-elev);
670
+ padding: 2px 6px;
671
+ border-radius: 4px;
672
+ border: 1px solid var(--border);
673
+ flex: none;
674
+ }
675
+ .plugin-key-row code {
676
+ color: var(--text-soft);
677
+ overflow: hidden;
678
+ text-overflow: ellipsis;
679
+ white-space: nowrap;
680
+ }
681
+
682
+ .plugin-meta {
683
+ display: grid;
684
+ grid-template-columns: 1fr 1fr;
685
+ gap: 10px 14px;
686
+ margin: 0;
687
+ padding: 12px 0 4px;
688
+ border-top: 1px dashed var(--border);
689
+ }
690
+ .plugin-meta > div { min-width: 0; }
691
+ .plugin-meta-full { grid-column: 1 / -1; }
692
+ .plugin-meta dt {
693
+ font-size: 11px;
694
+ color: var(--text-dim);
695
+ text-transform: uppercase;
696
+ letter-spacing: 0.08em;
697
+ margin-bottom: 2px;
698
+ }
699
+ .plugin-meta dd {
700
+ margin: 0;
701
+ font-size: 13px;
702
+ color: var(--text);
703
+ font-variant-numeric: tabular-nums;
704
+ word-break: break-word;
705
+ }
706
+ .plugin-meta dd em { font-style: normal; color: var(--text-mute); font-size: 11.5px; }
707
+ .js-perms { display: flex; flex-wrap: wrap; gap: 4px; }
708
+ .perm-chip {
709
+ display: inline-block;
710
+ padding: 1px 8px;
711
+ background: var(--primary-soft);
712
+ color: var(--primary);
713
+ border-radius: 999px;
714
+ font-size: 11px;
715
+ font-weight: 500;
716
+ }
717
+
718
+ .plugin-card-foot {
719
+ display: flex;
720
+ flex-wrap: wrap;
721
+ gap: 4px;
722
+ padding-top: 8px;
723
+ border-top: 1px dashed var(--border);
724
+ }
725
+
726
+ /* ---------- 模态框 ---------- */
727
+ .modal-mask {
728
+ position: fixed;
729
+ inset: 0;
730
+ z-index: 100;
731
+ background: color-mix(in srgb, #0B0D14 60%, transparent);
732
+ backdrop-filter: blur(4px);
733
+ -webkit-backdrop-filter: blur(4px);
734
+ display: grid;
735
+ place-items: center;
736
+ padding: 20px;
737
+ animation: mask-in 180ms var(--ease) both;
738
+ }
739
+ @keyframes mask-in { from { opacity: 0; } to { opacity: 1; } }
740
+
741
+ .modal {
742
+ width: 100%;
743
+ background: var(--bg-elev);
744
+ border: 1px solid var(--border);
745
+ border-radius: var(--radius-xl);
746
+ box-shadow: var(--shadow-lg);
747
+ display: flex;
748
+ flex-direction: column;
749
+ max-height: calc(100vh - 40px);
750
+ overflow: hidden;
751
+ animation: modal-in 220ms var(--ease) both;
752
+ }
753
+ @keyframes modal-in {
754
+ from { opacity: 0; transform: translateY(8px) scale(0.98); }
755
+ to { opacity: 1; transform: translateY(0) scale(1); }
756
+ }
757
+ .modal-sm { max-width: 420px; }
758
+ .modal-md { max-width: 540px; }
759
+ .modal-lg { max-width: 680px; }
760
+
761
+ .modal-header {
762
+ display: flex; align-items: center; justify-content: space-between;
763
+ padding: 18px 22px;
764
+ border-bottom: 1px solid var(--border);
765
+ }
766
+ .modal-header h3 { font-size: 16px; }
767
+ .modal-close {
768
+ width: 32px; height: 32px;
769
+ border: none;
770
+ background: transparent;
771
+ border-radius: 8px;
772
+ color: var(--text-mute);
773
+ font-size: 20px;
774
+ line-height: 1;
775
+ cursor: pointer;
776
+ transition: background 160ms var(--ease), color 160ms var(--ease);
777
+ }
778
+ .modal-close:hover { background: var(--bg-tint); color: var(--text); }
779
+
780
+ .modal-body {
781
+ padding: 20px 22px;
782
+ display: flex;
783
+ flex-direction: column;
784
+ gap: 14px;
785
+ overflow-y: auto;
786
+ }
787
+ .modal-footer {
788
+ display: flex;
789
+ justify-content: flex-end;
790
+ gap: 8px;
791
+ padding: 14px 22px;
792
+ border-top: 1px solid var(--border);
793
+ background: var(--bg-elev-2);
794
+ }
795
+
796
+ .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
797
+ .grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
798
+
799
+ .confirm-msg {
800
+ font-size: 14px;
801
+ color: var(--text-soft);
802
+ line-height: 1.6;
803
+ }
804
+
805
+ /* ---------- Key 高亮展示 ---------- */
806
+ .key-callout {
807
+ position: relative;
808
+ padding: 18px;
809
+ background: linear-gradient(135deg, var(--primary-soft) 0%, var(--primary-soft-2) 100%);
810
+ border: 1px solid color-mix(in srgb, var(--primary) 35%, transparent);
811
+ border-radius: var(--radius-lg);
812
+ display: flex;
813
+ flex-direction: column;
814
+ gap: 10px;
815
+ overflow: hidden;
816
+ }
817
+ .key-callout::after {
818
+ content: "";
819
+ position: absolute;
820
+ inset: -1px;
821
+ border-radius: inherit;
822
+ border: 1px solid var(--primary);
823
+ pointer-events: none;
824
+ opacity: 0.25;
825
+ animation: glow 2.4s ease-in-out infinite;
826
+ }
827
+ @keyframes glow {
828
+ 0%, 100% { opacity: 0.2; }
829
+ 50% { opacity: 0.55; }
830
+ }
831
+ .key-callout-label {
832
+ font-size: 11px;
833
+ font-weight: 700;
834
+ letter-spacing: 0.16em;
835
+ color: var(--primary);
836
+ }
837
+ .key-callout-value {
838
+ font-family: var(--font-mono);
839
+ font-size: 15px;
840
+ color: var(--text);
841
+ background: var(--bg-elev);
842
+ padding: 12px 14px;
843
+ border-radius: var(--radius);
844
+ border: 1px solid var(--border);
845
+ word-break: break-all;
846
+ user-select: all;
847
+ }
848
+ .btn-copy { align-self: flex-start; }
849
+ .btn-copy.is-copied {
850
+ --_bg: var(--success-soft);
851
+ --_color: var(--success);
852
+ --_border: color-mix(in srgb, var(--success) 35%, transparent);
853
+ }
854
+
855
+ .key-warn {
856
+ display: flex;
857
+ flex-direction: column;
858
+ gap: 4px;
859
+ padding: 12px 14px;
860
+ background: var(--warning-soft);
861
+ border: 1px solid color-mix(in srgb, var(--warning) 35%, transparent);
862
+ border-radius: var(--radius);
863
+ font-size: 13px;
864
+ }
865
+ .key-warn strong { color: var(--warning); font-weight: 600; }
866
+ .key-warn span { color: var(--text-soft); }
867
+
868
+ /* ---------- 拒绝访问 ---------- */
869
+ .view-forbidden {
870
+ display: grid;
871
+ place-items: center;
872
+ padding: 32px 20px;
873
+ }
874
+ .forbidden-card {
875
+ max-width: 460px;
876
+ text-align: center;
877
+ padding: 40px 32px;
878
+ background: var(--bg-elev);
879
+ border: 1px solid var(--border);
880
+ border-radius: var(--radius-xl);
881
+ box-shadow: var(--shadow-lg);
882
+ animation: rise 320ms var(--ease) both;
883
+ }
884
+ .forbidden-mark {
885
+ font-size: 40px;
886
+ margin-bottom: 8px;
887
+ filter: grayscale(0.2);
888
+ }
889
+ .forbidden-card h2 { font-size: 18px; margin-bottom: 8px; }
890
+ .forbidden-card p { color: var(--text-mute); margin-bottom: 20px; }
891
+
892
+ /* ---------- Toast ---------- */
893
+ .toast-container {
894
+ position: fixed;
895
+ bottom: 20px; right: 20px;
896
+ z-index: 200;
897
+ display: flex;
898
+ flex-direction: column;
899
+ gap: 10px;
900
+ pointer-events: none;
901
+ max-width: calc(100vw - 40px);
902
+ }
903
+ .toast {
904
+ pointer-events: auto;
905
+ min-width: 240px;
906
+ max-width: 360px;
907
+ padding: 12px 14px;
908
+ background: var(--bg-elev);
909
+ border: 1px solid var(--border);
910
+ border-left: 3px solid var(--primary);
911
+ border-radius: var(--radius);
912
+ box-shadow: var(--shadow-md);
913
+ color: var(--text);
914
+ font-size: 13.5px;
915
+ line-height: 1.5;
916
+ display: flex; align-items: flex-start; gap: 10px;
917
+ animation: toast-in 240ms var(--ease) both;
918
+ }
919
+ .toast.is-leaving { animation: toast-out 200ms var(--ease) forwards; }
920
+ .toast.toast-success { border-left-color: var(--success); }
921
+ .toast.toast-error { border-left-color: var(--danger); }
922
+ .toast.toast-warning { border-left-color: var(--warning); }
923
+ .toast-icon {
924
+ flex: none;
925
+ width: 18px; height: 18px;
926
+ border-radius: 50%;
927
+ display: grid; place-items: center;
928
+ font-size: 11px; font-weight: 700;
929
+ color: #fff;
930
+ background: var(--primary);
931
+ margin-top: 1px;
932
+ }
933
+ .toast-success .toast-icon { background: var(--success); }
934
+ .toast-error .toast-icon { background: var(--danger); }
935
+ .toast-warning .toast-icon { background: var(--warning); }
936
+
937
+ @keyframes toast-in {
938
+ from { opacity: 0; transform: translateX(20px); }
939
+ to { opacity: 1; transform: translateX(0); }
940
+ }
941
+ @keyframes toast-out {
942
+ to { opacity: 0; transform: translateX(20px); }
943
+ }
944
+
945
+ /* ---------- 响应式 ---------- */
946
+ @media (max-width: 880px) {
947
+ .login-shell { grid-template-columns: 1fr; max-width: 480px; }
948
+ .login-aside { padding: 28px 28px 24px; gap: 24px; }
949
+ .login-headline h1 { font-size: 24px; }
950
+ .login-card { padding: 28px 24px; }
951
+
952
+ .topbar { padding: 12px 16px; }
953
+ .crumbs { display: none; }
954
+ .main-shell { padding: 20px 16px 48px; }
955
+
956
+ .stat-row { grid-template-columns: 1fr; }
957
+ .grid-2, .grid-3 { grid-template-columns: 1fr; }
958
+ .modal-header, .modal-body, .modal-footer { padding-left: 18px; padding-right: 18px; }
959
+
960
+ .search-wrap input[type="search"] { width: 100%; }
961
+ .page-head-actions { width: 100%; }
962
+ }
963
+ @media (max-width: 480px) {
964
+ .topbar-left .brand-text .brand-sub { display: none; }
965
+ .user-name { display: none; }
966
+ .plugin-card-foot .btn { flex: 1 1 calc(50% - 4px); justify-content: center; }
967
+ }
968
+
969
+ /* ---------- 滚动条 ---------- */
970
+ .modal-body::-webkit-scrollbar { width: 8px; height: 8px; }
971
+ .modal-body::-webkit-scrollbar-track { background: transparent; }
972
+ .modal-body::-webkit-scrollbar-thumb {
973
+ background: var(--border-strong);
974
+ border-radius: 4px;
975
+ }
976
+ .modal-body::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
977
+
978
+ @media (prefers-reduced-motion: reduce) {
979
+ *, *::before, *::after {
980
+ animation-duration: 0.01ms !important;
981
+ transition-duration: 0.01ms !important;
982
+ }
983
+ }