ZHIWEI666 commited on
Commit
5259637
·
verified ·
1 Parent(s): b9d11ff

Upload 5 files

Browse files
Files changed (3) hide show
  1. 管理界面/app.js +1043 -284
  2. 管理界面/index.html +26 -26
  3. 管理界面/style.css +37 -5
管理界面/app.js CHANGED
@@ -6,6 +6,82 @@
6
  (function () {
7
  'use strict';
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  // -------------------- 配置 --------------------
10
  // 默认调用同源后端;如需指定接口域名可在 window.RANKING_API_BASE 设置
11
  const API_BASE = (typeof window !== 'undefined' && window.RANKING_API_BASE) || '';
@@ -20,9 +96,11 @@
20
  };
21
 
22
  // -------------------- 状态 --------------------
 
23
  const state = {
24
- token: sessionStorage.getItem(TOKEN_KEY) || '',
25
- user: safeParse(sessionStorage.getItem(USER_KEY)),
 
26
  plugins: [],
27
  filtered: [],
28
  searchKeyword: '',
@@ -89,6 +167,341 @@
89
  }
90
  }
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  function showError(node, msg) {
93
  if (!node) return;
94
  if (!msg) { node.hidden = true; node.textContent = ''; return; }
@@ -97,40 +510,77 @@
97
  }
98
 
99
  // -------------------- API 客户端 --------------------
100
- async function apiRequest(method, path, body) {
101
- const headers = { 'Accept': 'application/json' };
102
- if (state.token) headers['Authorization'] = 'Bearer ' + state.token;
103
- const init = { method, headers };
104
- if (body !== undefined && body !== null) {
105
- headers['Content-Type'] = 'application/json';
106
- init.body = JSON.stringify(body);
107
  }
108
- let response;
109
- try {
110
- response = await fetch(API_BASE + path, init);
111
- } catch (err) {
112
- throw new ApiError('网络异常,请检查连接后重试', 0, err);
113
- }
114
-
115
- // 401 处理:自动跳回登录
116
- if (response.status === 401) {
117
- handleUnauthorized();
118
- throw new ApiError('登录已失效,请重新登录', 401);
119
  }
120
-
121
- let payload = null;
122
- const text = await response.text();
123
- if (text) {
124
- try { payload = JSON.parse(text); } catch (e) { payload = { message: text }; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  }
126
-
127
- if (!response.ok) {
128
- const msg = (payload && (payload.message || payload.error || payload.detail)) || `请求失败(${response.status})`;
129
- throw new ApiError(msg, response.status, payload);
 
 
130
  }
131
- return payload;
132
  }
133
-
134
  class ApiError extends Error {
135
  constructor(message, status, raw) {
136
  super(message);
@@ -138,35 +588,18 @@
138
  this.raw = raw;
139
  }
140
  }
 
 
 
141
 
142
- // 🆕 multipart 上传(广告图片):FormData 不能 JSON 序列化,独立于 apiRequest
143
  async function apiUpload(path, formData) {
144
- const headers = { 'Accept': 'application/json' };
145
- if (state.token) headers['Authorization'] = 'Bearer ' + state.token;
146
- let response;
147
- try {
148
- response = await fetch(API_BASE + path, { method: 'POST', headers, body: formData });
149
- } catch (err) {
150
- throw new ApiError('网络异常,请检查连接后重试', 0, err);
151
- }
152
- if (response.status === 401) {
153
- handleUnauthorized();
154
- throw new ApiError('登录已失效,请重新登录', 401);
155
- }
156
- let payload = null;
157
- const text = await response.text();
158
- if (text) {
159
- try { payload = JSON.parse(text); } catch (e) { payload = { message: text }; }
160
- }
161
- if (!response.ok) {
162
- const msg = (payload && (payload.message || payload.error || payload.detail)) || `请求失败(${response.status})`;
163
- throw new ApiError(msg, response.status, payload);
164
- }
165
- return payload;
166
  }
167
 
168
  // -------------------- 视图切换 --------------------
169
  function showView(name) {
 
170
  ['view-login', 'view-main', 'view-forbidden'].forEach((id) => {
171
  const el = document.getElementById(id);
172
  if (el) el.hidden = (id !== name);
@@ -177,21 +610,78 @@
177
  function handleUnauthorized() {
178
  clearSession();
179
  showView('view-login');
180
- toast('登录状态已过期,请重新登录', 'warning');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  }
182
 
183
  function clearSession() {
 
184
  state.token = '';
185
  state.user = null;
186
- sessionStorage.removeItem(TOKEN_KEY);
187
- sessionStorage.removeItem(USER_KEY);
 
188
  }
189
 
190
  function persistSession(token, user) {
 
191
  state.token = token || '';
192
  state.user = user || null;
193
- if (token) sessionStorage.setItem(TOKEN_KEY, token);
194
- if (user) sessionStorage.setItem(USER_KEY, JSON.stringify(user));
 
 
 
 
 
 
 
195
  }
196
 
197
  // -------------------- 登录 --------------------
@@ -212,36 +702,40 @@
212
  }
213
 
214
  const submit = $('#login-submit');
215
- setBusy(submit, true);
 
 
216
  try {
217
  const data = await apiRequest('POST', '/api/users/login', { account, password });
218
- if (!data || !data.token) {
219
- throw new ApiError('登录响应缺少 token', 0, data);
220
- }
221
- // 登录响应顶层返回 account/name/avatar,组装为 user 对象(管理操作的 admin_account 取自这里)
222
- persistSession(data.token, data.user || { account: data.account, name: data.name, avatar: data.avatar });
223
 
224
  // 校验是否为管理员:尝试访问插件列表(403 → 非管理员)
225
  try {
226
  await loadPlugins();
 
227
  syncUserChip();
228
  showView('view-main');
229
  toast('登录成功', 'success');
230
  } catch (err) {
231
- if (err instanceof ApiError && (err.status === 403 || err.status === 401)) {
232
- clearSession();
 
233
  showView('view-forbidden');
234
  } else {
235
- // 其它错误:保留登录态但提示
236
- syncUserChip();
237
- showView('view-main');
238
- toast(err.message || '加载插件列表失败', 'error');
239
  }
240
  }
241
  } catch (err) {
 
242
  showError(errBox, err.message || '登录失败');
243
  } finally {
244
- setBusy(submit, false);
245
  }
246
  });
247
  }
@@ -266,16 +760,37 @@
266
  }
267
  }
268
 
 
 
 
 
 
 
 
 
 
269
  async function loadPlugins() {
 
 
 
270
  setListState('loading');
271
- const data = await apiRequest('GET', '/api/admin/plugins');
272
- const list = Array.isArray(data) ? data
273
- : Array.isArray(data && data.plugins) ? data.plugins
274
- : Array.isArray(data && data.items) ? data.items
275
- : Array.isArray(data && data.data) ? data.data
276
- : [];
277
- state.plugins = list;
278
- applyFilter();
 
 
 
 
 
 
 
 
 
279
  }
280
 
281
  function applyFilter() {
@@ -373,36 +888,36 @@
373
  function openModal(id) {
374
  const el = document.getElementById(id);
375
  if (!el) return;
 
376
  el.hidden = false;
377
- document.body.style.overflow = 'hidden';
378
  }
379
  function closeModal(id) {
380
- const el = document.getElementById(id);
 
 
 
381
  if (!el) return;
 
382
  el.hidden = true;
383
- if (!$$('.modal-mask').some((m) => !m.hidden)) {
384
- document.body.style.overflow = '';
385
- }
386
  }
387
 
388
  function bindModalCommon() {
389
- // data-close 关闭、点击遮罩关闭、Esc 关闭
390
  document.addEventListener('click', (e) => {
 
 
391
  const closer = e.target.closest('[data-close]');
392
- if (closer) {
393
  closeModal(closer.dataset.close);
394
- return;
395
- }
396
- if (e.target.classList && e.target.classList.contains('modal-mask')) {
397
- closeModal(e.target.id);
398
- }
399
- });
400
- document.addEventListener('keydown', (e) => {
401
- if (e.key === 'Escape') {
402
- const open = $$('.modal-mask').filter((m) => !m.hidden).pop();
403
- if (open) closeModal(open.id);
404
  }
405
  });
 
 
406
  }
407
 
408
  // -------------------- 创建插件 --------------------
@@ -415,7 +930,13 @@
415
  });
416
  showError($('#form-create-error'), '');
417
  openModal('modal-create');
418
- setTimeout(() => { const el = form.querySelector('[name="plugin_id"]'); if (el) el.focus(); }, 50);
 
 
 
 
 
 
419
  }
420
 
421
  function bindCreate() {
@@ -450,21 +971,22 @@
450
  };
451
 
452
  const submit = $('#btn-create-submit');
453
- setBusy(submit, true);
 
 
454
  try {
455
  const data = await apiRequest('POST', '/api/admin/plugins', payload);
 
 
 
456
  closeModal('modal-create');
457
- const fullKey = data && (data.plugin_key || data.key);
458
- if (fullKey) {
459
- showKeyModal(fullKey, '请妥善保存此 Key,关闭后将无法再次查看。');
460
- } else {
461
- toast('插件已创建', 'success');
462
- }
463
  await loadPlugins();
464
  } catch (err) {
 
465
  showError(errBox, err.message || '创建失败');
466
  } finally {
467
- setBusy(submit, false);
468
  }
469
  });
470
  }
@@ -524,16 +1046,22 @@
524
  };
525
 
526
  const submit = $('#btn-edit-submit');
527
- setBusy(submit, true);
 
 
528
  try {
529
- await apiRequest('PUT', '/api/admin/plugins/' + encodeURIComponent(id), payload);
 
 
 
530
  closeModal('modal-edit');
531
  toast('修改已保存', 'success');
532
  await loadPlugins();
533
  } catch (err) {
 
534
  showError(errBox, err.message || '保存失败');
535
  } finally {
536
- setBusy(submit, false);
537
  }
538
  });
539
  }
@@ -546,14 +1074,11 @@
546
  message: `轮换后旧 Key 立即失效,确定继续?\n插件:${plugin.name || id}`,
547
  okText: '确认轮换',
548
  danger: false,
549
- onConfirm: async () => {
550
  const data = await apiRequest('POST', '/api/admin/plugins/' + encodeURIComponent(id) + '/rotate-key');
551
- const fullKey = data && (data.plugin_key || data.key);
552
- if (fullKey) {
553
- showKeyModal(fullKey, '新 Key 已生成,请妥善保存。旧 Key 已失效。');
554
- } else {
555
- toast('Key 已轮换', 'success');
556
- }
557
  await loadPlugins();
558
  },
559
  });
@@ -561,16 +1086,20 @@
561
 
562
  function togglePlugin(plugin, btn) {
563
  const id = plugin.plugin_id || plugin.id;
564
- setBusy(btn, true);
 
565
  apiRequest('POST', '/api/admin/plugins/' + encodeURIComponent(id) + '/toggle')
566
- .then(async () => {
567
- toast(plugin.enabled ? '插件已禁用' : '插件已启用', 'success');
 
 
568
  await loadPlugins();
569
  })
570
  .catch((err) => {
 
571
  toast(err.message || '切换状态失败', 'error');
572
  })
573
- .finally(() => setBusy(btn, false));
574
  }
575
 
576
  function deletePlugin(plugin, btn) {
@@ -580,8 +1109,10 @@
580
  message: `确定删除插件「${plugin.name || id}」?此操作不可逆。`,
581
  okText: '确认删除',
582
  danger: true,
583
- onConfirm: async () => {
584
- await apiRequest('DELETE', '/api/admin/plugins/' + encodeURIComponent(id));
 
 
585
  toast('插件已删除', 'success');
586
  await loadPlugins();
587
  },
@@ -593,6 +1124,8 @@
593
  $('#modal-confirm-title').textContent = title || '请确认';
594
  $('#modal-confirm-msg').textContent = message || '确认执行此操作?';
595
  const okBtn = $('#btn-confirm-ok');
 
 
596
  $('.btn-label', okBtn).textContent = okText || '确定';
597
  okBtn.classList.toggle('btn-danger', !!danger);
598
  okBtn.classList.toggle('btn-primary', !danger);
@@ -605,85 +1138,107 @@
605
  okBtn.addEventListener('click', async () => {
606
  const handler = state.confirmHandler;
607
  if (!handler) { closeModal('modal-confirm'); return; }
608
- setBusy(okBtn, true);
 
609
  try {
610
- await handler();
 
 
 
 
 
611
  closeModal('modal-confirm');
612
  } catch (err) {
 
613
  toast(err.message || '操作失败', 'error');
614
  } finally {
615
- setBusy(okBtn, false);
616
- state.confirmHandler = null;
617
  }
618
  });
619
  }
620
 
621
  // -------------------- Key 展示模态框 --------------------
622
  function showKeyModal(key, hint) {
 
623
  $('#key-display').textContent = key;
624
- // 重置复制按钮状态
625
- const copyBtn = $('#btn-copy-key');
626
- copyBtn.classList.remove('is-copied');
627
- $('.btn-label', copyBtn).textContent = '复制 Key';
628
- // 自定义提示文案
629
  const warn = $('.key-warn span');
630
  if (warn && hint) warn.textContent = hint;
631
  openModal('modal-key');
632
  }
633
 
634
  function bindKeyModal() {
635
- $('#btn-copy-key').addEventListener('click', async () => {
 
 
 
 
 
 
 
636
  const key = $('#key-display').textContent;
637
- const ok = await copyToClipboard(key);
638
- const btn = $('#btn-copy-key');
639
- const label = $('.btn-label', btn);
640
- if (ok) {
641
- btn.classList.add('is-copied');
642
- label.textContent = '已复制';
643
- toast('Key 已复制到剪贴板', 'success');
644
- setTimeout(() => {
645
- btn.classList.remove('is-copied');
646
- label.textContent = '复制 Key';
647
- }, 2000);
648
- } else {
649
- toast('复制失败,请手动选择文本复制', 'error');
 
 
 
 
 
 
 
 
 
 
 
650
  }
651
  });
652
  }
653
 
654
- async function copyToClipboard(text) {
 
655
  try {
656
  if (navigator.clipboard && window.isSecureContext) {
657
  await navigator.clipboard.writeText(text);
658
  return true;
659
  }
660
- } catch (e) { /* fallthrough */ }
661
- // 兼容回退
 
 
662
  try {
663
- const ta = document.createElement('textarea');
664
- ta.value = text;
665
- ta.style.position = 'fixed';
666
- ta.style.opacity = '0';
667
- document.body.appendChild(ta);
668
- ta.select();
669
- const ok = document.execCommand('copy');
670
- document.body.removeChild(ta);
671
- return ok;
672
- } catch (e) {
673
  return false;
 
 
 
 
 
674
  }
675
  }
676
 
677
  // -------------------- 🧭 页签切换 --------------------
678
  const TAB_TITLES = {
679
- plugins: '第三方插件',
680
- withdrawals: '提现管理',
681
- disputes: '申诉仲裁',
682
- config: '版本管理',
683
- broadcast: '公告与广告',
684
- promptcats: '提示词分类',
685
- scripts: '脚本执行'
686
  };
 
687
 
688
  // 管理操作所需的 admin_account(后端实际以 Token 鉴权,此字段仅为请求体必填���)
689
  function adminAccount() {
@@ -692,18 +1247,20 @@
692
  }
693
 
694
  function switchTab(name) {
 
695
  state.activeTab = name;
696
  $$('.tab-item').forEach((btn) => {
697
  const active = btn.dataset.tab === name;
698
  btn.classList.toggle('is-active', active);
699
  btn.setAttribute('aria-selected', String(active));
 
700
  });
701
  Object.keys(TAB_TITLES).forEach((key) => {
702
  const panel = document.getElementById('panel-' + key);
703
  if (panel) panel.hidden = (key !== name);
704
  });
705
  const crumb = $('.crumbs-current');
706
- if (crumb) crumb.textContent = TAB_TITLES[name];
707
  // 管理操作低频,每次切换拉取最新数据保证时效
708
  if (name === 'withdrawals') refreshWithdrawals();
709
  if (name === 'disputes') refreshDisputes();
@@ -713,9 +1270,34 @@
713
  }
714
 
715
  function bindTabs() {
716
- $$('.tab-item').forEach((btn) => {
717
- btn.addEventListener('click', () => switchTab(btn.dataset.tab));
718
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
719
  }
720
 
721
  // -------------------- 💰 提现管理 --------------------
@@ -733,13 +1315,18 @@
733
  }
734
 
735
  async function refreshWithdrawals() {
 
736
  try {
737
  setWithdrawState('loading');
738
  const data = await apiRequest('GET', '/api/admin/withdrawals?status=' + state.withdrawStatus);
739
- renderWithdrawList(Array.isArray(data && data.data) ? data.data : []);
 
740
  } catch (err) {
 
741
  if (err instanceof ApiError && err.status === 401) return; // 已自动跳回登录
742
  setWithdrawState('error', err.message || '加载失败');
 
 
743
  }
744
  }
745
 
@@ -798,14 +1385,17 @@
798
  message: `确认已向 ${w.account || '该用户'} 打款 ${Math.abs(w.amount || 0)} 积分对应的金额?\n订单号:${orderId}`,
799
  okText: '确认打款',
800
  danger: false,
801
- onConfirm: async () => {
802
- setBusy(btn, true);
 
803
  try {
804
- await apiRequest('POST', '/api/admin/withdrawals/' + encodeURIComponent(w.tx_id) + '/complete', { payment_order_id: orderId });
 
 
805
  toast('打款已确认', 'success');
806
  await refreshWithdrawals();
807
  } finally {
808
- setBusy(btn, false);
809
  }
810
  }
811
  });
@@ -844,16 +1434,21 @@
844
  }
845
 
846
  async function refreshDisputes() {
 
847
  try {
848
  setDisputeState('loading');
849
  const url = state.disputeStatus
850
  ? '/api/admin/disputes?status=' + state.disputeStatus
851
  : '/api/admin/disputes';
852
  const data = await apiRequest('GET', url);
853
- renderDisputeList(Array.isArray(data && data.data) ? data.data : []);
 
854
  } catch (err) {
 
855
  if (err instanceof ApiError && err.status === 401) return;
856
  setDisputeState('error', err.message || '加载失败');
 
 
857
  }
858
  }
859
 
@@ -892,16 +1487,21 @@
892
  }
893
 
894
  async function openDisputeModal(disputeId) {
 
895
  openModal('modal-dispute');
896
  const body = $('#dispute-detail-body');
897
  body.innerHTML = '<div class="state-block"><div class="spinner-lg" aria-hidden="true"></div><p>正在加载申诉详情…</p></div>';
898
  try {
899
  const res = await apiRequest('GET', '/api/disputes/' + encodeURIComponent(disputeId));
900
- const dispute = (res && res.data) || res || {};
 
901
  renderDisputeDetail(body, dispute);
902
  } catch (err) {
 
903
  body.innerHTML = '<div class="state-block state-error"><div class="empty-mark">!</div><h3>加载失败</h3><p>' +
904
  escapeHtml(err.message || '请稍后重试。') + '</p></div>';
 
 
905
  }
906
  }
907
 
@@ -950,12 +1550,12 @@
950
  function renderResolveForm() {
951
  return `
952
  <div class="resolve-box">
953
- <div class="resolve-box-title">🔨 裁决操作</div>
954
- <div class="resolve-options">
955
  ${RESOLUTION_OPTIONS.map((opt) => `
956
  <label class="resolve-option" data-value="${opt.value}">
957
  <input type="radio" name="resolution" value="${opt.value}">
958
- <span class="resolve-option-icon">${opt.icon}</span>
959
  <span class="resolve-option-text">
960
  <strong>${opt.label}</strong>
961
  ${opt.desc ? `<em>${opt.desc}</em>` : ''}
@@ -963,7 +1563,7 @@
963
  </label>`).join('')}
964
  </div>
965
  <div class="split-row" id="split-row" hidden>
966
- <span>申诉方分成</span>
967
  <input type="range" id="resolve-ratio" min="0" max="100" value="50">
968
  <strong id="resolve-ratio-text">50% : 50%</strong>
969
  </div>
@@ -992,6 +1592,8 @@
992
  }
993
 
994
  function bindResolveForm(body, dispute) {
 
 
995
  let selected = null;
996
  const options = $$('.resolve-option', body);
997
  const splitRow = $('#split-row', body);
@@ -1000,12 +1602,12 @@
1000
  const submitBtn = $('#btn-resolve', body);
1001
 
1002
  options.forEach((opt) => {
1003
- opt.addEventListener('click', () => {
 
 
1004
  options.forEach((o) => o.classList.remove('selected'));
1005
  opt.classList.add('selected');
1006
- const radio = opt.querySelector('input');
1007
- if (radio) radio.checked = true;
1008
- selected = opt.dataset.value;
1009
  splitRow.hidden = selected !== 'split';
1010
  submitBtn.disabled = false;
1011
  });
@@ -1015,21 +1617,30 @@
1015
  ratioText.textContent = `${v}% : ${100 - v}%`;
1016
  });
1017
  submitBtn.addEventListener('click', async () => {
1018
- if (!selected) return;
1019
  const note = $('#resolve-note', body).value.trim();
1020
- setBusy(submitBtn, true);
 
1021
  try {
1022
  // ⚠️ 后端 resolve 端点为裸标量参数(FastAPI 按 query 解析),必须走 query 传参而非 JSON body
1023
  const params = new URLSearchParams({ resolution: selected });
1024
- if (selected === 'split') params.set('ratio', String(parseInt(ratioInput.value, 10) || 50));
 
 
 
1025
  if (note) params.set('note', note);
1026
- await apiRequest('POST', '/api/admin/disputes/' + encodeURIComponent(dispute.id) + '/resolve?' + params.toString());
 
 
 
1027
  toast('裁决已生效', 'success');
1028
  closeModal('modal-dispute');
1029
  refreshDisputes();
1030
  } catch (err) {
 
1031
  toast(err.message || '裁决失败', 'error');
1032
- setBusy(submitBtn, false);
 
1033
  }
1034
  });
1035
  }
@@ -1057,14 +1668,19 @@
1057
  }
1058
 
1059
  async function refreshPromptCats() {
 
1060
  try {
1061
  setPcatState('loading');
1062
  const res = await apiRequest('GET', '/api/prompt-categories?prompt_type=' + state.pcatType);
1063
- const list = Array.isArray(res && res.data) ? res.data : [];
 
1064
  renderPromptCats(list);
1065
  } catch (err) {
 
1066
  if (err instanceof ApiError && err.status === 401) return; // 已自动跳回登录
1067
  setPcatState('error', err.message || '加载失败');
 
 
1068
  }
1069
  }
1070
 
@@ -1095,13 +1711,16 @@
1095
  }
1096
 
1097
  function confirmDeletePromptCat(cat) {
 
1098
  confirmAction({
1099
  title: '删除分类',
1100
- message: `确定删除${PCAT_TYPE_LABELS[state.pcatType] || ''}分类「${cat}」?\n已使用该分类的提示词不受影响。`,
1101
  okText: '确认删除',
1102
  danger: true,
1103
- onConfirm: async () => {
1104
- await apiRequest('DELETE', '/api/prompt-categories?prompt_type=' + state.pcatType + '&name=' + encodeURIComponent(cat));
 
 
1105
  toast('分类已删除', 'success');
1106
  await refreshPromptCats();
1107
  }
@@ -1109,9 +1728,11 @@
1109
  }
1110
 
1111
  function bindPromptCats() {
 
1112
  $('#btn-pcat-refresh').addEventListener('click', () => refreshPromptCats());
1113
  $$('#pcat-type-tabs .filter-tab').forEach((tab) => {
1114
  tab.addEventListener('click', () => {
 
1115
  state.pcatType = tab.dataset.type;
1116
  $$('#pcat-type-tabs .filter-tab').forEach((t) => t.classList.toggle('is-active', t === tab));
1117
  refreshPromptCats();
@@ -1119,20 +1740,29 @@
1119
  });
1120
  const addBtn = $('#btn-pcat-add');
1121
  const nameInput = $('#pcat-name');
 
 
1122
  const submitAdd = async () => {
1123
- const name = nameInput.value.trim();
 
 
 
1124
  if (!name) { toast('请输入分类名称', 'warning'); nameInput.focus(); return; }
1125
  if (name.length > 8) { toast('分类名称不能超过 8 个字', 'warning'); return; }
1126
- setBusy(addBtn, true);
 
1127
  try {
1128
- await apiRequest('POST', '/api/prompt-categories', { prompt_type: state.pcatType, name });
1129
- nameInput.value = '';
 
 
1130
  toast('分类已添加', 'success');
1131
  await refreshPromptCats();
1132
  } catch (err) {
 
1133
  toast(err.message || '添加分类失败', 'error');
1134
  } finally {
1135
- setBusy(addBtn, false);
1136
  }
1137
  };
1138
  addBtn.addEventListener('click', submitAdd);
@@ -1149,10 +1779,24 @@
1149
  node.innerHTML = `<span class="toast-icon">${icon}</span><span class="toast-msg"></span>`;
1150
  node.querySelector('.toast-msg').textContent = message;
1151
  container.appendChild(node);
1152
- setTimeout(() => {
1153
- node.classList.add('is-leaving');
1154
- setTimeout(() => { if (node.parentNode) node.parentNode.removeChild(node); }, 220);
1155
- }, type === 'error' ? 4200 : 2800);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1156
  }
1157
 
1158
  // -------------------- 🏷️ 版本管理 --------------------
@@ -1168,29 +1812,64 @@
1168
  $('#ver-preview').textContent = `版本预览:V${major}.${minor}.${patch} · ${STAGE_LABELS[stage] || stage}`;
1169
  }
1170
 
 
 
 
 
 
 
 
 
1171
  async function loadVersionConfig() {
 
 
 
 
 
 
 
 
 
 
 
1172
  try {
1173
  showError($('#form-version-error'), '');
1174
  const res = await apiRequest('GET', '/api/admin/config/project_version');
1175
- const cfg = (res && res.data) || {};
1176
- const form = $('#form-version');
1177
- form.querySelector('[name="stage"]').value = cfg.stage || 'beta';
1178
- form.querySelector('[name="major"]').value = cfg.major ?? 2;
1179
- form.querySelector('[name="minor"]').value = cfg.minor ?? 0;
1180
- form.querySelector('[name="patch"]').value = cfg.patch ?? 0;
1181
- updateVerPreview();
 
 
 
 
 
 
 
 
 
 
 
 
 
1182
  } catch (err) {
 
 
1183
  if (!(err instanceof ApiError && err.status === 401)) {
1184
- showError($('#form-version-error'), err.message || '加载当前版本配置失败');
1185
  }
1186
  }
1187
  }
1188
 
1189
  function bindVersion() {
1190
  const form = $('#form-version');
1191
- ['change', 'input'].forEach((evt) => form.addEventListener(evt, updateVerPreview));
1192
  form.addEventListener('submit', async (event) => {
1193
  event.preventDefault();
 
1194
  showError($('#form-version-error'), '');
1195
  const payload = {
1196
  stage: form.querySelector('[name="stage"]').value,
@@ -1199,33 +1878,48 @@
1199
  patch: toIntOr(form.querySelector('[name="patch"]').value, 0)
1200
  };
1201
  const btn = $('#btn-version-save');
1202
- setBusy(btn, true);
 
 
1203
  try {
1204
- await apiRequest('PUT', '/api/admin/config/project_version', payload);
 
 
1205
  toast(`版本已更新为 V${payload.major}.${payload.minor}.${payload.patch}(${STAGE_LABELS[payload.stage] || payload.stage})`, 'success');
1206
  } catch (err) {
 
1207
  showError($('#form-version-error'), err.message || '版本更新失败');
1208
  } finally {
1209
- setBusy(btn, false);
 
 
1210
  }
1211
  });
1212
  }
1213
 
1214
  // -------------------- 📢 公告与广告 --------------------
 
1215
  async function refreshBannerStatus() {
 
1216
  const badge = $('#banner-status-badge');
1217
  try {
1218
- const res = await apiRequest('GET', '/api/public/banner_config');
1219
- const data = res && res.data;
1220
- if (data && data.enabled && data.bannerImage) {
1221
- badge.textContent = '广告展示中';
 
 
 
 
1222
  badge.className = 'badge is-on';
1223
  } else {
1224
- badge.textContent = '无活跃广告';
1225
  badge.className = 'badge is-off';
1226
  }
1227
  } catch (err) {
1228
- badge.textContent = '状态未知';
 
 
1229
  badge.className = 'badge';
1230
  }
1231
  }
@@ -1236,7 +1930,7 @@
1236
  img.hidden = false;
1237
  }
1238
 
1239
- function bindUploadButton(btnId, inputId, previewId, onDone) {
1240
  const btn = document.getElementById(btnId);
1241
  const input = document.getElementById(inputId);
1242
  btn.addEventListener('click', () => input.click());
@@ -1245,116 +1939,172 @@
1245
  if (!file) return;
1246
  const label = $('.btn-label', btn);
1247
  const oldText = label.textContent;
1248
- label.textContent = '上传中…';
1249
- setBusy(btn, true);
 
 
 
 
 
1250
  try {
1251
  const fd = new FormData();
1252
  fd.append('file', file);
1253
  fd.append('file_type', 'banner');
1254
  const res = await apiUpload('/api/upload', fd);
1255
- if (res && res.url) {
1256
- onDone(res.url);
1257
- setPreviewImg(previewId, res.url);
1258
- toast('图片上传成功', 'success');
1259
- } else {
1260
- throw new ApiError('上传响应缺少图片地址', 0, res);
1261
- }
1262
  } catch (err) {
1263
- toast(err.message || '图片上传失败', 'error');
 
1264
  } finally {
1265
- label.textContent = oldText;
1266
- setBusy(btn, false);
1267
- input.value = '';
1268
  }
1269
  });
1270
  }
1271
 
1272
  function bindBroadcast() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1273
  // 📢 公告发布(二次确认,防误发)
1274
  $('#btn-ann-publish').addEventListener('click', () => {
1275
- const content = $('#ann-content').value.trim();
 
 
1276
  if (!content) { toast('请输入公告内容', 'warning'); return; }
1277
  confirmAction({
1278
  title: '发布全站公告',
1279
  message: `公告将推送给全体用户,确认发布?\n\n${content.slice(0, 80)}${content.length > 80 ? '…' : ''}`,
1280
  okText: '确认发布',
1281
  danger: true,
1282
- onConfirm: async () => {
1283
- await apiRequest('POST', '/api/system/announcement', { admin_account: adminAccount(), content });
1284
- $('#ann-content').value = '';
 
 
1285
  toast('公告已发布', 'success');
1286
  }
1287
  });
1288
  });
1289
 
1290
  // 📰 广告图片上传
1291
- bindUploadButton('btn-banner-img', 'banner-img-file', 'banner-img-preview', (url) => { state.bannerImageUrl = url; });
1292
- bindUploadButton('btn-detail-img', 'banner-detail-img-file', 'banner-detail-img-preview', (url) => { state.bannerDetailImageUrl = url; });
1293
 
1294
  // 💾 保存广告配置
1295
- $('#btn-banner-save').addEventListener('click', async () => {
1296
- const enabled = $('#banner-enabled').checked;
1297
- if (enabled && !state.bannerImageUrl) {
 
 
 
 
 
1298
  toast('启用广告时必须上传横幅图片', 'warning');
1299
  return;
1300
  }
1301
- const config = {
1302
- enabled: enabled,
1303
- bannerImage: state.bannerImageUrl,
1304
- detailImage: state.bannerDetailImageUrl,
1305
- title: $('#banner-title').value.trim(),
1306
- description: $('#banner-desc').value.trim(),
1307
- detailContent: $('#banner-detail-content').value.trim()
1308
- };
1309
  const btn = $('#btn-banner-save');
1310
- setBusy(btn, true);
 
1311
  try {
1312
- await apiRequest('PUT', '/api/admin/config/banner_config', config);
 
 
1313
  toast('广告配置已保存', 'success');
1314
- // 保存成功后清空表单并刷新状态(与本地端原行为一致)
1315
- $('#banner-enabled').checked = false;
1316
- state.bannerImageUrl = '';
1317
- state.bannerDetailImageUrl = '';
1318
- ['banner-img-preview', 'banner-detail-img-preview'].forEach((id) => {
1319
- const img = document.getElementById(id);
1320
- img.src = '';
1321
- img.hidden = true;
1322
- });
1323
- $('#banner-title').value = '';
1324
- $('#banner-desc').value = '';
1325
- $('#banner-detail-content').value = '';
 
 
1326
  refreshBannerStatus();
1327
  } catch (err) {
 
1328
  toast(err.message || '保存失败', 'error');
1329
  } finally {
1330
- setBusy(btn, false);
1331
  }
1332
  });
1333
  }
1334
 
1335
  // -------------------- 🖥️ 脚本执行 --------------------
1336
  function bindScripts() {
1337
- $('#btn-script-run').addEventListener('click', async () => {
 
 
 
 
 
 
 
 
 
 
1338
  const name = $('#script-name').value.trim();
1339
- const result = $('#script-result');
1340
  if (!name) {
1341
- result.textContent = '❌ 请输入脚本名称';
1342
  result.classList.add('is-error');
1343
  return;
1344
  }
1345
- const btn = $('#btn-script-run');
1346
- setBusy(btn, true);
1347
  result.classList.remove('is-error');
1348
- result.textContent = `⚡ 正在执行 ${name} …`;
1349
  try {
1350
  const res = await apiRequest('POST', '/api/admin/run-script', { admin_account: adminAccount(), script_name: name });
1351
- const output = (res && (res.output || res.message)) || JSON.stringify(res, null, 2);
1352
- result.textContent = `✅ 执行完成:\n${output}`;
 
 
1353
  } catch (err) {
1354
- result.textContent = `❌ 执行失败:\n${err.message}`;
 
1355
  result.classList.add('is-error');
1356
  } finally {
1357
- setBusy(btn, false);
1358
  }
1359
  });
1360
  }
@@ -1377,23 +2127,27 @@
1377
 
1378
  $('#btn-refresh').addEventListener('click', async () => {
1379
  const btn = $('#btn-refresh');
1380
- setBusy(btn, true);
 
1381
  try {
1382
  await loadPlugins();
 
1383
  toast('已刷新', 'success');
1384
  } catch (err) {
 
1385
  if (!(err instanceof ApiError && err.status === 401)) {
1386
  setListState('error', err.message || '加载失败');
1387
  toast(err.message || '加载失败', 'error');
1388
  }
1389
  } finally {
1390
- setBusy(btn, false);
1391
  }
1392
  });
1393
 
1394
  $('#btn-retry').addEventListener('click', async () => {
1395
  try { await loadPlugins(); }
1396
  catch (err) {
 
1397
  if (!(err instanceof ApiError && err.status === 401)) {
1398
  setListState('error', err.message || '加载失败');
1399
  }
@@ -1433,17 +2187,22 @@
1433
  bindScripts();
1434
 
1435
  if (state.token) {
1436
- // 已有 token,尝试直接进入主界面
1437
- syncUserChip();
1438
- showView('view-main');
1439
- loadPlugins().catch((err) => {
 
 
 
 
 
1440
  if (err instanceof ApiError && (err.status === 401)) return; // 已自动跳转
1441
  if (err instanceof ApiError && err.status === 403) {
1442
- clearSession();
1443
  showView('view-forbidden');
1444
  return;
1445
  }
1446
- setListState('error', err.message || '加载失败');
 
1447
  });
1448
  } else {
1449
  showView('view-login');
 
6
  (function () {
7
  'use strict';
8
 
9
+ // 新增反馈集中在双语词典;管理页语言遵循文档lang。
10
+ const ADMIN_MESSAGES = {
11
+ zh: {
12
+ tab_navigation: '功能导航', tab_plugins: '第三方插件', tab_withdrawals: '提现管理',
13
+ tab_disputes: '申诉仲裁', tab_config: '版本管理', tab_broadcast: '公告与广告', tab_promptcats: '提示词分类',
14
+ script_tab: '🖥️ 脚本执行', script_title: '脚本执行',
15
+ script_subtitle: '执行服务端预置的运维脚本(仅限管理员)。',
16
+ script_card_title: '🖥️ 运维脚本', script_badge: '开发工具',
17
+ script_name_label: '脚本名称', script_name_placeholder: '输入服务端预置的脚本名称…',
18
+ script_run: '▶️ 执行', script_waiting: '等待执行…', script_output_label: '脚本输出',
19
+ script_name_required: '请输入脚本名称。', script_running: '正在执行脚本…',
20
+ script_completed: '执行完成', script_failed: '执行失败',
21
+ write_result_unconfirmed: '操作结果未确认,请先重新查询当前状态再决定是否重试。',
22
+ image_uploading: '上传中…', image_upload_success: '图片上传成功', image_upload_failed: '图片上传失败',
23
+ upload_response_unavailable: '无法确认图片上传结果,请保留当前草稿并检查后再操作。',
24
+ banner_upload_pending: '图片仍在上传,请等待上传结束后再保存。',
25
+ copy_key: '复制 Key', copied: '已复制', key_copied: 'Key 已复制到剪贴板',
26
+ copy_failed: '复制失败,请手动选择文本复制',
27
+ request_invalid: '请求内容无效,请检查输入。',
28
+ access_forbidden: '无权执行此操作。',
29
+ resource_unavailable: '请求的功能或内容不可用。',
30
+ request_conflict: '当前状态已变化,请重新读取后再操作。',
31
+ request_too_large: '提交内容过大,请缩小后再提交。',
32
+ request_throttled: '操作过于频繁,请稍后再试。',
33
+ service_unavailable: '服务暂不可用,请先检查当前状态。',
34
+ request_failed: '请求未成功,请先检查当前状态。',
35
+ config_save_unconfirmed: '保存结果未确认,请先重新读取配置再决定是否重试。',
36
+ config_read_unavailable: '无法确认当前配置,请稍后重新读取。',
37
+ session_changed: '登录状态已变化,请在当前会话重新操作。',
38
+ network_unavailable: '网络异常,请检查连接后重试。',
39
+ auth_expired: '登录已失效,请重新登录。',
40
+ admin_verification_unavailable: '无法确认管理员权限,请稍后重新登录。',
41
+ admin_data_unavailable: '无法确认返回的数据,请稍后重新读取。',
42
+ login_response_unavailable: '无法确认登录结果,请重新登录。',
43
+ banner_active: '广告展示中', banner_inactive: '无活跃广告', banner_unknown: '状态未知',
44
+ },
45
+ en: {
46
+ tab_navigation: 'Administration sections', tab_plugins: 'Third-party plugins', tab_withdrawals: 'Withdrawals',
47
+ tab_disputes: 'Dispute arbitration', tab_config: 'Version management', tab_broadcast: 'Announcements and advertisements', tab_promptcats: 'Prompt categories',
48
+ script_tab: '🖥️ Scripts', script_title: 'Script execution',
49
+ script_subtitle: 'Run preinstalled server maintenance scripts (administrators only).',
50
+ script_card_title: '🖥️ Maintenance scripts', script_badge: 'Developer tools',
51
+ script_name_label: 'Script name', script_name_placeholder: 'Enter a preinstalled server script name…',
52
+ script_run: '▶️ Run', script_waiting: 'Waiting to run…', script_output_label: 'Script output',
53
+ script_name_required: 'Enter a script name.', script_running: 'Running script…',
54
+ script_completed: 'Execution completed', script_failed: 'Execution failed',
55
+ write_result_unconfirmed: 'The operation result is unconfirmed. Reload the current state before deciding whether to retry.',
56
+ image_uploading: 'Uploading…', image_upload_success: 'Image uploaded', image_upload_failed: 'Image upload failed',
57
+ upload_response_unavailable: 'The image upload result could not be confirmed. Keep the current draft and check before continuing.',
58
+ banner_upload_pending: 'Images are still uploading. Wait for the uploads to finish before saving.',
59
+ copy_key: 'Copy Key', copied: 'Copied', key_copied: 'Key copied to clipboard',
60
+ copy_failed: 'Copy failed. Select and copy the text manually.',
61
+ request_invalid: 'The request is invalid. Check your input.',
62
+ access_forbidden: 'You do not have permission to perform this action.',
63
+ resource_unavailable: 'The requested feature or content is unavailable.',
64
+ request_conflict: 'The current state has changed. Reload it before continuing.',
65
+ request_too_large: 'The submitted content is too large. Reduce its size.',
66
+ request_throttled: 'Too many requests. Try again later.',
67
+ service_unavailable: 'The service is unavailable. Check the current state first.',
68
+ request_failed: 'The request was not successful. Check the current state first.',
69
+ config_save_unconfirmed: 'Save result is unconfirmed. Reload the configuration before deciding whether to retry.',
70
+ config_read_unavailable: 'The current configuration could not be confirmed. Reload it later.',
71
+ session_changed: 'The session has changed. Continue in the current session.',
72
+ network_unavailable: 'Network unavailable. Check your connection before retrying.',
73
+ auth_expired: 'Your session has expired. Sign in again.',
74
+ admin_verification_unavailable: 'Administrator access could not be confirmed. Sign in again later.',
75
+ admin_data_unavailable: 'The returned data could not be confirmed. Reload it later.',
76
+ login_response_unavailable: 'The sign-in result could not be confirmed. Sign in again.',
77
+ banner_active: 'Advertisement active', banner_inactive: 'No active advertisement', banner_unknown: 'Status unknown',
78
+ },
79
+ };
80
+ function adminMessage(key) {
81
+ const language = document.documentElement.lang.toLowerCase().startsWith('en') ? 'en' : 'zh';
82
+ return ADMIN_MESSAGES[language][key];
83
+ }
84
+
85
  // -------------------- 配置 --------------------
86
  // 默认调用同源后端;如需指定接口域名可在 window.RANKING_API_BASE 设置
87
  const API_BASE = (typeof window !== 'undefined' && window.RANKING_API_BASE) || '';
 
96
  };
97
 
98
  // -------------------- 状态 --------------------
99
+ const storedSession = readAdminStoredSession();
100
  const state = {
101
+ token: storedSession.token,
102
+ adminVerified: false,
103
+ user: storedSession.user,
104
  plugins: [],
105
  filtered: [],
106
  searchKeyword: '',
 
167
  }
168
  }
169
 
170
+ const adminBusyOperations = new Map();
171
+ function startAdminBusy(btn, onRelease) {
172
+ if (!btn || adminBusyOperations.has(btn)) return null;
173
+ const session = captureAdminSession();
174
+ const disabled = btn.disabled;
175
+ const ariaBusy = btn.getAttribute('aria-busy');
176
+ const release = () => {
177
+ if (adminBusyOperations.get(btn) !== release) return false;
178
+ adminBusyOperations.delete(btn);
179
+ btn.disabled = disabled;
180
+ if (ariaBusy === null) btn.removeAttribute('aria-busy');
181
+ else btn.setAttribute('aria-busy', ariaBusy);
182
+ if (onRelease) onRelease();
183
+ return true;
184
+ };
185
+ release.initialDisabled = disabled;
186
+ release.isCurrent = () => session.isCurrent() && adminBusyOperations.get(btn) === release;
187
+ release.assertCurrent = () => {
188
+ assertAdminSession(session);
189
+ if (adminBusyOperations.get(btn) !== release) {
190
+ const error = new ApiError(adminMessage('session_changed'), 0);
191
+ error.stale = true;
192
+ throw error;
193
+ }
194
+ };
195
+ adminBusyOperations.set(btn, release);
196
+ setBusy(btn, true);
197
+ return release;
198
+ }
199
+ function releaseAdminBusy() {
200
+ for (const release of Array.from(adminBusyOperations.values())) release();
201
+ if (adminModalFocusController) adminModalFocusController.reset();
202
+ }
203
+
204
+ let adminKeyCopyGeneration = 0;
205
+ let adminKeyCopyTimer = null;
206
+ function resetAdminKeyCopy() {
207
+ adminKeyCopyGeneration++;
208
+ if (adminKeyCopyTimer !== null) clearTimeout(adminKeyCopyTimer);
209
+ adminKeyCopyTimer = null;
210
+ const btn = $('#btn-copy-key');
211
+ if (!btn) return;
212
+ const release = adminBusyOperations.get(btn);
213
+ if (release) release();
214
+ btn.classList.remove('is-copied');
215
+ const label = $('.btn-label', btn);
216
+ if (label) label.textContent = adminMessage('copy_key');
217
+ }
218
+
219
+ const adminModalGenerations = new WeakMap();
220
+ function captureAdminModal(id) {
221
+ const element = document.getElementById(id);
222
+ const generation = element && adminModalGenerations.get(element);
223
+ const isCurrent = () => !!element && !element.hidden && adminModalGenerations.get(element) === generation;
224
+ return { isCurrent, assertCurrent: () => assertAdminSession({ isCurrent }) };
225
+ }
226
+
227
+ let adminModalFocusController = null;
228
+ function adminModalFocus() {
229
+ if (adminModalFocusController) return adminModalFocusController;
230
+ const stack = [], inertState = new Map();
231
+ let overflow = null, observer = null;
232
+ const top = () => stack[stack.length - 1];
233
+ const controls = (mask) => $$('button, input:not([type="hidden"]), select, textarea, a[href], [tabindex]', mask)
234
+ .filter(el => !el.disabled && el.tabIndex >= 0 && el.getClientRects().length && !el.closest('[hidden], [inert]'));
235
+ const dialog = (entry) => $('[role="dialog"]', entry.mask) || entry.mask;
236
+ const focus = (entry) => {
237
+ if (!entry) return;
238
+ const target = controls(entry.mask)[0] || dialog(entry);
239
+ if (target === dialog(entry) && target.getAttribute('tabindex') === null) {
240
+ target.setAttribute('tabindex', '-1');
241
+ entry.temporaryTabIndex = target;
242
+ }
243
+ target.focus({ preventScroll: true });
244
+ };
245
+ const restoreEntry = (entry) => {
246
+ entry.mask.style.zIndex = entry.zIndex;
247
+ if (entry.temporaryTabIndex && entry.temporaryTabIndex.getAttribute('tabindex') === '-1') {
248
+ entry.temporaryTabIndex.removeAttribute('tabindex');
249
+ }
250
+ };
251
+ const sync = () => {
252
+ const active = top();
253
+ if (!active) {
254
+ if (observer) observer.disconnect();
255
+ for (const [node, value] of inertState) node.inert = value;
256
+ inertState.clear();
257
+ if (overflow !== null) document.body.style.overflow = overflow;
258
+ overflow = null;
259
+ return;
260
+ }
261
+ if (observer) observer.observe(document.body, { childList: true });
262
+ for (const node of $$('body > *')) {
263
+ if (!inertState.has(node)) inertState.set(node, node.inert);
264
+ node.inert = node !== active.mask;
265
+ }
266
+ stack.forEach((entry, index) => { entry.mask.style.zIndex = String(100 + index); });
267
+ document.body.style.overflow = 'hidden';
268
+ };
269
+ adminModalFocusController = {
270
+ watch() {
271
+ if (observer) return;
272
+ observer = new MutationObserver(() => {
273
+ for (const entry of Array.from(stack)) {
274
+ if (!entry.mask.isConnected) closeModal(entry.mask.id);
275
+ }
276
+ sync();
277
+ });
278
+ if (top()) sync();
279
+ },
280
+ findMask(id) { return stack.find(entry => entry.mask.id === id)?.mask || null; },
281
+ open(mask) {
282
+ const index = stack.findIndex(entry => entry.mask === mask);
283
+ let entry;
284
+ if (index >= 0) entry = stack.splice(index, 1)[0];
285
+ else entry = { mask, returnFocus: document.activeElement, zIndex: mask.style.zIndex };
286
+ if (overflow === null) overflow = document.body.style.overflow;
287
+ stack.push(entry); sync(); focus(entry);
288
+ },
289
+ close(mask) {
290
+ const index = stack.findIndex(entry => entry.mask === mask);
291
+ if (index < 0) return;
292
+ const wasTop = stack[index] === top(), entry = stack.splice(index, 1)[0];
293
+ for (const other of stack) {
294
+ if (other.returnFocus && mask.contains(other.returnFocus)) other.returnFocus = entry.returnFocus;
295
+ }
296
+ restoreEntry(entry); sync();
297
+ if (!wasTop) return;
298
+ const target = entry.returnFocus;
299
+ if (target && target.isConnected && !target.disabled && target.getClientRects().length && !target.closest('[hidden], [inert]')) {
300
+ target.focus({ preventScroll: true });
301
+ } else if (top()) focus(top());
302
+ else {
303
+ const fallback = $('#tab-' + state.activeTab);
304
+ if (fallback && fallback.isConnected && !fallback.disabled && fallback.getClientRects().length && !fallback.closest('[hidden], [inert]')) {
305
+ fallback.focus({ preventScroll: true });
306
+ }
307
+ }
308
+ },
309
+ reset() {
310
+ for (const entry of stack) {
311
+ entry.mask.hidden = true;
312
+ adminModalGenerations.set(entry.mask, {});
313
+ restoreEntry(entry);
314
+ }
315
+ stack.length = 0; sync();
316
+ },
317
+ topMask() { return top()?.mask || null; },
318
+ focusInside(event) {
319
+ const entry = top();
320
+ if (entry && !entry.mask.contains(event.target)) focus(entry);
321
+ },
322
+ keydown(event) {
323
+ const entry = top();
324
+ if (!entry || event.isComposing || event.altKey || event.ctrlKey || event.metaKey) return;
325
+ if (event.key === 'Escape') {
326
+ event.preventDefault(); event.stopPropagation(); closeModal(entry.mask.id);
327
+ } else if (event.key === 'Tab') {
328
+ const items = controls(entry.mask), first = items[0], last = items[items.length - 1];
329
+ const active = document.activeElement;
330
+ if (!items.length || !entry.mask.contains(active) || (event.shiftKey ? active === first : active === last)) {
331
+ event.preventDefault();
332
+ if (items.length) (event.shiftKey ? last : first).focus({ preventScroll: true });
333
+ else focus(entry);
334
+ }
335
+ }
336
+ },
337
+ };
338
+ return adminModalFocusController;
339
+ }
340
+
341
+ const adminReadOperations = new Map();
342
+ function startAdminRead(key) {
343
+ const session = captureAdminSession();
344
+ const owner = {};
345
+ adminReadOperations.set(key, owner);
346
+ const isCurrent = () => session.isCurrent() && adminReadOperations.get(key) === owner;
347
+ return {
348
+ isCurrent,
349
+ assertCurrent() {
350
+ if (isCurrent()) return;
351
+ const error = new ApiError(adminMessage('session_changed'), 0);
352
+ error.stale = true;
353
+ throw error;
354
+ },
355
+ release() {
356
+ if (adminReadOperations.get(key) === owner) adminReadOperations.delete(key);
357
+ },
358
+ };
359
+ }
360
+
361
+ const adminToastOperations = new Map();
362
+
363
+ function clearAdminToasts() {
364
+ for (const release of adminToastOperations.values()) release();
365
+ }
366
+
367
+ function readAdminStoredSession() {
368
+ try {
369
+ const token = sessionStorage.getItem(TOKEN_KEY);
370
+ const user = sessionStorage.getItem(USER_KEY);
371
+ if (typeof token !== 'string' || !token || token.trim() !== token) return { token: '', user: null };
372
+ return { token, user: safeParse(user) };
373
+ } catch (_) { return { token: '', user: null }; }
374
+ }
375
+
376
+ function writeAdminStoredValue(key, value) {
377
+ try {
378
+ if (value === null) sessionStorage.removeItem(key);
379
+ else sessionStorage.setItem(key, value);
380
+ return true;
381
+ } catch (_) { return false; }
382
+ }
383
+
384
+ function clearAdminStoredSession() {
385
+ const tokenRemoved = writeAdminStoredValue(TOKEN_KEY, null);
386
+ const userRemoved = writeAdminStoredValue(USER_KEY, null);
387
+ return tokenRemoved && userRemoved;
388
+ }
389
+
390
+ function confirmAdminStatusWrite(response) {
391
+ if (!response || typeof response !== 'object' || Array.isArray(response) ||
392
+ !Object.prototype.hasOwnProperty.call(response, 'status') || response.status !== 'success' ||
393
+ (Object.prototype.hasOwnProperty.call(response, 'success') && response.success !== true)) {
394
+ throw new ApiError(adminMessage('write_result_unconfirmed'), 0);
395
+ }
396
+ return response;
397
+ }
398
+
399
+ function confirmAdminPluginWrite(response, expectedId, operation) {
400
+ const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
401
+ const fail = () => { throw new ApiError(adminMessage('write_result_unconfirmed'), 0); };
402
+ if (!response || typeof response !== 'object' || Array.isArray(response) ||
403
+ !own(response, 'success') || response.success !== true ||
404
+ (own(response, 'status') && response.status !== 'success')) fail();
405
+ if (operation === 'edit') {
406
+ const plugin = response.plugin;
407
+ if (!own(response, 'plugin') || !plugin || typeof plugin !== 'object' || Array.isArray(plugin) ||
408
+ !own(plugin, 'plugin_id') || plugin.plugin_id !== expectedId) fail();
409
+ } else if (!own(response, 'plugin_id') || response.plugin_id !== expectedId) fail();
410
+ if (operation === 'create' || operation === 'rotate') {
411
+ if (!own(response, 'plugin_key') || typeof response.plugin_key !== 'string' ||
412
+ !/^rk_plug_[a-f0-9]{32}$/.test(response.plugin_key)) fail();
413
+ if (operation === 'create') {
414
+ const plugin = response.plugin;
415
+ if (!own(response, 'plugin') || !plugin || typeof plugin !== 'object' || Array.isArray(plugin) ||
416
+ !own(plugin, 'plugin_id') || plugin.plugin_id !== expectedId ||
417
+ !own(plugin, 'plugin_key') || plugin.plugin_key !== response.plugin_key) fail();
418
+ }
419
+ }
420
+ if (operation === 'toggle' && (!own(response, 'enabled') || typeof response.enabled !== 'boolean')) fail();
421
+ return response;
422
+ }
423
+
424
+ const ADMIN_SCRIPT_OUTPUT_MAX = 2048;
425
+ function parseAdminScriptResult(response) {
426
+ const own = key => Object.prototype.hasOwnProperty.call(response, key);
427
+ if (!response || typeof response !== 'object' || Array.isArray(response) ||
428
+ !own('status') || !['success', 'error'].includes(response.status) ||
429
+ !own('return_code') || !Number.isSafeInteger(response.return_code) ||
430
+ (response.status === 'success') !== (response.return_code === 0) ||
431
+ (own('success') && response.success !== (response.status === 'success')) ||
432
+ !own('output') || typeof response.output !== 'string' || response.output.length > ADMIN_SCRIPT_OUTPUT_MAX) {
433
+ throw new ApiError(adminMessage('write_result_unconfirmed'), 0);
434
+ }
435
+ return { succeeded: response.status === 'success', output: response.output };
436
+ }
437
+
438
+ function parseAdminUpload(response) {
439
+ const fail = () => { throw new ApiError(adminMessage('upload_response_unavailable'), 0); };
440
+ if (!response || typeof response !== 'object' || Array.isArray(response) ||
441
+ !Object.prototype.hasOwnProperty.call(response, 'status') || response.status !== 'success' ||
442
+ !Object.prototype.hasOwnProperty.call(response, 'url') ||
443
+ (Object.prototype.hasOwnProperty.call(response, 'success') && response.success !== true) ||
444
+ typeof response.url !== 'string' || !response.url || /[\s\\]/.test(response.url) ||
445
+ !/^(?:https?:\/\/|\/(?!\/))/i.test(response.url)) fail();
446
+ try {
447
+ const url = new URL(response.url, window.location.href);
448
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) fail();
449
+ } catch (_) { fail(); }
450
+ return response.url;
451
+ }
452
+
453
+ function parseAdminLogin(response, expectedAccount) {
454
+ const fields = ['status', 'token', 'account', 'name', 'avatar'];
455
+ if (!response || typeof response !== 'object' || Array.isArray(response) ||
456
+ !fields.every(key => Object.prototype.hasOwnProperty.call(response, key)) ||
457
+ response.status !== 'success' ||
458
+ (Object.prototype.hasOwnProperty.call(response, 'success') && response.success !== true) ||
459
+ typeof response.token !== 'string' || !response.token || response.token.trim() !== response.token ||
460
+ typeof expectedAccount !== 'string' || !expectedAccount || response.account !== expectedAccount ||
461
+ typeof response.name !== 'string' || typeof response.avatar !== 'string') {
462
+ throw new ApiError(adminMessage('login_response_unavailable'), 0);
463
+ }
464
+ return { token: response.token, user: { account: response.account, name: response.name, avatar: response.avatar } };
465
+ }
466
+
467
+ function confirmedAdminData(response) {
468
+ if (!response || typeof response !== 'object' || Array.isArray(response) ||
469
+ response.status !== 'success' ||
470
+ (Object.prototype.hasOwnProperty.call(response, 'success') && response.success !== true) ||
471
+ !Object.prototype.hasOwnProperty.call(response, 'data')) {
472
+ throw new ApiError(adminMessage('admin_data_unavailable'), 0);
473
+ }
474
+ return response.data;
475
+ }
476
+ function validAdminDispute(record) {
477
+ return record && typeof record === 'object' && !Array.isArray(record) &&
478
+ typeof record.id === 'string' && !!record.id.trim() &&
479
+ ['pending', 'responded', 'resolved'].includes(record.status);
480
+ }
481
+ function parseAdminList(response, kind) {
482
+ const list = confirmedAdminData(response);
483
+ if (!['withdrawals', 'disputes', 'categories'].includes(kind)) {
484
+ throw new ApiError(adminMessage('admin_data_unavailable'), 0);
485
+ }
486
+ const text = value => typeof value === 'string' && !!value.trim();
487
+ const valid = record => kind === 'categories' ? text(record) : kind === 'disputes' ? validAdminDispute(record) :
488
+ kind === 'withdrawals' && record && typeof record === 'object' && !Array.isArray(record) &&
489
+ text(record.tx_id) && text(record.account) && Number.isSafeInteger(record.amount) &&
490
+ ['pending', 'completed'].includes(record.withdraw_status);
491
+ if (!Array.isArray(list) || !list.every(valid) ||
492
+ new Set(list.map(record => kind === 'categories' ? record : kind === 'disputes' ? record.id : record.tx_id)).size !== list.length) {
493
+ throw new ApiError(adminMessage('admin_data_unavailable'), 0);
494
+ }
495
+ return list;
496
+ }
497
+ function parseAdminDispute(response, expectedId) {
498
+ const dispute = confirmedAdminData(response);
499
+ if (!validAdminDispute(dispute) || dispute.id !== expectedId) {
500
+ throw new ApiError(adminMessage('admin_data_unavailable'), 0);
501
+ }
502
+ return dispute;
503
+ }
504
+
505
  function showError(node, msg) {
506
  if (!node) return;
507
  if (!msg) { node.hidden = true; node.textContent = ''; return; }
 
510
  }
511
 
512
  // -------------------- API 客户端 --------------------
513
+ let adminSessionGeneration = 0;
514
+ const adminRequests = new Set();
515
+ function revokeAdminRequests() {
516
+ for (const controller of Array.from(adminRequests)) {
517
+ try { controller.abort(); } catch (_) { /* 单项失败不阻断其余撤销。 */ }
 
 
518
  }
519
+ }
520
+ function captureAdminSession() {
521
+ const generation = adminSessionGeneration, token = state.token;
522
+ return { token, isCurrent: () => generation === adminSessionGeneration && token === state.token };
523
+ }
524
+ function assertAdminSession(session) {
525
+ if (!session.isCurrent()) {
526
+ const error = new ApiError(adminMessage('session_changed'), 0);
527
+ error.stale = true;
528
+ throw error;
 
529
  }
530
+ }
531
+ async function performAdminRequest(path, options) {
532
+ const session = captureAdminSession();
533
+ const controller = new AbortController();
534
+ const headers = { Accept: 'application/json', ...options.headers };
535
+ if (session.token) headers.Authorization = 'Bearer ' + session.token;
536
+ adminRequests.add(controller);
537
+ try {
538
+ let response;
539
+ try {
540
+ response = await fetch(API_BASE + path, { ...options, headers, signal: controller.signal });
541
+ } catch (err) {
542
+ assertAdminSession(session);
543
+ throw new ApiError(adminMessage('network_unavailable'), 0);
544
+ }
545
+ assertAdminSession(session);
546
+ if (response.status === 401) {
547
+ handleUnauthorized();
548
+ throw new ApiError(adminMessage('auth_expired'), 401);
549
+ }
550
+ let text;
551
+ try {
552
+ text = await response.text();
553
+ } catch (err) {
554
+ assertAdminSession(session);
555
+ throw new ApiError(adminMessage('network_unavailable'), 0);
556
+ }
557
+ assertAdminSession(session);
558
+ let payload = null;
559
+ if (text) {
560
+ try { payload = JSON.parse(text); } catch (_) { payload = { message: text }; }
561
+ }
562
+ if (!response.ok) {
563
+ const key = { 400: 'request_invalid', 403: 'access_forbidden', 404: 'resource_unavailable',
564
+ 409: 'request_conflict', 413: 'request_too_large', 415: 'request_invalid',
565
+ 422: 'request_invalid', 429: 'request_throttled' }[response.status] ||
566
+ (response.status >= 500 ? 'service_unavailable' : 'request_failed');
567
+ const msg = payload && ['config_save_unconfirmed', 'config_read_unavailable'].includes(payload.detail)
568
+ ? adminMessage(payload.detail) : adminMessage(key);
569
+ throw new ApiError(msg, response.status);
570
+ }
571
+ return payload;
572
+ } finally {
573
+ adminRequests.delete(controller);
574
  }
575
+ }
576
+ async function apiRequest(method, path, body) {
577
+ const options = { method };
578
+ if (body !== undefined && body !== null) {
579
+ options.headers = { 'Content-Type': 'application/json' };
580
+ options.body = JSON.stringify(body);
581
  }
582
+ return performAdminRequest(path, options);
583
  }
 
584
  class ApiError extends Error {
585
  constructor(message, status, raw) {
586
  super(message);
 
588
  this.raw = raw;
589
  }
590
  }
591
+ function isStaleSessionError(error) {
592
+ return error instanceof ApiError && error.stale === true;
593
+ }
594
 
595
+ // 🆕 multipart 上传(广告图片):FormData 不序列化,复用同一会话和取消边界。
596
  async function apiUpload(path, formData) {
597
+ return performAdminRequest(path, { method: 'POST', body: formData });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
598
  }
599
 
600
  // -------------------- 视图切换 --------------------
601
  function showView(name) {
602
+ if (name === 'view-main' && !state.adminVerified) name = 'view-login';
603
  ['view-login', 'view-main', 'view-forbidden'].forEach((id) => {
604
  const el = document.getElementById(id);
605
  if (el) el.hidden = (id !== name);
 
610
  function handleUnauthorized() {
611
  clearSession();
612
  showView('view-login');
613
+ toast(adminMessage('auth_expired'), 'warning');
614
+ }
615
+
616
+ function resetAdminVerification() {
617
+ clearAdminToasts();
618
+ resetAdminKeyCopy();
619
+ releaseAdminBusy();
620
+ adminReadOperations.clear();
621
+ state.adminVerified = false;
622
+ state.plugins = [];
623
+ state.filtered = [];
624
+ state.searchKeyword = '';
625
+ state.confirmHandler = null;
626
+ state.bannerImageUrl = '';
627
+ state.bannerDetailImageUrl = '';
628
+ versionConfigGeneration++;
629
+ versionConfigConfirmed = false;
630
+ const versionSave = $('#btn-version-save');
631
+ if (versionSave) versionSave.disabled = true;
632
+ bannerStatusGeneration++;
633
+ const main = $('#view-main');
634
+ if (main) main.hidden = true;
635
+ for (const id of ['plugin-grid', 'withdraw-list', 'dispute-list', 'pcat-list', 'dispute-detail-body']) {
636
+ const node = $('#' + id);
637
+ if (node) node.replaceChildren();
638
+ }
639
+ for (const id of ['key-display', 'edit-key-display', 'user-name', 'user-avatar', 'script-result']) {
640
+ const node = $('#' + id);
641
+ if (node) node.textContent = '';
642
+ }
643
+ for (const id of ['banner-img-preview', 'banner-detail-img-preview']) {
644
+ const node = $('#' + id);
645
+ if (node) { node.hidden = true; node.removeAttribute('src'); }
646
+ }
647
+ for (const id of ['form-create', 'form-edit', 'form-version']) {
648
+ const form = $('#' + id);
649
+ if (form) form.reset();
650
+ }
651
+ for (const id of ['search-input', 'login-password', 'ann-content', 'script-name',
652
+ 'banner-title', 'banner-desc', 'banner-detail-content', 'banner-img-file', 'banner-detail-img-file']) {
653
+ const input = $('#' + id);
654
+ if (input) input.value = '';
655
+ }
656
+ const bannerEnabled = $('#banner-enabled');
657
+ if (bannerEnabled) bannerEnabled.checked = false;
658
+ $$('.modal-mask').forEach(node => { node.hidden = true; });
659
+ if (!adminModalFocusController) document.body.style.overflow = '';
660
+ switchTab('plugins');
661
  }
662
 
663
  function clearSession() {
664
+ adminSessionGeneration++;
665
  state.token = '';
666
  state.user = null;
667
+ resetAdminVerification();
668
+ revokeAdminRequests();
669
+ clearAdminStoredSession();
670
  }
671
 
672
  function persistSession(token, user) {
673
+ adminSessionGeneration++;
674
  state.token = token || '';
675
  state.user = user || null;
676
+ resetAdminVerification();
677
+ revokeAdminRequests();
678
+ let serializedUser = null;
679
+ try { if (state.user) serializedUser = JSON.stringify(state.user); }
680
+ catch (_) { clearAdminStoredSession(); return false; }
681
+ const userStored = writeAdminStoredValue(USER_KEY, serializedUser);
682
+ const tokenStored = writeAdminStoredValue(TOKEN_KEY, state.token || null);
683
+ if (!userStored || !tokenStored) { clearAdminStoredSession(); return false; }
684
+ return true;
685
  }
686
 
687
  // -------------------- 登录 --------------------
 
702
  }
703
 
704
  const submit = $('#login-submit');
705
+ const finishBusy = startAdminBusy(submit);
706
+ if (!finishBusy) return;
707
+ let loginSession = captureAdminSession();
708
  try {
709
  const data = await apiRequest('POST', '/api/users/login', { account, password });
710
+ finishBusy.assertCurrent();
711
+ const login = parseAdminLogin(data, account);
712
+ persistSession(login.token, login.user);
713
+
714
+ loginSession = captureAdminSession();
715
 
716
  // 校验是否为管理员:尝试访问插件列表(403 → 非管理员)
717
  try {
718
  await loadPlugins();
719
+ assertAdminSession(loginSession);
720
  syncUserChip();
721
  showView('view-main');
722
  toast('登录成功', 'success');
723
  } catch (err) {
724
+ if (isStaleSessionError(err) || !loginSession.isCurrent()) return;
725
+ if (err instanceof ApiError && err.status === 401) return; // 网络层已清会话并回登录。
726
+ if (err instanceof ApiError && err.status === 403) {
727
  showView('view-forbidden');
728
  } else {
729
+ // 尚未核实管理员权限,不能沿用旧管理界面。
730
+ showView('view-login');
731
+ showError(errBox, adminMessage('admin_verification_unavailable'));
 
732
  }
733
  }
734
  } catch (err) {
735
+ if (isStaleSessionError(err) || !loginSession.isCurrent()) return;
736
  showError(errBox, err.message || '登录失败');
737
  } finally {
738
+ finishBusy();
739
  }
740
  });
741
  }
 
760
  }
761
  }
762
 
763
+ function parseAdminPlugins(data) {
764
+ if (!data || typeof data !== 'object' || Array.isArray(data) || data.success !== true ||
765
+ !Array.isArray(data.plugins) || !Number.isSafeInteger(data.total) || data.total !== data.plugins.length ||
766
+ !data.plugins.every(plugin => plugin && typeof plugin === 'object' && !Array.isArray(plugin) &&
767
+ typeof plugin.plugin_id === 'string' && plugin.plugin_id.trim())) {
768
+ throw new ApiError(adminMessage('admin_verification_unavailable'), 0);
769
+ }
770
+ return data.plugins;
771
+ }
772
  async function loadPlugins() {
773
+ const session = captureAdminSession();
774
+ if (!session.token) throw new ApiError(adminMessage('admin_verification_unavailable'), 0);
775
+ const read = startAdminRead('plugins');
776
  setListState('loading');
777
+ try {
778
+ const data = await apiRequest('GET', '/api/admin/plugins');
779
+ read.assertCurrent();
780
+ const list = parseAdminPlugins(data);
781
+ state.plugins = list;
782
+ applyFilter();
783
+ state.adminVerified = true;
784
+ } catch (err) {
785
+ read.assertCurrent();
786
+ if (session.isCurrent() && err instanceof ApiError && err.status === 403) {
787
+ clearSession();
788
+ showView('view-forbidden');
789
+ }
790
+ throw err;
791
+ } finally {
792
+ read.release();
793
+ }
794
  }
795
 
796
  function applyFilter() {
 
888
  function openModal(id) {
889
  const el = document.getElementById(id);
890
  if (!el) return;
891
+ adminModalGenerations.set(el, {});
892
  el.hidden = false;
893
+ adminModalFocus().open(el);
894
  }
895
  function closeModal(id) {
896
+ if (id === 'modal-key') resetAdminKeyCopy();
897
+ if (id === 'modal-confirm') state.confirmHandler = null;
898
+ if (id === 'modal-dispute') adminReadOperations.delete('dispute-detail');
899
+ const el = document.getElementById(id) || adminModalFocusController?.findMask(id);
900
  if (!el) return;
901
+ adminModalGenerations.set(el, {});
902
  el.hidden = true;
903
+ if (adminModalFocusController) adminModalFocusController.close(el);
904
+ else if (!$$('.modal-mask').some((m) => !m.hidden)) document.body.style.overflow = '';
 
905
  }
906
 
907
  function bindModalCommon() {
908
+ adminModalFocus().watch();
909
  document.addEventListener('click', (e) => {
910
+ const active = adminModalFocusController?.topMask();
911
+ if (!active) return;
912
  const closer = e.target.closest('[data-close]');
913
+ if (closer && active.contains(closer)) {
914
  closeModal(closer.dataset.close);
915
+ } else if (e.target === active) {
916
+ closeModal(active.id);
 
 
 
 
 
 
 
 
917
  }
918
  });
919
+ document.addEventListener('keydown', (e) => adminModalFocusController?.keydown(e));
920
+ document.addEventListener('focusin', (e) => adminModalFocusController?.focusInside(e));
921
  }
922
 
923
  // -------------------- 创建插件 --------------------
 
930
  });
931
  showError($('#form-create-error'), '');
932
  openModal('modal-create');
933
+ const modal = captureAdminModal('modal-create');
934
+ const session = captureAdminSession();
935
+ setTimeout(() => {
936
+ if (!modal.isCurrent() || !session.isCurrent()) return;
937
+ const el = form.querySelector('[name="plugin_id"]');
938
+ if (el) el.focus();
939
+ }, 50);
940
  }
941
 
942
  function bindCreate() {
 
971
  };
972
 
973
  const submit = $('#btn-create-submit');
974
+ const modal = captureAdminModal('modal-create');
975
+ const finishBusy = startAdminBusy(submit);
976
+ if (!finishBusy) return;
977
  try {
978
  const data = await apiRequest('POST', '/api/admin/plugins', payload);
979
+ finishBusy.assertCurrent();
980
+ modal.assertCurrent();
981
+ const result = confirmAdminPluginWrite(data, pluginId, 'create');
982
  closeModal('modal-create');
983
+ showKeyModal(result.plugin_key, '请妥善保存此 Key,关闭后将无法再次查看。');
 
 
 
 
 
984
  await loadPlugins();
985
  } catch (err) {
986
+ if (isStaleSessionError(err) || !finishBusy.isCurrent() || !modal.isCurrent()) return;
987
  showError(errBox, err.message || '创建失败');
988
  } finally {
989
+ finishBusy();
990
  }
991
  });
992
  }
 
1046
  };
1047
 
1048
  const submit = $('#btn-edit-submit');
1049
+ const modal = captureAdminModal('modal-edit');
1050
+ const finishBusy = startAdminBusy(submit);
1051
+ if (!finishBusy) return;
1052
  try {
1053
+ const response = await apiRequest('PUT', '/api/admin/plugins/' + encodeURIComponent(id), payload);
1054
+ finishBusy.assertCurrent();
1055
+ modal.assertCurrent();
1056
+ confirmAdminPluginWrite(response, id, 'edit');
1057
  closeModal('modal-edit');
1058
  toast('修改已保存', 'success');
1059
  await loadPlugins();
1060
  } catch (err) {
1061
+ if (isStaleSessionError(err) || !finishBusy.isCurrent() || !modal.isCurrent()) return;
1062
  showError(errBox, err.message || '保存失败');
1063
  } finally {
1064
+ finishBusy();
1065
  }
1066
  });
1067
  }
 
1074
  message: `轮换后旧 Key 立即失效,确定继续?\n插件:${plugin.name || id}`,
1075
  okText: '确认轮换',
1076
  danger: false,
1077
+ onConfirm: async (assertCurrent) => {
1078
  const data = await apiRequest('POST', '/api/admin/plugins/' + encodeURIComponent(id) + '/rotate-key');
1079
+ assertCurrent();
1080
+ const result = confirmAdminPluginWrite(data, id, 'rotate');
1081
+ showKeyModal(result.plugin_key, '新 Key 已生成,请妥善保存。旧 Key 已失效。');
 
 
 
1082
  await loadPlugins();
1083
  },
1084
  });
 
1086
 
1087
  function togglePlugin(plugin, btn) {
1088
  const id = plugin.plugin_id || plugin.id;
1089
+ const finishBusy = startAdminBusy(btn);
1090
+ if (!finishBusy) return;
1091
  apiRequest('POST', '/api/admin/plugins/' + encodeURIComponent(id) + '/toggle')
1092
+ .then(async (response) => {
1093
+ finishBusy.assertCurrent();
1094
+ const result = confirmAdminPluginWrite(response, id, 'toggle');
1095
+ toast(result.enabled ? '插件已启用' : '插件已禁用', 'success');
1096
  await loadPlugins();
1097
  })
1098
  .catch((err) => {
1099
+ if (isStaleSessionError(err) || !finishBusy.isCurrent()) return;
1100
  toast(err.message || '切换状态失败', 'error');
1101
  })
1102
+ .finally(() => finishBusy());
1103
  }
1104
 
1105
  function deletePlugin(plugin, btn) {
 
1109
  message: `确定删除插件「${plugin.name || id}」?此操作不可逆。`,
1110
  okText: '确认删除',
1111
  danger: true,
1112
+ onConfirm: async (assertCurrent) => {
1113
+ const response = await apiRequest('DELETE', '/api/admin/plugins/' + encodeURIComponent(id));
1114
+ assertCurrent();
1115
+ confirmAdminPluginWrite(response, id, 'delete');
1116
  toast('插件已删除', 'success');
1117
  await loadPlugins();
1118
  },
 
1124
  $('#modal-confirm-title').textContent = title || '请确认';
1125
  $('#modal-confirm-msg').textContent = message || '确认执行此操作?';
1126
  const okBtn = $('#btn-confirm-ok');
1127
+ const previous = adminBusyOperations.get(okBtn);
1128
+ if (previous) previous();
1129
  $('.btn-label', okBtn).textContent = okText || '确定';
1130
  okBtn.classList.toggle('btn-danger', !!danger);
1131
  okBtn.classList.toggle('btn-primary', !danger);
 
1138
  okBtn.addEventListener('click', async () => {
1139
  const handler = state.confirmHandler;
1140
  if (!handler) { closeModal('modal-confirm'); return; }
1141
+ const finishBusy = startAdminBusy(okBtn);
1142
+ if (!finishBusy) return;
1143
  try {
1144
+ await handler(() => {
1145
+ finishBusy.assertCurrent();
1146
+ assertAdminSession({ isCurrent: () => state.confirmHandler === handler });
1147
+ });
1148
+ finishBusy.assertCurrent();
1149
+ if (state.confirmHandler !== handler) return;
1150
  closeModal('modal-confirm');
1151
  } catch (err) {
1152
+ if (isStaleSessionError(err) || !finishBusy.isCurrent() || state.confirmHandler !== handler) return;
1153
  toast(err.message || '操作失败', 'error');
1154
  } finally {
1155
+ if (finishBusy() && state.confirmHandler === handler) state.confirmHandler = null;
 
1156
  }
1157
  });
1158
  }
1159
 
1160
  // -------------------- Key 展示模态框 --------------------
1161
  function showKeyModal(key, hint) {
1162
+ resetAdminKeyCopy();
1163
  $('#key-display').textContent = key;
 
 
 
 
 
1164
  const warn = $('.key-warn span');
1165
  if (warn && hint) warn.textContent = hint;
1166
  openModal('modal-key');
1167
  }
1168
 
1169
  function bindKeyModal() {
1170
+ resetAdminKeyCopy();
1171
+ const btn = $('#btn-copy-key');
1172
+ btn.addEventListener('click', async () => {
1173
+ const modal = captureAdminModal('modal-key');
1174
+ if (!modal.isCurrent() || adminBusyOperations.has(btn)) return;
1175
+ resetAdminKeyCopy();
1176
+ const finishBusy = startAdminBusy(btn);
1177
+ if (!finishBusy) return;
1178
  const key = $('#key-display').textContent;
1179
+ const generation = adminKeyCopyGeneration;
1180
+ const session = captureAdminSession();
1181
+ const isCurrent = () => session.isCurrent() && modal.isCurrent() &&
1182
+ generation === adminKeyCopyGeneration && $('#key-display').textContent === key;
1183
+ try {
1184
+ const ok = await copyToClipboard(key, isCurrent);
1185
+ if (!isCurrent() || !finishBusy.isCurrent()) return;
1186
+ const label = $('.btn-label', btn);
1187
+ if (ok) {
1188
+ btn.classList.add('is-copied');
1189
+ label.textContent = adminMessage('copied');
1190
+ toast(adminMessage('key_copied'), 'success');
1191
+ if (adminKeyCopyTimer !== null) clearTimeout(adminKeyCopyTimer);
1192
+ adminKeyCopyTimer = setTimeout(() => {
1193
+ if (!isCurrent()) return;
1194
+ adminKeyCopyTimer = null;
1195
+ btn.classList.remove('is-copied');
1196
+ label.textContent = adminMessage('copy_key');
1197
+ }, 2000);
1198
+ } else {
1199
+ toast(adminMessage('copy_failed'), 'error');
1200
+ }
1201
+ } finally {
1202
+ finishBusy();
1203
  }
1204
  });
1205
  }
1206
 
1207
+ async function copyToClipboard(text, isCurrent = () => true) {
1208
+ if (!isCurrent()) return false;
1209
  try {
1210
  if (navigator.clipboard && window.isSecureContext) {
1211
  await navigator.clipboard.writeText(text);
1212
  return true;
1213
  }
1214
+ } catch (_) { /* 尝试当前操作的兼容路径。 */ }
1215
+ if (!isCurrent()) return false;
1216
+ let textarea;
1217
+ const active = document.activeElement;
1218
  try {
1219
+ textarea = document.createElement('textarea');
1220
+ textarea.value = text;
1221
+ textarea.style.position = 'fixed';
1222
+ textarea.style.opacity = '0';
1223
+ document.body.appendChild(textarea);
1224
+ textarea.select();
1225
+ return document.execCommand('copy');
1226
+ } catch (_) {
 
 
1227
  return false;
1228
+ } finally {
1229
+ if (textarea) textarea.remove();
1230
+ if (isCurrent() && active && active.isConnected && typeof active.focus === 'function') {
1231
+ try { active.focus(); } catch (_) { /* 不覆盖复制结果。 */ }
1232
+ }
1233
  }
1234
  }
1235
 
1236
  // -------------------- 🧭 页签切换 --------------------
1237
  const TAB_TITLES = {
1238
+ plugins: 'tab_plugins', withdrawals: 'tab_withdrawals', disputes: 'tab_disputes',
1239
+ config: 'tab_config', broadcast: 'tab_broadcast', promptcats: 'tab_promptcats', scripts: 'script_title'
 
 
 
 
 
1240
  };
1241
+ const TAB_ICONS = { plugins: '🔌', withdrawals: '💰', disputes: '⚖️', config: '🏷️', broadcast: '📢', promptcats: '🗂️' };
1242
 
1243
  // 管理操作所需的 admin_account(后端实际以 Token 鉴权,此字段仅为请求体必填���)
1244
  function adminAccount() {
 
1247
  }
1248
 
1249
  function switchTab(name) {
1250
+ if (!Object.hasOwn(TAB_TITLES, name)) return;
1251
  state.activeTab = name;
1252
  $$('.tab-item').forEach((btn) => {
1253
  const active = btn.dataset.tab === name;
1254
  btn.classList.toggle('is-active', active);
1255
  btn.setAttribute('aria-selected', String(active));
1256
+ btn.tabIndex = active ? 0 : -1;
1257
  });
1258
  Object.keys(TAB_TITLES).forEach((key) => {
1259
  const panel = document.getElementById('panel-' + key);
1260
  if (panel) panel.hidden = (key !== name);
1261
  });
1262
  const crumb = $('.crumbs-current');
1263
+ if (crumb) crumb.textContent = adminMessage(TAB_TITLES[name]);
1264
  // 管理操作低频,每次切换拉取最新数据保证时效
1265
  if (name === 'withdrawals') refreshWithdrawals();
1266
  if (name === 'disputes') refreshDisputes();
 
1270
  }
1271
 
1272
  function bindTabs() {
1273
+ const tabs = $$('.tab-item');
1274
+ const navigation = $('.tab-bar');
1275
+ navigation.setAttribute('aria-label', adminMessage('tab_navigation'));
1276
+ const restoreTabStop = () => tabs.forEach(tab => { tab.tabIndex = tab.dataset.tab === state.activeTab ? 0 : -1; });
1277
+ for (const btn of tabs) {
1278
+ const name = btn.dataset.tab;
1279
+ btn.textContent = name === 'scripts' ? adminMessage('script_tab') : TAB_ICONS[name] + ' ' + adminMessage(TAB_TITLES[name]);
1280
+ btn.addEventListener('click', () => switchTab(name));
1281
+ btn.addEventListener('keydown', (event) => {
1282
+ if (event.isComposing || event.altKey || event.ctrlKey || event.metaKey) return;
1283
+ const index = tabs.indexOf(btn);
1284
+ let next;
1285
+ if (event.key === 'ArrowRight') next = (index + 1) % tabs.length;
1286
+ else if (event.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length;
1287
+ else if (event.key === 'Home') next = 0;
1288
+ else if (event.key === 'End') next = tabs.length - 1;
1289
+ else return;
1290
+ event.preventDefault();
1291
+ tabs.forEach((tab, position) => { tab.tabIndex = position === next ? 0 : -1; });
1292
+ tabs[next].focus();
1293
+ });
1294
+ btn.addEventListener('blur', (event) => {
1295
+ if (!tabs.includes(event.relatedTarget)) restoreTabStop();
1296
+ });
1297
+ }
1298
+ restoreTabStop();
1299
+ const crumb = $('.crumbs-current');
1300
+ if (crumb) crumb.textContent = adminMessage(TAB_TITLES[state.activeTab]);
1301
  }
1302
 
1303
  // -------------------- 💰 提现管理 --------------------
 
1315
  }
1316
 
1317
  async function refreshWithdrawals() {
1318
+ const read = startAdminRead('withdrawals');
1319
  try {
1320
  setWithdrawState('loading');
1321
  const data = await apiRequest('GET', '/api/admin/withdrawals?status=' + state.withdrawStatus);
1322
+ if (!read.isCurrent()) return;
1323
+ renderWithdrawList(parseAdminList(data, 'withdrawals'));
1324
  } catch (err) {
1325
+ if (isStaleSessionError(err) || !read.isCurrent()) return;
1326
  if (err instanceof ApiError && err.status === 401) return; // 已自动跳回登录
1327
  setWithdrawState('error', err.message || '加载失败');
1328
+ } finally {
1329
+ read.release();
1330
  }
1331
  }
1332
 
 
1385
  message: `确认已向 ${w.account || '该用户'} 打款 ${Math.abs(w.amount || 0)} 积分对应的金额?\n订单号:${orderId}`,
1386
  okText: '确认打款',
1387
  danger: false,
1388
+ onConfirm: async (assertCurrent) => {
1389
+ const finishBusy = startAdminBusy(btn);
1390
+ if (!finishBusy) return;
1391
  try {
1392
+ const response = await apiRequest('POST', '/api/admin/withdrawals/' + encodeURIComponent(w.tx_id) + '/complete', { payment_order_id: orderId });
1393
+ assertCurrent();
1394
+ confirmAdminStatusWrite(response);
1395
  toast('打款已确认', 'success');
1396
  await refreshWithdrawals();
1397
  } finally {
1398
+ finishBusy();
1399
  }
1400
  }
1401
  });
 
1434
  }
1435
 
1436
  async function refreshDisputes() {
1437
+ const read = startAdminRead('disputes');
1438
  try {
1439
  setDisputeState('loading');
1440
  const url = state.disputeStatus
1441
  ? '/api/admin/disputes?status=' + state.disputeStatus
1442
  : '/api/admin/disputes';
1443
  const data = await apiRequest('GET', url);
1444
+ if (!read.isCurrent()) return;
1445
+ renderDisputeList(parseAdminList(data, 'disputes'));
1446
  } catch (err) {
1447
+ if (isStaleSessionError(err) || !read.isCurrent()) return;
1448
  if (err instanceof ApiError && err.status === 401) return;
1449
  setDisputeState('error', err.message || '加载失败');
1450
+ } finally {
1451
+ read.release();
1452
  }
1453
  }
1454
 
 
1487
  }
1488
 
1489
  async function openDisputeModal(disputeId) {
1490
+ const read = startAdminRead('dispute-detail');
1491
  openModal('modal-dispute');
1492
  const body = $('#dispute-detail-body');
1493
  body.innerHTML = '<div class="state-block"><div class="spinner-lg" aria-hidden="true"></div><p>正在加载申诉详情…</p></div>';
1494
  try {
1495
  const res = await apiRequest('GET', '/api/disputes/' + encodeURIComponent(disputeId));
1496
+ if (!read.isCurrent()) return;
1497
+ const dispute = parseAdminDispute(res, disputeId);
1498
  renderDisputeDetail(body, dispute);
1499
  } catch (err) {
1500
+ if (isStaleSessionError(err) || !read.isCurrent()) return;
1501
  body.innerHTML = '<div class="state-block state-error"><div class="empty-mark">!</div><h3>加载失败</h3><p>' +
1502
  escapeHtml(err.message || '请稍后重试。') + '</p></div>';
1503
+ } finally {
1504
+ read.release();
1505
  }
1506
  }
1507
 
 
1550
  function renderResolveForm() {
1551
  return `
1552
  <div class="resolve-box">
1553
+ <div class="resolve-box-title" id="resolve-title">🔨 裁决操作</div>
1554
+ <div class="resolve-options" role="radiogroup" aria-labelledby="resolve-title">
1555
  ${RESOLUTION_OPTIONS.map((opt) => `
1556
  <label class="resolve-option" data-value="${opt.value}">
1557
  <input type="radio" name="resolution" value="${opt.value}">
1558
+ <span class="resolve-option-icon" aria-hidden="true">${opt.icon}</span>
1559
  <span class="resolve-option-text">
1560
  <strong>${opt.label}</strong>
1561
  ${opt.desc ? `<em>${opt.desc}</em>` : ''}
 
1563
  </label>`).join('')}
1564
  </div>
1565
  <div class="split-row" id="split-row" hidden>
1566
+ <label for="resolve-ratio">申诉方分成</label>
1567
  <input type="range" id="resolve-ratio" min="0" max="100" value="50">
1568
  <strong id="resolve-ratio-text">50% : 50%</strong>
1569
  </div>
 
1592
  }
1593
 
1594
  function bindResolveForm(body, dispute) {
1595
+ const modal = captureAdminModal('modal-dispute');
1596
+ const session = captureAdminSession();
1597
  let selected = null;
1598
  const options = $$('.resolve-option', body);
1599
  const splitRow = $('#split-row', body);
 
1602
  const submitBtn = $('#btn-resolve', body);
1603
 
1604
  options.forEach((opt) => {
1605
+ const radio = opt.querySelector('input');
1606
+ radio.addEventListener('change', () => {
1607
+ if (!radio.checked || !modal.isCurrent() || !session.isCurrent()) return;
1608
  options.forEach((o) => o.classList.remove('selected'));
1609
  opt.classList.add('selected');
1610
+ selected = radio.value;
 
 
1611
  splitRow.hidden = selected !== 'split';
1612
  submitBtn.disabled = false;
1613
  });
 
1617
  ratioText.textContent = `${v}% : ${100 - v}%`;
1618
  });
1619
  submitBtn.addEventListener('click', async () => {
1620
+ if (!selected || !modal.isCurrent() || !session.isCurrent()) return;
1621
  const note = $('#resolve-note', body).value.trim();
1622
+ const finishBusy = startAdminBusy(submitBtn);
1623
+ if (!finishBusy) return;
1624
  try {
1625
  // ⚠️ 后端 resolve 端点为裸标量参数(FastAPI 按 query 解析),必须走 query 传参而非 JSON body
1626
  const params = new URLSearchParams({ resolution: selected });
1627
+ if (selected === 'split') {
1628
+ const ratio = parseInt(ratioInput.value, 10);
1629
+ params.set('ratio', String(Number.isNaN(ratio) ? 50 : ratio));
1630
+ }
1631
  if (note) params.set('note', note);
1632
+ const response = await apiRequest('POST', '/api/admin/disputes/' + encodeURIComponent(dispute.id) + '/resolve?' + params.toString());
1633
+ finishBusy.assertCurrent();
1634
+ confirmAdminStatusWrite(response);
1635
+ modal.assertCurrent();
1636
  toast('裁决已生效', 'success');
1637
  closeModal('modal-dispute');
1638
  refreshDisputes();
1639
  } catch (err) {
1640
+ if (isStaleSessionError(err) || !finishBusy.isCurrent() || !modal.isCurrent()) return;
1641
  toast(err.message || '裁决失败', 'error');
1642
+ } finally {
1643
+ finishBusy();
1644
  }
1645
  });
1646
  }
 
1668
  }
1669
 
1670
  async function refreshPromptCats() {
1671
+ const read = startAdminRead('promptcats');
1672
  try {
1673
  setPcatState('loading');
1674
  const res = await apiRequest('GET', '/api/prompt-categories?prompt_type=' + state.pcatType);
1675
+ if (!read.isCurrent()) return;
1676
+ const list = parseAdminList(res, 'categories');
1677
  renderPromptCats(list);
1678
  } catch (err) {
1679
+ if (isStaleSessionError(err) || !read.isCurrent()) return;
1680
  if (err instanceof ApiError && err.status === 401) return; // 已自动跳回登录
1681
  setPcatState('error', err.message || '加载失败');
1682
+ } finally {
1683
+ read.release();
1684
  }
1685
  }
1686
 
 
1711
  }
1712
 
1713
  function confirmDeletePromptCat(cat) {
1714
+ const promptType = state.pcatType;
1715
  confirmAction({
1716
  title: '删除分类',
1717
+ message: `确定删除${PCAT_TYPE_LABELS[promptType] || ''}分类「${cat}」?\n已使用该分类的提示词不受影响。`,
1718
  okText: '确认删除',
1719
  danger: true,
1720
+ onConfirm: async (assertCurrent) => {
1721
+ const response = await apiRequest('DELETE', '/api/prompt-categories?prompt_type=' + promptType + '&name=' + encodeURIComponent(cat));
1722
+ assertCurrent();
1723
+ confirmAdminStatusWrite(response);
1724
  toast('分类已删除', 'success');
1725
  await refreshPromptCats();
1726
  }
 
1728
  }
1729
 
1730
  function bindPromptCats() {
1731
+ let categoryRevision = 0;
1732
  $('#btn-pcat-refresh').addEventListener('click', () => refreshPromptCats());
1733
  $$('#pcat-type-tabs .filter-tab').forEach((tab) => {
1734
  tab.addEventListener('click', () => {
1735
+ categoryRevision++;
1736
  state.pcatType = tab.dataset.type;
1737
  $$('#pcat-type-tabs .filter-tab').forEach((t) => t.classList.toggle('is-active', t === tab));
1738
  refreshPromptCats();
 
1740
  });
1741
  const addBtn = $('#btn-pcat-add');
1742
  const nameInput = $('#pcat-name');
1743
+ nameInput.addEventListener('input', () => { categoryRevision++; });
1744
+ nameInput.addEventListener('change', () => { categoryRevision++; });
1745
  const submitAdd = async () => {
1746
+ const rawName = nameInput.value;
1747
+ const name = rawName.trim();
1748
+ const promptType = state.pcatType;
1749
+ const revision = categoryRevision;
1750
  if (!name) { toast('请输入分类名称', 'warning'); nameInput.focus(); return; }
1751
  if (name.length > 8) { toast('分类名称不能超过 8 个字', 'warning'); return; }
1752
+ const finishBusy = startAdminBusy(addBtn);
1753
+ if (!finishBusy) return;
1754
  try {
1755
+ const response = await apiRequest('POST', '/api/prompt-categories', { prompt_type: promptType, name });
1756
+ finishBusy.assertCurrent();
1757
+ confirmAdminStatusWrite(response);
1758
+ if (categoryRevision === revision && state.pcatType === promptType && nameInput.value === rawName) nameInput.value = '';
1759
  toast('分类已添加', 'success');
1760
  await refreshPromptCats();
1761
  } catch (err) {
1762
+ if (isStaleSessionError(err) || !finishBusy.isCurrent()) return;
1763
  toast(err.message || '添加分类失败', 'error');
1764
  } finally {
1765
+ finishBusy();
1766
  }
1767
  };
1768
  addBtn.addEventListener('click', submitAdd);
 
1779
  node.innerHTML = `<span class="toast-icon">${icon}</span><span class="toast-msg"></span>`;
1780
  node.querySelector('.toast-msg').textContent = message;
1781
  container.appendChild(node);
1782
+ let displayTimer = null, leaveTimer = null;
1783
+ const release = () => {
1784
+ if (adminToastOperations.get(node) !== release) return;
1785
+ adminToastOperations.delete(node);
1786
+ if (displayTimer !== null) clearTimeout(displayTimer);
1787
+ if (leaveTimer !== null) clearTimeout(leaveTimer);
1788
+ if (node.parentNode) node.parentNode.removeChild(node);
1789
+ };
1790
+ adminToastOperations.set(node, release);
1791
+ try {
1792
+ displayTimer = setTimeout(() => {
1793
+ if (adminToastOperations.get(node) !== release) return;
1794
+ displayTimer = null;
1795
+ node.classList.add('is-leaving');
1796
+ try { leaveTimer = setTimeout(release, 220); }
1797
+ catch (_) { release(); }
1798
+ }, type === 'error' ? 4200 : 2800);
1799
+ } catch (_) { release(); }
1800
  }
1801
 
1802
  // -------------------- 🏷️ 版本管理 --------------------
 
1812
  $('#ver-preview').textContent = `版本预览:V${major}.${minor}.${patch} · ${STAGE_LABELS[stage] || stage}`;
1813
  }
1814
 
1815
+ let versionConfigGeneration = 0;
1816
+ let versionConfigConfirmed = false;
1817
+ let versionSavePending = false;
1818
+ let versionEditRevision = 0;
1819
+
1820
+ function readVersionDraft(form) {
1821
+ return Object.fromEntries(['stage', 'major', 'minor', 'patch'].map(key => [key, String(form.querySelector(`[name="${key}"]`).value)]));
1822
+ }
1823
  async function loadVersionConfig() {
1824
+ const generation = ++versionConfigGeneration;
1825
+ const btn = $('#btn-version-save');
1826
+ versionConfigConfirmed = false;
1827
+ btn.disabled = true;
1828
+ if (versionSavePending) {
1829
+ showError($('#form-version-error'), adminMessage('config_read_unavailable'));
1830
+ return;
1831
+ }
1832
+ const form = $('#form-version');
1833
+ const draft = readVersionDraft(form);
1834
+ const revision = versionEditRevision;
1835
  try {
1836
  showError($('#form-version-error'), '');
1837
  const res = await apiRequest('GET', '/api/admin/config/project_version');
1838
+ if (generation !== versionConfigGeneration) return;
1839
+ if (!res || res.status !== 'success' || !Object.prototype.hasOwnProperty.call(res, 'data')) {
1840
+ throw new ApiError(adminMessage('config_read_unavailable'), 503);
1841
+ }
1842
+ const cfg = res.data === null ? {} : res.data;
1843
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
1844
+ throw new ApiError(adminMessage('config_read_unavailable'), 503);
1845
+ }
1846
+ const next = { stage: cfg.stage ?? 'beta', major: cfg.major ?? 2, minor: cfg.minor ?? 0, patch: cfg.patch ?? 0 };
1847
+ if (typeof next.stage !== 'string' || !Object.prototype.hasOwnProperty.call(STAGE_LABELS, next.stage) ||
1848
+ !['major', 'minor', 'patch'].every(key => Number.isSafeInteger(next[key]) && next[key] >= 0)) {
1849
+ throw new ApiError(adminMessage('config_read_unavailable'), 503);
1850
+ }
1851
+ const current = readVersionDraft(form);
1852
+ if (revision === versionEditRevision && Object.keys(draft).every(key => draft[key] === current[key])) {
1853
+ for (const key of ['stage', 'major', 'minor', 'patch']) form.querySelector(`[name="${key}"]`).value = next[key];
1854
+ updateVerPreview();
1855
+ }
1856
+ versionConfigConfirmed = true;
1857
+ if (btn.getAttribute('aria-busy') !== 'true') btn.disabled = false;
1858
  } catch (err) {
1859
+ if (isStaleSessionError(err)) return;
1860
+ if (generation !== versionConfigGeneration) return;
1861
  if (!(err instanceof ApiError && err.status === 401)) {
1862
+ showError($('#form-version-error'), adminMessage('config_read_unavailable'));
1863
  }
1864
  }
1865
  }
1866
 
1867
  function bindVersion() {
1868
  const form = $('#form-version');
1869
+ ['change', 'input'].forEach((evt) => form.addEventListener(evt, () => { versionEditRevision++; updateVerPreview(); }));
1870
  form.addEventListener('submit', async (event) => {
1871
  event.preventDefault();
1872
+ if (!versionConfigConfirmed || versionSavePending) return;
1873
  showError($('#form-version-error'), '');
1874
  const payload = {
1875
  stage: form.querySelector('[name="stage"]').value,
 
1878
  patch: toIntOr(form.querySelector('[name="patch"]').value, 0)
1879
  };
1880
  const btn = $('#btn-version-save');
1881
+ const finishBusy = startAdminBusy(btn);
1882
+ if (!finishBusy) return;
1883
+ versionSavePending = true;
1884
  try {
1885
+ const response = await apiRequest('PUT', '/api/admin/config/project_version', payload);
1886
+ finishBusy.assertCurrent();
1887
+ confirmAdminStatusWrite(response);
1888
  toast(`版本已更新为 V${payload.major}.${payload.minor}.${payload.patch}(${STAGE_LABELS[payload.stage] || payload.stage})`, 'success');
1889
  } catch (err) {
1890
+ if (isStaleSessionError(err) || !finishBusy.isCurrent()) return;
1891
  showError($('#form-version-error'), err.message || '版本更新失败');
1892
  } finally {
1893
+ versionSavePending = false;
1894
+ finishBusy();
1895
+ if (!versionConfigConfirmed) btn.disabled = true;
1896
  }
1897
  });
1898
  }
1899
 
1900
  // -------------------- 📢 公告与广告 --------------------
1901
+ let bannerStatusGeneration = 0;
1902
  async function refreshBannerStatus() {
1903
+ const generation = ++bannerStatusGeneration;
1904
  const badge = $('#banner-status-badge');
1905
  try {
1906
+ const res = await apiRequest('GET', '/api/admin/config/banner_config');
1907
+ if (generation !== bannerStatusGeneration) return;
1908
+ if (!res || res.status !== 'success' || !Object.prototype.hasOwnProperty.call(res, 'data')) throw new Error('Invalid config response');
1909
+ const data = res.data;
1910
+ if (data !== null && (!data || typeof data !== 'object' || Array.isArray(data) || typeof data.enabled !== 'boolean')) throw new Error('Invalid banner state');
1911
+ if (data && data.enabled) {
1912
+ if (typeof data.bannerImage !== 'string' || !data.bannerImage.trim()) throw new Error('Invalid banner image');
1913
+ badge.textContent = adminMessage('banner_active');
1914
  badge.className = 'badge is-on';
1915
  } else {
1916
+ badge.textContent = adminMessage('banner_inactive');
1917
  badge.className = 'badge is-off';
1918
  }
1919
  } catch (err) {
1920
+ if (isStaleSessionError(err)) return;
1921
+ if (generation !== bannerStatusGeneration) return;
1922
+ badge.textContent = adminMessage('banner_unknown');
1923
  badge.className = 'badge';
1924
  }
1925
  }
 
1930
  img.hidden = false;
1931
  }
1932
 
1933
+ function bindUploadButton(btnId, inputId, previewId, onDone, onBusyChange) {
1934
  const btn = document.getElementById(btnId);
1935
  const input = document.getElementById(inputId);
1936
  btn.addEventListener('click', () => input.click());
 
1939
  if (!file) return;
1940
  const label = $('.btn-label', btn);
1941
  const oldText = label.textContent;
1942
+ const finishBusy = startAdminBusy(btn, () => {
1943
+ label.textContent = oldText;
1944
+ if (onBusyChange) onBusyChange(finishBusy, false);
1945
+ });
1946
+ if (!finishBusy) return;
1947
+ if (onBusyChange) onBusyChange(finishBusy, true);
1948
+ label.textContent = adminMessage('image_uploading');
1949
  try {
1950
  const fd = new FormData();
1951
  fd.append('file', file);
1952
  fd.append('file_type', 'banner');
1953
  const res = await apiUpload('/api/upload', fd);
1954
+ finishBusy.assertCurrent();
1955
+ const url = parseAdminUpload(res);
1956
+ onDone(url);
1957
+ setPreviewImg(previewId, url);
1958
+ toast(adminMessage('image_upload_success'), 'success');
 
 
1959
  } catch (err) {
1960
+ if (isStaleSessionError(err) || !finishBusy.isCurrent()) return;
1961
+ toast(err.message || adminMessage('image_upload_failed'), 'error');
1962
  } finally {
1963
+ if (finishBusy()) input.value = '';
 
 
1964
  }
1965
  });
1966
  }
1967
 
1968
  function bindBroadcast() {
1969
+ const bannerSave = $('#btn-banner-save');
1970
+ const bannerUploads = new Set();
1971
+ let bannerSaveBeforeUpload = null;
1972
+ const syncBannerSave = () => {
1973
+ const saving = adminBusyOperations.get(bannerSave);
1974
+ if (bannerUploads.size) {
1975
+ if (bannerSaveBeforeUpload === null) bannerSaveBeforeUpload = saving ? saving.initialDisabled : bannerSave.disabled;
1976
+ bannerSave.disabled = true;
1977
+ } else if (bannerSaveBeforeUpload !== null) {
1978
+ const disabled = bannerSaveBeforeUpload;
1979
+ bannerSaveBeforeUpload = null;
1980
+ if (!saving) bannerSave.disabled = disabled;
1981
+ }
1982
+ };
1983
+ const onUploadBusy = (operation, active) => {
1984
+ if (active) bannerUploads.add(operation);
1985
+ else if (!bannerUploads.delete(operation)) return;
1986
+ syncBannerSave();
1987
+ };
1988
+ const announcement = $('#ann-content');
1989
+ let announcementRevision = 0;
1990
+ announcement.addEventListener('input', () => { announcementRevision++; });
1991
+ announcement.addEventListener('change', () => { announcementRevision++; });
1992
+ let bannerRevision = 0;
1993
+ for (const id of ['banner-enabled', 'banner-title', 'banner-desc', 'banner-detail-content', 'banner-img-file', 'banner-detail-img-file']) {
1994
+ const element = $('#' + id);
1995
+ element.addEventListener('input', () => { bannerRevision++; });
1996
+ element.addEventListener('change', () => { bannerRevision++; });
1997
+ }
1998
+ const readBannerDraft = () => ({ enabled: $('#banner-enabled').checked,
1999
+ bannerImage: state.bannerImageUrl, detailImage: state.bannerDetailImageUrl,
2000
+ title: $('#banner-title').value, description: $('#banner-desc').value,
2001
+ detailContent: $('#banner-detail-content').value });
2002
  // 📢 公告发布(二次确认,防误发)
2003
  $('#btn-ann-publish').addEventListener('click', () => {
2004
+ const rawContent = announcement.value;
2005
+ const content = rawContent.trim();
2006
+ const revision = announcementRevision;
2007
  if (!content) { toast('请输入公告内容', 'warning'); return; }
2008
  confirmAction({
2009
  title: '发布全站公告',
2010
  message: `公告将推送给全体用户,确认发布?\n\n${content.slice(0, 80)}${content.length > 80 ? '…' : ''}`,
2011
  okText: '确认发布',
2012
  danger: true,
2013
+ onConfirm: async (assertCurrent) => {
2014
+ const response = await apiRequest('POST', '/api/system/announcement', { admin_account: adminAccount(), content });
2015
+ assertCurrent();
2016
+ confirmAdminStatusWrite(response);
2017
+ if (announcementRevision === revision && announcement.value === rawContent) announcement.value = '';
2018
  toast('公告已发布', 'success');
2019
  }
2020
  });
2021
  });
2022
 
2023
  // 📰 广告图片上传
2024
+ bindUploadButton('btn-banner-img', 'banner-img-file', 'banner-img-preview', (url) => { bannerRevision++; state.bannerImageUrl = url; }, onUploadBusy);
2025
+ bindUploadButton('btn-detail-img', 'banner-detail-img-file', 'banner-detail-img-preview', (url) => { bannerRevision++; state.bannerDetailImageUrl = url; }, onUploadBusy);
2026
 
2027
  // 💾 保存广告配置
2028
+ bannerSave.addEventListener('click', async () => {
2029
+ if (bannerUploads.size) {
2030
+ toast(adminMessage('banner_upload_pending'), 'warning');
2031
+ return;
2032
+ }
2033
+ const draft = readBannerDraft();
2034
+ const revision = bannerRevision;
2035
+ if (draft.enabled && !draft.bannerImage) {
2036
  toast('启用广告时必须上传横幅图片', 'warning');
2037
  return;
2038
  }
2039
+ const config = { ...draft, title: draft.title.trim(), description: draft.description.trim(),
2040
+ detailContent: draft.detailContent.trim() };
 
 
 
 
 
 
2041
  const btn = $('#btn-banner-save');
2042
+ const finishBusy = startAdminBusy(btn, syncBannerSave);
2043
+ if (!finishBusy) return;
2044
  try {
2045
+ const response = await apiRequest('PUT', '/api/admin/config/banner_config', config);
2046
+ finishBusy.assertCurrent();
2047
+ confirmAdminStatusWrite(response);
2048
  toast('广告配置已保存', 'success');
2049
+ const current = readBannerDraft();
2050
+ if (bannerRevision === revision && Object.keys(draft).every(key => current[key] === draft[key])) {
2051
+ $('#banner-enabled').checked = false;
2052
+ state.bannerImageUrl = '';
2053
+ state.bannerDetailImageUrl = '';
2054
+ ['banner-img-preview', 'banner-detail-img-preview'].forEach((id) => {
2055
+ const img = document.getElementById(id);
2056
+ img.src = '';
2057
+ img.hidden = true;
2058
+ });
2059
+ $('#banner-title').value = '';
2060
+ $('#banner-desc').value = '';
2061
+ $('#banner-detail-content').value = '';
2062
+ }
2063
  refreshBannerStatus();
2064
  } catch (err) {
2065
+ if (isStaleSessionError(err) || !finishBusy.isCurrent()) return;
2066
  toast(err.message || '保存失败', 'error');
2067
  } finally {
2068
+ finishBusy();
2069
  }
2070
  });
2071
  }
2072
 
2073
  // -------------------- 🖥️ 脚本执行 --------------------
2074
  function bindScripts() {
2075
+ for (const element of document.querySelectorAll('[data-admin-script-text]')) {
2076
+ element.textContent = adminMessage(element.dataset.adminScriptText);
2077
+ }
2078
+ $('#script-name').setAttribute('placeholder', adminMessage('script_name_placeholder'));
2079
+ const result = $('#script-result');
2080
+ result.setAttribute('aria-label', adminMessage('script_output_label'));
2081
+ result.textContent = adminMessage('script_waiting');
2082
+ $('#form-script').addEventListener('submit', async (event) => {
2083
+ event.preventDefault();
2084
+ const btn = $('#btn-script-run');
2085
+ if (adminBusyOperations.has(btn)) return;
2086
  const name = $('#script-name').value.trim();
 
2087
  if (!name) {
2088
+ result.textContent = '❌ ' + adminMessage('script_name_required');
2089
  result.classList.add('is-error');
2090
  return;
2091
  }
2092
+ const finishBusy = startAdminBusy(btn);
2093
+ if (!finishBusy) return;
2094
  result.classList.remove('is-error');
2095
+ result.textContent = adminMessage('script_running');
2096
  try {
2097
  const res = await apiRequest('POST', '/api/admin/run-script', { admin_account: adminAccount(), script_name: name });
2098
+ finishBusy.assertCurrent();
2099
+ const outcome = parseAdminScriptResult(res);
2100
+ result.textContent = `${outcome.succeeded ? '✅ ' + adminMessage('script_completed') : '❌ ' + adminMessage('script_failed')}:\n${outcome.output}`;
2101
+ result.classList.toggle('is-error', !outcome.succeeded);
2102
  } catch (err) {
2103
+ if (isStaleSessionError(err) || !finishBusy.isCurrent()) return;
2104
+ result.textContent = `❌ ${adminMessage('script_failed')}:\n${err.message}`;
2105
  result.classList.add('is-error');
2106
  } finally {
2107
+ finishBusy();
2108
  }
2109
  });
2110
  }
 
2127
 
2128
  $('#btn-refresh').addEventListener('click', async () => {
2129
  const btn = $('#btn-refresh');
2130
+ const finishBusy = startAdminBusy(btn);
2131
+ if (!finishBusy) return;
2132
  try {
2133
  await loadPlugins();
2134
+ finishBusy.assertCurrent();
2135
  toast('已刷新', 'success');
2136
  } catch (err) {
2137
+ if (isStaleSessionError(err) || !finishBusy.isCurrent()) return;
2138
  if (!(err instanceof ApiError && err.status === 401)) {
2139
  setListState('error', err.message || '加载失败');
2140
  toast(err.message || '加载失败', 'error');
2141
  }
2142
  } finally {
2143
+ finishBusy();
2144
  }
2145
  });
2146
 
2147
  $('#btn-retry').addEventListener('click', async () => {
2148
  try { await loadPlugins(); }
2149
  catch (err) {
2150
+ if (isStaleSessionError(err)) return;
2151
  if (!(err instanceof ApiError && err.status === 401)) {
2152
  setListState('error', err.message || '加载失败');
2153
  }
 
2187
  bindScripts();
2188
 
2189
  if (state.token) {
2190
+ // 已有凭据也必须先由管理员列表接口确认,不能先显示管理内容。
2191
+ showView('view-login');
2192
+ const session = captureAdminSession();
2193
+ loadPlugins().then(() => {
2194
+ assertAdminSession(session);
2195
+ syncUserChip();
2196
+ showView('view-main');
2197
+ }).catch((err) => {
2198
+ if (isStaleSessionError(err) || !session.isCurrent()) return;
2199
  if (err instanceof ApiError && (err.status === 401)) return; // 已自动跳转
2200
  if (err instanceof ApiError && err.status === 403) {
 
2201
  showView('view-forbidden');
2202
  return;
2203
  }
2204
+ showView('view-login');
2205
+ showError($('#login-error'), adminMessage('admin_verification_unavailable'));
2206
  });
2207
  } else {
2208
  showView('view-login');
管理界面/index.html CHANGED
@@ -98,18 +98,18 @@
98
 
99
  <main class="main-shell">
100
  <!-- 🧭 功能页签导航:插件 / 提现 / 仲裁 / 版本 / 公告广告 / 提示词分类 / 脚本 -->
101
- <nav class="tab-bar" role="tablist" aria-label="功能导航">
102
- <button type="button" class="tab-item is-active" role="tab" aria-selected="true" data-tab="plugins">🔌 第三方插件</button>
103
- <button type="button" class="tab-item" role="tab" aria-selected="false" data-tab="withdrawals">💰 提现管理</button>
104
- <button type="button" class="tab-item" role="tab" aria-selected="false" data-tab="disputes">⚖️ 申诉仲裁</button>
105
- <button type="button" class="tab-item" role="tab" aria-selected="false" data-tab="config">🏷️ 版本管理</button>
106
- <button type="button" class="tab-item" role="tab" aria-selected="false" data-tab="broadcast">📢 公告与广告</button>
107
- <button type="button" class="tab-item" role="tab" aria-selected="false" data-tab="promptcats">🗂️ 提示词分类</button>
108
- <button type="button" class="tab-item" role="tab" aria-selected="false" data-tab="scripts">🖥️ 脚本执行</button>
109
  </nav>
110
 
111
  <!-- ============ 页签 1:第三方插件(原有功能) ============ -->
112
- <div class="tab-panel" id="panel-plugins" role="tabpanel">
113
  <section class="page-head">
114
  <div class="page-head-text">
115
  <h1>第三方插件</h1>
@@ -165,7 +165,7 @@
165
  </div>
166
 
167
  <!-- ============ 页签 2:提现管理 ============ -->
168
- <div class="tab-panel" id="panel-withdrawals" role="tabpanel" hidden>
169
  <section class="page-head">
170
  <div class="page-head-text">
171
  <h1>提现管理</h1>
@@ -203,7 +203,7 @@
203
  </div>
204
 
205
  <!-- ============ 页签 3:申诉仲裁 ============ -->
206
- <div class="tab-panel" id="panel-disputes" role="tabpanel" hidden>
207
  <section class="page-head">
208
  <div class="page-head-text">
209
  <h1>申诉仲裁</h1>
@@ -243,7 +243,7 @@
243
  </div>
244
 
245
  <!-- ============ 页签 4:版本管理 ============ -->
246
- <div class="tab-panel" id="panel-config" role="tabpanel" hidden>
247
  <section class="page-head">
248
  <div class="page-head-text">
249
  <h1>版本管理</h1>
@@ -287,7 +287,7 @@
287
  </div>
288
 
289
  <!-- ============ 页签 5:公告与广告 ============ -->
290
- <div class="tab-panel" id="panel-broadcast" role="tabpanel" hidden>
291
  <section class="page-head">
292
  <div class="page-head-text">
293
  <h1>公告与广告</h1>
@@ -373,7 +373,7 @@
373
  </div>
374
 
375
  <!-- ============ 页签 6:提示词分类 ============ -->
376
- <div class="tab-panel" id="panel-promptcats" role="tabpanel" hidden>
377
  <section class="page-head">
378
  <div class="page-head-text">
379
  <h1>提示词分类</h1>
@@ -437,31 +437,31 @@
437
  </div>
438
 
439
  <!-- ============ 页签 7:脚本执行 ============ -->
440
- <div class="tab-panel" id="panel-scripts" role="tabpanel" hidden>
441
  <section class="page-head">
442
  <div class="page-head-text">
443
- <h1>脚本执行</h1>
444
- <p class="page-sub">执行服务端预置的运维脚本(仅限管理员)。</p>
445
  </div>
446
  </section>
447
 
448
  <div class="admin-card">
449
  <div class="admin-card-head">
450
- <h3>🖥️ 运维脚本</h3>
451
- <span class="badge badge-primary">开发工具</span>
452
  </div>
453
  <div class="admin-card-body">
454
- <div class="script-row">
455
  <label class="field script-field">
456
- <span class="field-label">脚本名称</span>
457
- <input type="text" id="script-name" placeholder="输入服务端预置的脚本名称…">
458
  </label>
459
- <button type="button" id="btn-script-run" class="btn btn-primary btn-script-run">
460
- <span class="btn-label">▶️ 执行</span>
461
  <span class="btn-spinner" aria-hidden="true"></span>
462
  </button>
463
- </div>
464
- <pre id="script-result" class="script-result">等待执行…</pre>
465
  </div>
466
  </div>
467
  </div>
 
98
 
99
  <main class="main-shell">
100
  <!-- 🧭 功能页签导航:插件 / 提现 / 仲裁 / 版本 / 公告广告 / 提示词分类 / 脚本 -->
101
+ <nav class="tab-bar" role="tablist" aria-orientation="horizontal">
102
+ <button type="button" class="tab-item is-active" role="tab" id="tab-plugins" aria-controls="panel-plugins" tabindex="0" aria-selected="true" data-tab="plugins"></button>
103
+ <button type="button" class="tab-item" role="tab" id="tab-withdrawals" aria-controls="panel-withdrawals" tabindex="-1" aria-selected="false" data-tab="withdrawals"></button>
104
+ <button type="button" class="tab-item" role="tab" id="tab-disputes" aria-controls="panel-disputes" tabindex="-1" aria-selected="false" data-tab="disputes"></button>
105
+ <button type="button" class="tab-item" role="tab" id="tab-config" aria-controls="panel-config" tabindex="-1" aria-selected="false" data-tab="config"></button>
106
+ <button type="button" class="tab-item" role="tab" id="tab-broadcast" aria-controls="panel-broadcast" tabindex="-1" aria-selected="false" data-tab="broadcast"></button>
107
+ <button type="button" class="tab-item" role="tab" id="tab-promptcats" aria-controls="panel-promptcats" tabindex="-1" aria-selected="false" data-tab="promptcats"></button>
108
+ <button type="button" class="tab-item" role="tab" id="tab-scripts" aria-controls="panel-scripts" tabindex="-1" aria-selected="false" data-tab="scripts" data-admin-script-text="script_tab"></button>
109
  </nav>
110
 
111
  <!-- ============ 页签 1:第三方插件(原有功能) ============ -->
112
+ <div class="tab-panel" id="panel-plugins" role="tabpanel" aria-labelledby="tab-plugins" tabindex="0">
113
  <section class="page-head">
114
  <div class="page-head-text">
115
  <h1>第三方插件</h1>
 
165
  </div>
166
 
167
  <!-- ============ 页签 2:提现管理 ============ -->
168
+ <div class="tab-panel" id="panel-withdrawals" role="tabpanel" aria-labelledby="tab-withdrawals" tabindex="0" hidden>
169
  <section class="page-head">
170
  <div class="page-head-text">
171
  <h1>提现管理</h1>
 
203
  </div>
204
 
205
  <!-- ============ 页签 3:申诉仲裁 ============ -->
206
+ <div class="tab-panel" id="panel-disputes" role="tabpanel" aria-labelledby="tab-disputes" tabindex="0" hidden>
207
  <section class="page-head">
208
  <div class="page-head-text">
209
  <h1>申诉仲裁</h1>
 
243
  </div>
244
 
245
  <!-- ============ 页签 4:版本管理 ============ -->
246
+ <div class="tab-panel" id="panel-config" role="tabpanel" aria-labelledby="tab-config" tabindex="0" hidden>
247
  <section class="page-head">
248
  <div class="page-head-text">
249
  <h1>版本管理</h1>
 
287
  </div>
288
 
289
  <!-- ============ 页签 5:公告与广告 ============ -->
290
+ <div class="tab-panel" id="panel-broadcast" role="tabpanel" aria-labelledby="tab-broadcast" tabindex="0" hidden>
291
  <section class="page-head">
292
  <div class="page-head-text">
293
  <h1>公告与广告</h1>
 
373
  </div>
374
 
375
  <!-- ============ 页签 6:提示词分类 ============ -->
376
+ <div class="tab-panel" id="panel-promptcats" role="tabpanel" aria-labelledby="tab-promptcats" tabindex="0" hidden>
377
  <section class="page-head">
378
  <div class="page-head-text">
379
  <h1>提示词分类</h1>
 
437
  </div>
438
 
439
  <!-- ============ 页签 7:脚本执行 ============ -->
440
+ <div class="tab-panel" id="panel-scripts" role="tabpanel" aria-labelledby="tab-scripts" tabindex="0" hidden>
441
  <section class="page-head">
442
  <div class="page-head-text">
443
+ <h1 id="script-title" data-admin-script-text="script_title"></h1>
444
+ <p class="page-sub" data-admin-script-text="script_subtitle"></p>
445
  </div>
446
  </section>
447
 
448
  <div class="admin-card">
449
  <div class="admin-card-head">
450
+ <h3 data-admin-script-text="script_card_title"></h3>
451
+ <span class="badge badge-primary" data-admin-script-text="script_badge"></span>
452
  </div>
453
  <div class="admin-card-body">
454
+ <form id="form-script" class="script-row" aria-labelledby="script-title" novalidate>
455
  <label class="field script-field">
456
+ <span class="field-label" data-admin-script-text="script_name_label"></span>
457
+ <input type="text" id="script-name">
458
  </label>
459
+ <button type="submit" id="btn-script-run" class="btn btn-primary btn-script-run">
460
+ <span class="btn-label" data-admin-script-text="script_run"></span>
461
  <span class="btn-spinner" aria-hidden="true"></span>
462
  </button>
463
+ </form>
464
+ <pre id="script-result" class="script-result" role="status" aria-live="polite" aria-atomic="true" tabindex="0"></pre>
465
  </div>
466
  </div>
467
  </div>
管理界面/style.css CHANGED
@@ -977,9 +977,11 @@ code, pre { font-family: var(--font-mono); }
977
 
978
  @media (prefers-reduced-motion: reduce) {
979
  *, *::before, *::after {
980
- animation-duration: 0.01ms !important;
981
- transition-duration: 0.01ms !important;
 
982
  }
 
983
  }
984
 
985
  /* ===================================================================
@@ -1018,7 +1020,7 @@ code, pre { font-family: var(--font-mono); }
1018
  color: var(--primary);
1019
  font-weight: 600;
1020
  }
1021
- .tab-item:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
1022
 
1023
  .tab-panel { display: flex; flex-direction: column; gap: 22px; animation: fade-in 220ms var(--ease) both; }
1024
 
@@ -1238,6 +1240,7 @@ code, pre { font-family: var(--font-mono); }
1238
  white-space: pre-wrap;
1239
  word-break: break-all;
1240
  }
 
1241
  .script-result.is-error { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 35%, transparent); background: var(--danger-soft); }
1242
 
1243
  /* ---------- 申诉详情弹窗 ---------- */
@@ -1290,7 +1293,9 @@ code, pre { font-family: var(--font-mono); }
1290
  background: var(--primary-soft);
1291
  box-shadow: 0 0 0 1px var(--primary);
1292
  }
1293
- .resolve-option input { display: none; }
 
 
1294
  .resolve-option-icon { font-size: 18px; }
1295
  .resolve-option-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
1296
  .resolve-option-text strong { font-size: 13.5px; color: var(--text); }
@@ -1305,7 +1310,7 @@ code, pre { font-family: var(--font-mono); }
1305
  border-radius: var(--radius);
1306
  font-size: 13px;
1307
  }
1308
- .split-row input[type="range"] { flex: 1; accent-color: var(--primary); }
1309
  .split-row strong { min-width: 88px; text-align: center; color: var(--primary); font-variant-numeric: tabular-nums; }
1310
 
1311
  .resolve-result {
@@ -1324,5 +1329,32 @@ code, pre { font-family: var(--font-mono); }
1324
  .row-card-meta { grid-template-columns: 1fr; }
1325
  .row-card-action { flex-direction: column; align-items: stretch; }
1326
  .script-row { flex-direction: column; align-items: stretch; }
 
 
1327
  .row-card-parties .initiator { margin-left: 0; }
1328
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
977
 
978
  @media (prefers-reduced-motion: reduce) {
979
  *, *::before, *::after {
980
+ animation: none !important;
981
+ transition: none !important;
982
+ scroll-behavior: auto !important;
983
  }
984
+ .btn:hover, .plugin-card:hover, .admin-row-card:hover { transform: none !important; }
985
  }
986
 
987
  /* ===================================================================
 
1020
  color: var(--primary);
1021
  font-weight: 600;
1022
  }
1023
+ .tab-item:focus-visible, .tab-panel:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
1024
 
1025
  .tab-panel { display: flex; flex-direction: column; gap: 22px; animation: fade-in 220ms var(--ease) both; }
1026
 
 
1240
  white-space: pre-wrap;
1241
  word-break: break-all;
1242
  }
1243
+ .script-result:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
1244
  .script-result.is-error { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 35%, transparent); background: var(--danger-soft); }
1245
 
1246
  /* ---------- 申诉详情弹窗 ---------- */
 
1293
  background: var(--primary-soft);
1294
  box-shadow: 0 0 0 1px var(--primary);
1295
  }
1296
+ .resolve-option input { margin: 0; flex: 0 0 auto; accent-color: var(--primary); }
1297
+ .resolve-option:focus-within { outline: 2px solid var(--primary); outline-offset: 2px; }
1298
+ .modal:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
1299
  .resolve-option-icon { font-size: 18px; }
1300
  .resolve-option-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
1301
  .resolve-option-text strong { font-size: 13.5px; color: var(--text); }
 
1310
  border-radius: var(--radius);
1311
  font-size: 13px;
1312
  }
1313
+ .split-row input[type="range"] { flex: 1; min-width: 0; accent-color: var(--primary); }
1314
  .split-row strong { min-width: 88px; text-align: center; color: var(--primary); font-variant-numeric: tabular-nums; }
1315
 
1316
  .resolve-result {
 
1329
  .row-card-meta { grid-template-columns: 1fr; }
1330
  .row-card-action { flex-direction: column; align-items: stretch; }
1331
  .script-row { flex-direction: column; align-items: stretch; }
1332
+ .split-row { flex-wrap: wrap; }
1333
+ .split-row label { flex-basis: 100%; }
1334
  .row-card-parties .initiator { margin-left: 0; }
1335
  }
1336
+
1337
+ @media (forced-colors: active) {
1338
+ .btn, .tab-item, .filter-tab {
1339
+ border: 1px solid ButtonText;
1340
+ box-shadow: none;
1341
+ }
1342
+ .tab-item.is-active, .filter-tab.is-active, .resolve-option.selected {
1343
+ text-decoration: underline;
1344
+ text-decoration-thickness: 2px;
1345
+ text-underline-offset: 4px;
1346
+ }
1347
+ .btn:focus-visible, .tab-item:focus-visible, .tab-panel:focus-visible,
1348
+ .script-result:focus-visible, .modal-close:focus-visible, .modal:focus-visible, .resolve-option:focus-within,
1349
+ input:focus-visible, select:focus-visible, textarea:focus-visible {
1350
+ outline: 2px solid Highlight;
1351
+ outline-offset: 2px;
1352
+ box-shadow: none;
1353
+ }
1354
+ .btn:disabled, .btn[aria-busy="true"] {
1355
+ opacity: 1;
1356
+ color: GrayText;
1357
+ border-color: GrayText;
1358
+ }
1359
+ .btn[aria-busy="true"] .btn-label { opacity: 1; }
1360
+ }