ZHIWEI666 commited on
Commit
5413648
·
verified ·
1 Parent(s): 4401d6e

将管理员功能改在云端

Browse files
router_users_auth.py CHANGED
@@ -50,9 +50,20 @@ async def send_verify_code(request: Request, req: SendCodeRequest, bg_tasks: Bac
50
  请求参数:
51
  - contact: 邮箱或手机号
52
  - contact_type: "email" 或 "phone"
53
- - action_type: "register" 注册 / "reset" 重置密码
54
  - account: (仅重置密码时必填) 用户账号
55
  """
 
 
 
 
 
 
 
 
 
 
 
56
  # 如果是重置密码,需要先验证账号与联系方式匹配
57
  if req.action_type == "reset":
58
  if not req.account:
@@ -99,13 +110,25 @@ async def send_verify_code(request: Request, req: SendCodeRequest, bg_tasks: Bac
99
 
100
 
101
  # ==========================================
102
- # 📤 发送验证码接口(同步版本,备用)
103
  # ==========================================
104
  # 作用:同步方式发送验证码,部分场景需要立即知道发送结果
105
  # 关联:verify_code_engine.py 的 send_email_code
106
  @router.post("/api/users/send_code")
107
- async def send_code_api(req: SendCodeRequest):
 
108
  """发送验证码接口(同步版本,直接等待发送结果)"""
 
 
 
 
 
 
 
 
 
 
 
109
  # 🔒 检查发送频率限制(同一联系方式60秒内只能发送1条)
110
  check_send_cooldown(req.contact)
111
 
@@ -138,6 +161,71 @@ async def send_code_api(req: SendCodeRequest):
138
  raise HTTPException(status_code=400, detail="不支持的验证方式")
139
 
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  # ==========================================
142
  # 📝 用户注册接口
143
  # ==========================================
 
50
  请求参数:
51
  - contact: 邮箱或手机号
52
  - contact_type: "email" 或 "phone"
53
+ - action_type: "register" 注册 / "reset" 重置密码 / "recover" 找回账号
54
  - account: (仅重置密码时必填) 用户账号
55
  """
56
+ # 🔎 找回账号:凭绑定邮箱/手机反查,无需账号
57
+ if req.action_type == "recover":
58
+ users_db = db.load_data("users.json", default_data={})
59
+ matched = any(
60
+ u.get("email") == req.contact or u.get("phone") == req.contact
61
+ for u in users_db.values()
62
+ )
63
+ if not matched:
64
+ # 🛡️ 防枚举:未注册的联系方式也返回通用成功,但不实际发送验证码
65
+ return {"status": "success", "message": "如果该联系方式已绑定账号,验证码已发送"}
66
+
67
  # 如果是重置密码,需要先验证账号与联系方式匹配
68
  if req.action_type == "reset":
69
  if not req.account:
 
110
 
111
 
112
  # ==========================================
113
+ # 📤 发送验证码接口(同步版本,前端实际调用)
114
  # ==========================================
115
  # 作用:同步方式发送验证码,部分场景需要立即知道发送结果
116
  # 关联:verify_code_engine.py 的 send_email_code
117
  @router.post("/api/users/send_code")
118
+ @limiter.limit("5/minute") # 🔒 安全限流:与异步版对齐,每分钟最多5次
119
+ async def send_code_api(request: Request, req: SendCodeRequest):
120
  """发送验证码接口(同步版本,直接等待发送结果)"""
121
+ # 🔎 找回账号:凭绑定邮箱/手机反查,无需账号
122
+ if req.action_type == "recover":
123
+ users_db = db.load_data("users.json", default_data={})
124
+ matched = any(
125
+ u.get("email") == req.contact or u.get("phone") == req.contact
126
+ for u in users_db.values()
127
+ )
128
+ if not matched:
129
+ # 🛡️ 防枚举:未注册的联系方式也返回通用成功,但不实际发送验证码
130
+ return {"status": "success", "message": "如果该联系方式已绑定账号,验证码已发送"}
131
+
132
  # 🔒 检查发送频率限制(同一联系方式60秒内只能发送1条)
133
  check_send_cooldown(req.contact)
134
 
 
161
  raise HTTPException(status_code=400, detail="不支持的验证方式")
162
 
163
 
164
+ # ==========================================
165
+ # 🔎 找回账号接口(凭绑定邮箱+验证码反查账号)
166
+ # ==========================================
167
+ # 作用:用户同时忘记账号和密码时,凭绑定邮箱收到的验证码找回账号
168
+ # 关联:
169
+ # - verify_code_engine.py 的 VERIFY_CODES (校验 recover 验证码)
170
+ # - 数据库连接.py (遍历 users.json 按邮箱反查账号)
171
+ # - 前端 找回账号表单组件.js
172
+ @router.post("/api/users/recover_account")
173
+ @limiter.limit("3/minute") # 🔒 安全限流:每分钟最多3次
174
+ async def recover_account(request: Request):
175
+ """
176
+ 找回账号接口
177
+
178
+ 请求参数:
179
+ - email: 绑定邮箱
180
+ - code: 6位验证码
181
+ """
182
+ # ========== 第一步:解析请求数据(兼容双重字符串化) ==========
183
+ try:
184
+ data = await request.json()
185
+ if isinstance(data, str):
186
+ data = json.loads(data)
187
+ except Exception:
188
+ raise HTTPException(status_code=400, detail="请求数据解析失败,请检查网络")
189
+
190
+ if not isinstance(data, dict):
191
+ raise HTTPException(status_code=400, detail="请求数据格式异常")
192
+
193
+ email = (data.get("email") or data.get("contact") or "").strip()
194
+ code = (data.get("code") or "").strip()
195
+
196
+ if not email or not code:
197
+ raise HTTPException(status_code=400, detail="缺失必要参数 (邮箱/验证码)")
198
+
199
+ # ========== 第二步:验证码校验(原子性一次性消费,并发安全) ==========
200
+ cache_key = f"{email}:recover"
201
+ async with _verify_code_lock:
202
+ cached = VERIFY_CODES.pop(cache_key, None)
203
+ expire_time = cached.get("expires_at", cached.get("expires", 0)) if cached else 0
204
+
205
+ # 🛡️ 防枚举:验证码错误与邮箱未注册统一报错,不暴露账号注册状态
206
+ if not cached or cached["code"] != code or time.time() > expire_time:
207
+ raise HTTPException(status_code=400, detail="验证码不正确或已过期")
208
+
209
+ # ========== 第三步:按邮箱反查账号 ==========
210
+ users_db = db.load_data("users.json", default_data={})
211
+ matched_account = None
212
+ for account, user in users_db.items():
213
+ if user.get("email") == email:
214
+ matched_account = account
215
+ break
216
+
217
+ if not matched_account:
218
+ # 理论上验证码已验证邮箱存在,此处为防御性分支
219
+ raise HTTPException(status_code=400, detail="验证码不正确或已过期")
220
+
221
+ return {
222
+ "status": "success",
223
+ "message": "账号找回成功",
224
+ "account": matched_account,
225
+ "name": users_db[matched_account].get("name", "")
226
+ }
227
+
228
+
229
  # ==========================================
230
  # 📝 用户注册接口
231
  # ==========================================
verify_code_engine.py CHANGED
@@ -114,6 +114,8 @@ def send_email_code(to_email: str, code: str, action: str):
114
  action_str = "注册账号"
115
  elif action == "withdraw":
116
  action_str = "提现操作"
 
 
117
  else:
118
  action_str = "修改/找回密码"
119
  subject = f"ComfyUI 社区 - {action_str}验证码"
@@ -169,6 +171,8 @@ def send_sms_code(phone: str, code: str, action: str):
169
  action_str = "注册账号"
170
  elif action == "withdraw":
171
  action_str = "提现操作"
 
 
172
  else:
173
  action_str = "修改/找回密码"
174
 
 
114
  action_str = "注册账号"
115
  elif action == "withdraw":
116
  action_str = "提现操作"
117
+ elif action == "recover":
118
+ action_str = "找回账号"
119
  else:
120
  action_str = "修改/找回密码"
121
  subject = f"ComfyUI 社区 - {action_str}验证码"
 
171
  action_str = "注册账号"
172
  elif action == "withdraw":
173
  action_str = "提现操作"
174
+ elif action == "recover":
175
+ action_str = "找回账号"
176
  else:
177
  action_str = "修改/找回密码"
178
 
管理界面/app.js CHANGED
@@ -27,6 +27,12 @@
27
  filtered: [],
28
  searchKeyword: '',
29
  confirmHandler: null,
 
 
 
 
 
 
30
  };
31
 
32
  // -------------------- 工具 --------------------
@@ -132,6 +138,32 @@
132
  }
133
  }
134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  // -------------------- 视图切换 --------------------
136
  function showView(name) {
137
  ['view-login', 'view-main', 'view-forbidden'].forEach((id) => {
@@ -185,7 +217,8 @@
185
  if (!data || !data.token) {
186
  throw new ApiError('登录响应缺少 token', 0, data);
187
  }
188
- persistSession(data.token, data.user || null);
 
189
 
190
  // 校验是否为管理员:尝试访问插件列表(403 → 非管理员)
191
  try {
@@ -640,6 +673,375 @@
640
  }
641
  }
642
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
643
  // -------------------- Toast --------------------
644
  function toast(message, type) {
645
  const container = $('#toast-container');
@@ -656,6 +1058,210 @@
656
  }, type === 'error' ? 4200 : 2800);
657
  }
658
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
659
  // -------------------- 主视图绑定 --------------------
660
  function bindMain() {
661
  $('#btn-logout').addEventListener('click', () => {
@@ -720,6 +1326,13 @@
720
  bindConfirm();
721
  bindKeyModal();
722
  bindModalCommon();
 
 
 
 
 
 
 
723
 
724
  if (state.token) {
725
  // 已有 token,尝试直接进入主界面
 
27
  filtered: [],
28
  searchKeyword: '',
29
  confirmHandler: null,
30
+ // 🆕 管理功能迁移:页签与各模块状态
31
+ activeTab: 'plugins',
32
+ withdrawStatus: 'pending',
33
+ disputeStatus: '',
34
+ bannerImageUrl: '',
35
+ bannerDetailImageUrl: '',
36
  };
37
 
38
  // -------------------- 工具 --------------------
 
138
  }
139
  }
140
 
141
+ // 🆕 multipart 上传(广告图片):FormData 不能 JSON 序列化,独立于 apiRequest
142
+ async function apiUpload(path, formData) {
143
+ const headers = { 'Accept': 'application/json' };
144
+ if (state.token) headers['Authorization'] = 'Bearer ' + state.token;
145
+ let response;
146
+ try {
147
+ response = await fetch(API_BASE + path, { method: 'POST', headers, body: formData });
148
+ } catch (err) {
149
+ throw new ApiError('网络异常,请检查连接后重试', 0, err);
150
+ }
151
+ if (response.status === 401) {
152
+ handleUnauthorized();
153
+ throw new ApiError('登录已失效,请重新登录', 401);
154
+ }
155
+ let payload = null;
156
+ const text = await response.text();
157
+ if (text) {
158
+ try { payload = JSON.parse(text); } catch (e) { payload = { message: text }; }
159
+ }
160
+ if (!response.ok) {
161
+ const msg = (payload && (payload.message || payload.error || payload.detail)) || `请求失败(${response.status})`;
162
+ throw new ApiError(msg, response.status, payload);
163
+ }
164
+ return payload;
165
+ }
166
+
167
  // -------------------- 视图切换 --------------------
168
  function showView(name) {
169
  ['view-login', 'view-main', 'view-forbidden'].forEach((id) => {
 
217
  if (!data || !data.token) {
218
  throw new ApiError('登录响应缺少 token', 0, data);
219
  }
220
+ // 登录响应顶层返回 account/name/avatar,组装为 user 对象(管理操作的 admin_account 取自这里)
221
+ persistSession(data.token, data.user || { account: data.account, name: data.name, avatar: data.avatar });
222
 
223
  // 校验是否为管理员:尝试访问插件列表(403 → 非管理员)
224
  try {
 
673
  }
674
  }
675
 
676
+ // -------------------- 🧭 页签切换 --------------------
677
+ const TAB_TITLES = {
678
+ plugins: '第三方插件',
679
+ withdrawals: '提现管理',
680
+ disputes: '申诉仲裁',
681
+ config: '版本管理',
682
+ broadcast: '公告与广告',
683
+ scripts: '脚本执行'
684
+ };
685
+
686
+ // 管理操作所需的 admin_account(后端实际以 Token 鉴权,此字段仅为请求体必填项)
687
+ function adminAccount() {
688
+ const u = state.user || {};
689
+ return u.account || u.username || '';
690
+ }
691
+
692
+ function switchTab(name) {
693
+ state.activeTab = name;
694
+ $$('.tab-item').forEach((btn) => {
695
+ const active = btn.dataset.tab === name;
696
+ btn.classList.toggle('is-active', active);
697
+ btn.setAttribute('aria-selected', String(active));
698
+ });
699
+ Object.keys(TAB_TITLES).forEach((key) => {
700
+ const panel = document.getElementById('panel-' + key);
701
+ if (panel) panel.hidden = (key !== name);
702
+ });
703
+ const crumb = $('.crumbs-current');
704
+ if (crumb) crumb.textContent = TAB_TITLES[name];
705
+ // 管理操作低频,每次切换拉取最新数据保证时效
706
+ if (name === 'withdrawals') refreshWithdrawals();
707
+ if (name === 'disputes') refreshDisputes();
708
+ if (name === 'config') loadVersionConfig();
709
+ if (name === 'broadcast') refreshBannerStatus();
710
+ }
711
+
712
+ function bindTabs() {
713
+ $$('.tab-item').forEach((btn) => {
714
+ btn.addEventListener('click', () => switchTab(btn.dataset.tab));
715
+ });
716
+ }
717
+
718
+ // -------------------- 💰 提现管理 --------------------
719
+ function setWithdrawState(stateName, message) {
720
+ $('#withdraw-loading').hidden = stateName !== 'loading';
721
+ $('#withdraw-empty').hidden = stateName !== 'empty';
722
+ $('#withdraw-error').hidden = stateName !== 'error';
723
+ $('#withdraw-list').hidden = stateName !== 'data';
724
+ if (stateName === 'error' && message) $('#withdraw-error-msg').textContent = message;
725
+ if (stateName === 'empty') {
726
+ $('#withdraw-empty-msg').textContent = state.withdrawStatus === 'pending'
727
+ ? '当前没有待打款的提现申请。'
728
+ : '当前没有已完成的提现记录。';
729
+ }
730
+ }
731
+
732
+ async function refreshWithdrawals() {
733
+ try {
734
+ setWithdrawState('loading');
735
+ const data = await apiRequest('GET', '/api/admin/withdrawals?status=' + state.withdrawStatus);
736
+ renderWithdrawList(Array.isArray(data && data.data) ? data.data : []);
737
+ } catch (err) {
738
+ if (err instanceof ApiError && err.status === 401) return; // 已自动跳回登录
739
+ setWithdrawState('error', err.message || '加载失败');
740
+ }
741
+ }
742
+
743
+ function renderWithdrawList(list) {
744
+ const wrap = $('#withdraw-list');
745
+ if (!list.length) { setWithdrawState('empty'); return; }
746
+ setWithdrawState('data');
747
+ wrap.innerHTML = '';
748
+ const frag = document.createDocumentFragment();
749
+ list.forEach((w) => frag.appendChild(buildWithdrawCard(w)));
750
+ wrap.appendChild(frag);
751
+ }
752
+
753
+ function buildWithdrawCard(w) {
754
+ const isPending = (w.withdraw_status || 'pending') === 'pending';
755
+ const amount = Math.abs(w.amount || 0);
756
+ const card = document.createElement('article');
757
+ card.className = 'admin-card admin-row-card';
758
+ card.innerHTML = `
759
+ <div class="row-card-head">
760
+ <div class="row-card-title">
761
+ <span class="row-card-name">👤 ${escapeHtml(w.account || '未知用户')}</span>
762
+ <span class="badge ${isPending ? 'is-off' : 'is-on'}">${isPending ? '待打款' : '已完成'}</span>
763
+ </div>
764
+ <div class="row-card-amount">${formatNumber(amount)} 积分 <em>≈ ¥${formatNumber(amount)}</em></div>
765
+ </div>
766
+ <dl class="row-card-meta">
767
+ <div><dt>支付宝账号</dt><dd>${escapeHtml(w.alipay_account || '-')}</dd></div>
768
+ <div><dt>真实姓名</dt><dd>${escapeHtml(w.real_name || '-')}</dd></div>
769
+ <div><dt>申请时间</dt><dd>${escapeHtml(formatDate(w.created_at))}</dd></div>
770
+ <div><dt>交易 ID</dt><dd>#${escapeHtml(w.tx_id)}</dd></div>
771
+ </dl>
772
+ ${isPending ? `
773
+ <div class="row-card-action">
774
+ <input type="text" class="row-input js-order-input" placeholder="输入打款订单号(1-64 字符)" maxlength="64">
775
+ <button type="button" class="btn btn-primary btn-sm js-confirm-pay">
776
+ <span class="btn-label">确认打款</span>
777
+ <span class="btn-spinner" aria-hidden="true"></span>
778
+ </button>
779
+ </div>` : `
780
+ <div class="row-card-done">✅ 已完成打款 · 订单号:${escapeHtml(w.payment_order_id || '-')}</div>`}
781
+ `;
782
+ if (isPending) {
783
+ const btn = $('.js-confirm-pay', card);
784
+ btn.addEventListener('click', () => confirmWithdrawPayment(w, card, btn));
785
+ }
786
+ return card;
787
+ }
788
+
789
+ function confirmWithdrawPayment(w, card, btn) {
790
+ const input = $('.js-order-input', card);
791
+ const orderId = input.value.trim();
792
+ if (!orderId) { toast('请先输入打款订单号', 'warning'); input.focus(); return; }
793
+ confirmAction({
794
+ title: '确认打款',
795
+ message: `确认已向 ${w.account || '该用户'} 打款 ${Math.abs(w.amount || 0)} 积分对应的金额?\n订单号:${orderId}`,
796
+ okText: '确认打款',
797
+ danger: false,
798
+ onConfirm: async () => {
799
+ setBusy(btn, true);
800
+ try {
801
+ await apiRequest('POST', '/api/admin/withdrawals/' + encodeURIComponent(w.tx_id) + '/complete', { payment_order_id: orderId });
802
+ toast('打款已确认', 'success');
803
+ await refreshWithdrawals();
804
+ } finally {
805
+ setBusy(btn, false);
806
+ }
807
+ }
808
+ });
809
+ }
810
+
811
+ function bindWithdrawals() {
812
+ $('#btn-withdraw-refresh').addEventListener('click', () => refreshWithdrawals());
813
+ $$('#withdraw-status-tabs .filter-tab').forEach((tab) => {
814
+ tab.addEventListener('click', () => {
815
+ state.withdrawStatus = tab.dataset.status;
816
+ $$('#withdraw-status-tabs .filter-tab').forEach((t) => t.classList.toggle('is-active', t === tab));
817
+ refreshWithdrawals();
818
+ });
819
+ });
820
+ }
821
+
822
+ // -------------------- ⚖️ 申诉仲裁 --------------------
823
+ const DISPUTE_STATUS = {
824
+ pending: { label: '待回应', cls: 'is-warn' },
825
+ responded: { label: '待裁决', cls: 'is-info' },
826
+ resolved: { label: '已裁决', cls: 'is-on' }
827
+ };
828
+
829
+ const RESOLUTION_OPTIONS = [
830
+ { value: 'favor_initiator', icon: '✅', label: '支持申诉方' },
831
+ { value: 'favor_respondent', icon: '❌', label: '支持被申诉方' },
832
+ { value: 'split', icon: '⚖️', label: '按比例分成', desc: '双方按比例分配任务金额' }
833
+ ];
834
+
835
+ function setDisputeState(stateName, message) {
836
+ $('#dispute-loading').hidden = stateName !== 'loading';
837
+ $('#dispute-empty').hidden = stateName !== 'empty';
838
+ $('#dispute-error').hidden = stateName !== 'error';
839
+ $('#dispute-list').hidden = stateName !== 'data';
840
+ if (stateName === 'error' && message) $('#dispute-error-msg').textContent = message;
841
+ }
842
+
843
+ async function refreshDisputes() {
844
+ try {
845
+ setDisputeState('loading');
846
+ const url = state.disputeStatus
847
+ ? '/api/admin/disputes?status=' + state.disputeStatus
848
+ : '/api/admin/disputes';
849
+ const data = await apiRequest('GET', url);
850
+ renderDisputeList(Array.isArray(data && data.data) ? data.data : []);
851
+ } catch (err) {
852
+ if (err instanceof ApiError && err.status === 401) return;
853
+ setDisputeState('error', err.message || '加载失败');
854
+ }
855
+ }
856
+
857
+ function renderDisputeList(list) {
858
+ const wrap = $('#dispute-list');
859
+ if (!list.length) { setDisputeState('empty'); return; }
860
+ setDisputeState('data');
861
+ wrap.innerHTML = '';
862
+ const frag = document.createDocumentFragment();
863
+ list.forEach((d) => frag.appendChild(buildDisputeCard(d)));
864
+ wrap.appendChild(frag);
865
+ }
866
+
867
+ function buildDisputeCard(d) {
868
+ const st = DISPUTE_STATUS[d.status] || DISPUTE_STATUS.pending;
869
+ const card = document.createElement('article');
870
+ card.className = 'admin-card admin-row-card is-clickable';
871
+ card.innerHTML = `
872
+ <div class="row-card-head">
873
+ <div class="row-card-title">
874
+ <span class="row-card-name">${escapeHtml(d.task_title || '未知任务')}</span>
875
+ <span class="badge ${st.cls}">${st.label}</span>
876
+ </div>
877
+ <div class="row-card-time">${escapeHtml(formatDate(d.created_at))}</div>
878
+ </div>
879
+ <div class="row-card-parties">
880
+ <span>${escapeHtml(d.publisher_name || d.publisher || '?')}</span>
881
+ <em class="vs">VS</em>
882
+ <span>${escapeHtml(d.assignee_name || d.assignee || '?')}</span>
883
+ <span class="initiator">发起方:${d.initiator_role === 'publisher' ? '发布者' : '接单方'}</span>
884
+ </div>
885
+ <p class="row-card-reason">${escapeHtml(d.reason || '(未填写申诉理由)')}</p>
886
+ `;
887
+ card.addEventListener('click', () => openDisputeModal(d.id));
888
+ return card;
889
+ }
890
+
891
+ async function openDisputeModal(disputeId) {
892
+ openModal('modal-dispute');
893
+ const body = $('#dispute-detail-body');
894
+ body.innerHTML = '<div class="state-block"><div class="spinner-lg" aria-hidden="true"></div><p>正在加载申诉详情…</p></div>';
895
+ try {
896
+ const res = await apiRequest('GET', '/api/disputes/' + encodeURIComponent(disputeId));
897
+ const dispute = (res && res.data) || res || {};
898
+ renderDisputeDetail(body, dispute);
899
+ } catch (err) {
900
+ body.innerHTML = '<div class="state-block state-error"><div class="empty-mark">!</div><h3>加载失败</h3><p>' +
901
+ escapeHtml(err.message || '请稍后重试。') + '</p></div>';
902
+ }
903
+ }
904
+
905
+ function renderEvidence(images) {
906
+ if (!Array.isArray(images) || !images.length) return '';
907
+ return '<div class="evidence-row">' + images.map((src) =>
908
+ `<img src="${escapeHtml(src)}" alt="证据图片" loading="lazy" class="js-evidence-img">`
909
+ ).join('') + '</div>';
910
+ }
911
+
912
+ // 证据图点击放大:程序化绑定 + scheme 白名单(src 为用户上传内容,禁止 javascript: 等非 http(s) 协议)
913
+ function bindEvidence(root) {
914
+ $$('.js-evidence-img', root).forEach((img) => {
915
+ img.addEventListener('click', () => {
916
+ let url;
917
+ try { url = new URL(img.src, window.location.href); } catch (e) { return; }
918
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
919
+ window.open(url.href, '_blank', 'noopener');
920
+ });
921
+ });
922
+ }
923
+
924
+ function renderDisputeDetail(body, d) {
925
+ const canResolve = d.status !== 'resolved';
926
+ const initiatorLabel = d.initiator_role === 'publisher' ? '发布者' : '接单方';
927
+ const respondentLabel = d.initiator_role === 'publisher' ? '接单方' : '发布者';
928
+ body.innerHTML = `
929
+ <div class="dispute-section">
930
+ <div class="dispute-section-title">📋 关联任务</div>
931
+ <div class="dispute-section-body">${escapeHtml(d.task_title || '未知任务')}</div>
932
+ </div>
933
+ <div class="dispute-section">
934
+ <div class="dispute-section-title">📝 申诉方陈述(${escapeHtml(initiatorLabel)})</div>
935
+ <div class="dispute-section-body">${escapeHtml(d.reason || '(无内容)')}${renderEvidence(d.evidence)}</div>
936
+ </div>
937
+ <div class="dispute-section">
938
+ <div class="dispute-section-title">💬 被申诉方回应(${escapeHtml(respondentLabel)})</div>
939
+ <div class="dispute-section-body">${escapeHtml(d.response || '(尚未回应)')}${renderEvidence(d.response_evidence)}</div>
940
+ </div>
941
+ ${canResolve ? renderResolveForm() : renderResolutionResult(d)}
942
+ `;
943
+ bindEvidence(body);
944
+ if (canResolve) bindResolveForm(body, d);
945
+ }
946
+
947
+ function renderResolveForm() {
948
+ return `
949
+ <div class="resolve-box">
950
+ <div class="resolve-box-title">🔨 裁决操作</div>
951
+ <div class="resolve-options">
952
+ ${RESOLUTION_OPTIONS.map((opt) => `
953
+ <label class="resolve-option" data-value="${opt.value}">
954
+ <input type="radio" name="resolution" value="${opt.value}">
955
+ <span class="resolve-option-icon">${opt.icon}</span>
956
+ <span class="resolve-option-text">
957
+ <strong>${opt.label}</strong>
958
+ ${opt.desc ? `<em>${opt.desc}</em>` : ''}
959
+ </span>
960
+ </label>`).join('')}
961
+ </div>
962
+ <div class="split-row" id="split-row" hidden>
963
+ <span>申诉方分成</span>
964
+ <input type="range" id="resolve-ratio" min="0" max="100" value="50">
965
+ <strong id="resolve-ratio-text">50% : 50%</strong>
966
+ </div>
967
+ <label class="field">
968
+ <span class="field-label">裁决说明(可选)</span>
969
+ <textarea id="resolve-note" rows="3" placeholder="说明裁决依据…"></textarea>
970
+ </label>
971
+ <button type="button" id="btn-resolve" class="btn btn-primary btn-block" disabled>
972
+ <span class="btn-label">确认裁决</span>
973
+ <span class="btn-spinner" aria-hidden="true"></span>
974
+ </button>
975
+ </div>`;
976
+ }
977
+
978
+ function renderResolutionResult(d) {
979
+ const ratio = d.resolution_ratio ?? 50;
980
+ let text = '已裁决';
981
+ if (d.resolution === 'favor_initiator') text = '支持申诉方';
982
+ else if (d.resolution === 'favor_respondent') text = '支持被申诉方';
983
+ else if (d.resolution === 'split') text = `按比例分成(${ratio}% : ${100 - ratio}%)`;
984
+ return `
985
+ <div class="resolve-result">
986
+ <div class="resolve-result-title">✅ 裁决结果:${escapeHtml(text)}</div>
987
+ ${d.resolution_note ? `<p>${escapeHtml(d.resolution_note)}</p>` : ''}
988
+ </div>`;
989
+ }
990
+
991
+ function bindResolveForm(body, dispute) {
992
+ let selected = null;
993
+ const options = $$('.resolve-option', body);
994
+ const splitRow = $('#split-row', body);
995
+ const ratioInput = $('#resolve-ratio', body);
996
+ const ratioText = $('#resolve-ratio-text', body);
997
+ const submitBtn = $('#btn-resolve', body);
998
+
999
+ options.forEach((opt) => {
1000
+ opt.addEventListener('click', () => {
1001
+ options.forEach((o) => o.classList.remove('selected'));
1002
+ opt.classList.add('selected');
1003
+ const radio = opt.querySelector('input');
1004
+ if (radio) radio.checked = true;
1005
+ selected = opt.dataset.value;
1006
+ splitRow.hidden = selected !== 'split';
1007
+ submitBtn.disabled = false;
1008
+ });
1009
+ });
1010
+ ratioInput.addEventListener('input', () => {
1011
+ const v = parseInt(ratioInput.value, 10) || 0;
1012
+ ratioText.textContent = `${v}% : ${100 - v}%`;
1013
+ });
1014
+ submitBtn.addEventListener('click', async () => {
1015
+ if (!selected) return;
1016
+ const note = $('#resolve-note', body).value.trim();
1017
+ setBusy(submitBtn, true);
1018
+ try {
1019
+ // ⚠️ 后端 resolve 端点为裸标量参数(FastAPI 按 query 解析),必须走 query 传参而非 JSON body
1020
+ const params = new URLSearchParams({ resolution: selected });
1021
+ if (selected === 'split') params.set('ratio', String(parseInt(ratioInput.value, 10) || 50));
1022
+ if (note) params.set('note', note);
1023
+ await apiRequest('POST', '/api/admin/disputes/' + encodeURIComponent(dispute.id) + '/resolve?' + params.toString());
1024
+ toast('裁决已生效', 'success');
1025
+ closeModal('modal-dispute');
1026
+ refreshDisputes();
1027
+ } catch (err) {
1028
+ toast(err.message || '裁决失败', 'error');
1029
+ setBusy(submitBtn, false);
1030
+ }
1031
+ });
1032
+ }
1033
+
1034
+ function bindDisputes() {
1035
+ $('#btn-dispute-refresh').addEventListener('click', () => refreshDisputes());
1036
+ $$('#dispute-status-tabs .filter-tab').forEach((tab) => {
1037
+ tab.addEventListener('click', () => {
1038
+ state.disputeStatus = tab.dataset.status || '';
1039
+ $$('#dispute-status-tabs .filter-tab').forEach((t) => t.classList.toggle('is-active', t === tab));
1040
+ refreshDisputes();
1041
+ });
1042
+ });
1043
+ }
1044
+
1045
  // -------------------- Toast --------------------
1046
  function toast(message, type) {
1047
  const container = $('#toast-container');
 
1058
  }, type === 'error' ? 4200 : 2800);
1059
  }
1060
 
1061
+ // -------------------- 🏷️ 版本管理 --------------------
1062
+ const STAGE_LABELS = { alpha: 'Alpha(内测)', beta: 'Beta(公测)', rc: 'RC(候选发布)', stable: 'Stable(稳定版)' };
1063
+
1064
+ function updateVerPreview() {
1065
+ const form = $('#form-version');
1066
+ if (!form) return;
1067
+ const stage = form.querySelector('[name="stage"]').value;
1068
+ const major = form.querySelector('[name="major"]').value;
1069
+ const minor = form.querySelector('[name="minor"]').value;
1070
+ const patch = form.querySelector('[name="patch"]').value;
1071
+ $('#ver-preview').textContent = `版本预览:V${major}.${minor}.${patch} · ${STAGE_LABELS[stage] || stage}`;
1072
+ }
1073
+
1074
+ async function loadVersionConfig() {
1075
+ try {
1076
+ showError($('#form-version-error'), '');
1077
+ const res = await apiRequest('GET', '/api/admin/config/project_version');
1078
+ const cfg = (res && res.data) || {};
1079
+ const form = $('#form-version');
1080
+ form.querySelector('[name="stage"]').value = cfg.stage || 'beta';
1081
+ form.querySelector('[name="major"]').value = cfg.major ?? 2;
1082
+ form.querySelector('[name="minor"]').value = cfg.minor ?? 0;
1083
+ form.querySelector('[name="patch"]').value = cfg.patch ?? 0;
1084
+ updateVerPreview();
1085
+ } catch (err) {
1086
+ if (!(err instanceof ApiError && err.status === 401)) {
1087
+ showError($('#form-version-error'), err.message || '加载当前版本配置失败');
1088
+ }
1089
+ }
1090
+ }
1091
+
1092
+ function bindVersion() {
1093
+ const form = $('#form-version');
1094
+ ['change', 'input'].forEach((evt) => form.addEventListener(evt, updateVerPreview));
1095
+ form.addEventListener('submit', async (event) => {
1096
+ event.preventDefault();
1097
+ showError($('#form-version-error'), '');
1098
+ const payload = {
1099
+ stage: form.querySelector('[name="stage"]').value,
1100
+ major: toIntOr(form.querySelector('[name="major"]').value, 0),
1101
+ minor: toIntOr(form.querySelector('[name="minor"]').value, 0),
1102
+ patch: toIntOr(form.querySelector('[name="patch"]').value, 0)
1103
+ };
1104
+ const btn = $('#btn-version-save');
1105
+ setBusy(btn, true);
1106
+ try {
1107
+ await apiRequest('PUT', '/api/admin/config/project_version', payload);
1108
+ toast(`版本已更新为 V${payload.major}.${payload.minor}.${payload.patch}(${STAGE_LABELS[payload.stage] || payload.stage})`, 'success');
1109
+ } catch (err) {
1110
+ showError($('#form-version-error'), err.message || '版本更新失败');
1111
+ } finally {
1112
+ setBusy(btn, false);
1113
+ }
1114
+ });
1115
+ }
1116
+
1117
+ // -------------------- 📢 公告与广告 --------------------
1118
+ async function refreshBannerStatus() {
1119
+ const badge = $('#banner-status-badge');
1120
+ try {
1121
+ const res = await apiRequest('GET', '/api/public/banner_config');
1122
+ const data = res && res.data;
1123
+ if (data && data.enabled && data.bannerImage) {
1124
+ badge.textContent = '广告展示中';
1125
+ badge.className = 'badge is-on';
1126
+ } else {
1127
+ badge.textContent = '无活跃广告';
1128
+ badge.className = 'badge is-off';
1129
+ }
1130
+ } catch (err) {
1131
+ badge.textContent = '状态未知';
1132
+ badge.className = 'badge';
1133
+ }
1134
+ }
1135
+
1136
+ function setPreviewImg(id, url) {
1137
+ const img = document.getElementById(id);
1138
+ img.src = url;
1139
+ img.hidden = false;
1140
+ }
1141
+
1142
+ function bindUploadButton(btnId, inputId, previewId, onDone) {
1143
+ const btn = document.getElementById(btnId);
1144
+ const input = document.getElementById(inputId);
1145
+ btn.addEventListener('click', () => input.click());
1146
+ input.addEventListener('change', async () => {
1147
+ const file = input.files[0];
1148
+ if (!file) return;
1149
+ const label = $('.btn-label', btn);
1150
+ const oldText = label.textContent;
1151
+ label.textContent = '上传中…';
1152
+ setBusy(btn, true);
1153
+ try {
1154
+ const fd = new FormData();
1155
+ fd.append('file', file);
1156
+ fd.append('file_type', 'banner');
1157
+ const res = await apiUpload('/api/upload', fd);
1158
+ if (res && res.url) {
1159
+ onDone(res.url);
1160
+ setPreviewImg(previewId, res.url);
1161
+ toast('图片上传成功', 'success');
1162
+ } else {
1163
+ throw new ApiError('上传响应缺少图片地址', 0, res);
1164
+ }
1165
+ } catch (err) {
1166
+ toast(err.message || '图片上传失败', 'error');
1167
+ } finally {
1168
+ label.textContent = oldText;
1169
+ setBusy(btn, false);
1170
+ input.value = '';
1171
+ }
1172
+ });
1173
+ }
1174
+
1175
+ function bindBroadcast() {
1176
+ // 📢 公告发布(二次确认,防误发)
1177
+ $('#btn-ann-publish').addEventListener('click', () => {
1178
+ const content = $('#ann-content').value.trim();
1179
+ if (!content) { toast('请输入公告内容', 'warning'); return; }
1180
+ confirmAction({
1181
+ title: '发布全站公告',
1182
+ message: `公告将推送给全体用户,确认发布?\n\n${content.slice(0, 80)}${content.length > 80 ? '…' : ''}`,
1183
+ okText: '确认发布',
1184
+ danger: true,
1185
+ onConfirm: async () => {
1186
+ await apiRequest('POST', '/api/system/announcement', { admin_account: adminAccount(), content });
1187
+ $('#ann-content').value = '';
1188
+ toast('公告已发布', 'success');
1189
+ }
1190
+ });
1191
+ });
1192
+
1193
+ // 📰 广告图片上传
1194
+ bindUploadButton('btn-banner-img', 'banner-img-file', 'banner-img-preview', (url) => { state.bannerImageUrl = url; });
1195
+ bindUploadButton('btn-detail-img', 'banner-detail-img-file', 'banner-detail-img-preview', (url) => { state.bannerDetailImageUrl = url; });
1196
+
1197
+ // 💾 保存广告配置
1198
+ $('#btn-banner-save').addEventListener('click', async () => {
1199
+ const enabled = $('#banner-enabled').checked;
1200
+ if (enabled && !state.bannerImageUrl) {
1201
+ toast('启用广告时必须上传横幅图片', 'warning');
1202
+ return;
1203
+ }
1204
+ const config = {
1205
+ enabled: enabled,
1206
+ bannerImage: state.bannerImageUrl,
1207
+ detailImage: state.bannerDetailImageUrl,
1208
+ title: $('#banner-title').value.trim(),
1209
+ description: $('#banner-desc').value.trim(),
1210
+ detailContent: $('#banner-detail-content').value.trim()
1211
+ };
1212
+ const btn = $('#btn-banner-save');
1213
+ setBusy(btn, true);
1214
+ try {
1215
+ await apiRequest('PUT', '/api/admin/config/banner_config', config);
1216
+ toast('广告配置已保存', 'success');
1217
+ // 保存成功后清空表单并刷新状态(与本地端原行为一致)
1218
+ $('#banner-enabled').checked = false;
1219
+ state.bannerImageUrl = '';
1220
+ state.bannerDetailImageUrl = '';
1221
+ ['banner-img-preview', 'banner-detail-img-preview'].forEach((id) => {
1222
+ const img = document.getElementById(id);
1223
+ img.src = '';
1224
+ img.hidden = true;
1225
+ });
1226
+ $('#banner-title').value = '';
1227
+ $('#banner-desc').value = '';
1228
+ $('#banner-detail-content').value = '';
1229
+ refreshBannerStatus();
1230
+ } catch (err) {
1231
+ toast(err.message || '保存失败', 'error');
1232
+ } finally {
1233
+ setBusy(btn, false);
1234
+ }
1235
+ });
1236
+ }
1237
+
1238
+ // -------------------- 🖥️ 脚本执行 --------------------
1239
+ function bindScripts() {
1240
+ $('#btn-script-run').addEventListener('click', async () => {
1241
+ const name = $('#script-name').value.trim();
1242
+ const result = $('#script-result');
1243
+ if (!name) {
1244
+ result.textContent = '❌ 请输入脚本名称';
1245
+ result.classList.add('is-error');
1246
+ return;
1247
+ }
1248
+ const btn = $('#btn-script-run');
1249
+ setBusy(btn, true);
1250
+ result.classList.remove('is-error');
1251
+ result.textContent = `⚡ 正在执行 ${name} …`;
1252
+ try {
1253
+ const res = await apiRequest('POST', '/api/admin/run-script', { admin_account: adminAccount(), script_name: name });
1254
+ const output = (res && (res.output || res.message)) || JSON.stringify(res, null, 2);
1255
+ result.textContent = `✅ 执行完成:\n${output}`;
1256
+ } catch (err) {
1257
+ result.textContent = `❌ 执行失败:\n${err.message}`;
1258
+ result.classList.add('is-error');
1259
+ } finally {
1260
+ setBusy(btn, false);
1261
+ }
1262
+ });
1263
+ }
1264
+
1265
  // -------------------- 主视图绑定 --------------------
1266
  function bindMain() {
1267
  $('#btn-logout').addEventListener('click', () => {
 
1326
  bindConfirm();
1327
  bindKeyModal();
1328
  bindModalCommon();
1329
+ // 🆕 管理功能迁移:页签导航与 5 个新模块
1330
+ bindTabs();
1331
+ bindWithdrawals();
1332
+ bindDisputes();
1333
+ bindVersion();
1334
+ bindBroadcast();
1335
+ bindScripts();
1336
 
1337
  if (state.token) {
1338
  // 已有 token,尝试直接进入主界面
管理界面/index.html CHANGED
@@ -97,6 +97,18 @@
97
  </header>
98
 
99
  <main class="main-shell">
 
 
 
 
 
 
 
 
 
 
 
 
100
  <section class="page-head">
101
  <div class="page-head-text">
102
  <h1>第三方插件</h1>
@@ -149,6 +161,245 @@
149
  </div>
150
  <div id="plugin-grid" class="plugin-grid"></div>
151
  </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  </main>
153
  </section>
154
 
@@ -319,6 +570,25 @@
319
  </div>
320
  </div>
321
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
  <!-- ============ 通用确认对话框 ============ -->
323
  <div id="modal-confirm" class="modal-mask" hidden>
324
  <div class="modal modal-sm" role="alertdialog" aria-modal="true" aria-labelledby="modal-confirm-title">
 
97
  </header>
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="scripts">🖥️ 脚本执行</button>
108
+ </nav>
109
+
110
+ <!-- ============ 页签 1:第三方插件(原有功能) ============ -->
111
+ <div class="tab-panel" id="panel-plugins" role="tabpanel">
112
  <section class="page-head">
113
  <div class="page-head-text">
114
  <h1>第三方插件</h1>
 
161
  </div>
162
  <div id="plugin-grid" class="plugin-grid"></div>
163
  </section>
164
+ </div>
165
+
166
+ <!-- ============ 页签 2:提现管理 ============ -->
167
+ <div class="tab-panel" id="panel-withdrawals" role="tabpanel" hidden>
168
+ <section class="page-head">
169
+ <div class="page-head-text">
170
+ <h1>提现管理</h1>
171
+ <p class="page-sub">审核用户提现申请,线下打款后回填支付订单号完成结算。</p>
172
+ </div>
173
+ <div class="page-head-actions">
174
+ <button type="button" id="btn-withdraw-refresh" class="btn btn-secondary" title="刷新">
175
+ <span class="btn-label">刷新</span>
176
+ </button>
177
+ </div>
178
+ </section>
179
+
180
+ <div class="filter-tabs" id="withdraw-status-tabs">
181
+ <button type="button" class="filter-tab is-active" data-status="pending">⏳ 待打款</button>
182
+ <button type="button" class="filter-tab" data-status="completed">✅ 已完成</button>
183
+ </div>
184
+
185
+ <section class="list-shell">
186
+ <div id="withdraw-loading" class="state-block" hidden>
187
+ <div class="spinner-lg" aria-hidden="true"></div>
188
+ <p>正在加载提现列表…</p>
189
+ </div>
190
+ <div id="withdraw-empty" class="state-block" hidden>
191
+ <div class="empty-mark">📭</div>
192
+ <h3>暂无提现记录</h3>
193
+ <p id="withdraw-empty-msg">当前没有待打款的提现申请。</p>
194
+ </div>
195
+ <div id="withdraw-error" class="state-block state-error" hidden>
196
+ <div class="empty-mark">!</div>
197
+ <h3>加载失败</h3>
198
+ <p id="withdraw-error-msg">请稍后重试。</p>
199
+ </div>
200
+ <div id="withdraw-list" class="admin-list"></div>
201
+ </section>
202
+ </div>
203
+
204
+ <!-- ============ 页签 3:申诉仲裁 ============ -->
205
+ <div class="tab-panel" id="panel-disputes" role="tabpanel" hidden>
206
+ <section class="page-head">
207
+ <div class="page-head-text">
208
+ <h1>申诉仲裁</h1>
209
+ <p class="page-sub">查看任务纠纷申诉,审核双方证据并做出裁决。</p>
210
+ </div>
211
+ <div class="page-head-actions">
212
+ <button type="button" id="btn-dispute-refresh" class="btn btn-secondary" title="刷新">
213
+ <span class="btn-label">刷新</span>
214
+ </button>
215
+ </div>
216
+ </section>
217
+
218
+ <div class="filter-tabs" id="dispute-status-tabs">
219
+ <button type="button" class="filter-tab is-active" data-status="">全部</button>
220
+ <button type="button" class="filter-tab" data-status="pending">待回应</button>
221
+ <button type="button" class="filter-tab" data-status="responded">待裁决</button>
222
+ <button type="button" class="filter-tab" data-status="resolved">已裁决</button>
223
+ </div>
224
+
225
+ <section class="list-shell">
226
+ <div id="dispute-loading" class="state-block" hidden>
227
+ <div class="spinner-lg" aria-hidden="true"></div>
228
+ <p>正在加载申诉列表…</p>
229
+ </div>
230
+ <div id="dispute-empty" class="state-block" hidden>
231
+ <div class="empty-mark">📭</div>
232
+ <h3>暂无申诉</h3>
233
+ <p>当前筛选条件下没有申诉记录。</p>
234
+ </div>
235
+ <div id="dispute-error" class="state-block state-error" hidden>
236
+ <div class="empty-mark">!</div>
237
+ <h3>加载失败</h3>
238
+ <p id="dispute-error-msg">请稍后重试。</p>
239
+ </div>
240
+ <div id="dispute-list" class="admin-list"></div>
241
+ </section>
242
+ </div>
243
+
244
+ <!-- ============ 页签 4:版本管理 ============ -->
245
+ <div class="tab-panel" id="panel-config" role="tabpanel" hidden>
246
+ <section class="page-head">
247
+ <div class="page-head-text">
248
+ <h1>版本管理</h1>
249
+ <p class="page-sub">设置全站展示的项目版本号与开发阶段。</p>
250
+ </div>
251
+ </section>
252
+
253
+ <div class="admin-card">
254
+ <form id="form-version" class="admin-card-body" novalidate>
255
+ <div class="grid-2">
256
+ <label class="field">
257
+ <span class="field-label">项目阶段</span>
258
+ <select name="stage">
259
+ <option value="alpha">Alpha(内测)</option>
260
+ <option value="beta" selected>Beta(公测)</option>
261
+ <option value="rc">RC(候选发布)</option>
262
+ <option value="stable">Stable(稳定版)</option>
263
+ </select>
264
+ </label>
265
+ <div class="field">
266
+ <span class="field-label">版本号</span>
267
+ <div class="ver-inputs">
268
+ <input type="number" name="major" min="0" max="99" value="2" aria-label="主版本号">
269
+ <span class="ver-dot">.</span>
270
+ <input type="number" name="minor" min="0" max="99" value="0" aria-label="次版本号">
271
+ <span class="ver-dot">.</span>
272
+ <input type="number" name="patch" min="0" max="99" value="0" aria-label="修订号">
273
+ </div>
274
+ </div>
275
+ </div>
276
+ <div class="ver-preview" id="ver-preview">当前版本:—</div>
277
+ <div class="form-error" id="form-version-error" hidden></div>
278
+ <div>
279
+ <button type="submit" id="btn-version-save" class="btn btn-primary">
280
+ <span class="btn-label">应用版本</span>
281
+ <span class="btn-spinner" aria-hidden="true"></span>
282
+ </button>
283
+ </div>
284
+ </form>
285
+ </div>
286
+ </div>
287
+
288
+ <!-- ============ 页签 5:公告与广告 ============ -->
289
+ <div class="tab-panel" id="panel-broadcast" role="tabpanel" hidden>
290
+ <section class="page-head">
291
+ <div class="page-head-text">
292
+ <h1>公告与广告</h1>
293
+ <p class="page-sub">发布全站系统公告,管理顶部广告横幅展示。</p>
294
+ </div>
295
+ </section>
296
+
297
+ <!-- 📢 系统公告 -->
298
+ <div class="admin-card">
299
+ <div class="admin-card-head">
300
+ <h3>📢 全站系统公告</h3>
301
+ <span class="badge badge-warning">管理员特权</span>
302
+ </div>
303
+ <div class="admin-card-body">
304
+ <label class="field">
305
+ <span class="field-label">公告内容</span>
306
+ <textarea id="ann-content" rows="4" placeholder="输入要向全体用户推送的公告内容…"></textarea>
307
+ </label>
308
+ <div>
309
+ <button type="button" id="btn-ann-publish" class="btn btn-primary">
310
+ <span class="btn-label">🚀 确认发布</span>
311
+ <span class="btn-spinner" aria-hidden="true"></span>
312
+ </button>
313
+ </div>
314
+ </div>
315
+ </div>
316
+
317
+ <!-- 📰 广告横幅管理 -->
318
+ <div class="admin-card">
319
+ <div class="admin-card-head">
320
+ <h3>📰 广告横幅管理</h3>
321
+ <span class="badge" id="banner-status-badge">检查中…</span>
322
+ </div>
323
+ <div class="admin-card-body">
324
+ <label class="switch-row">
325
+ <input type="checkbox" id="banner-enabled">
326
+ <span>启用广告展示</span>
327
+ </label>
328
+
329
+ <div class="grid-2">
330
+ <div class="field">
331
+ <span class="field-label">横幅图片(支持 GIF)</span>
332
+ <img id="banner-img-preview" class="upload-preview" hidden alt="横幅预览">
333
+ <button type="button" id="btn-banner-img" class="btn btn-secondary btn-upload">
334
+ <span class="btn-label">🖼️ 选择横幅图片</span>
335
+ </button>
336
+ <input type="file" id="banner-img-file" accept="image/*" hidden>
337
+ </div>
338
+ <div class="field">
339
+ <span class="field-label">详情页大图</span>
340
+ <img id="banner-detail-img-preview" class="upload-preview" hidden alt="详情图预览">
341
+ <button type="button" id="btn-detail-img" class="btn btn-secondary btn-upload">
342
+ <span class="btn-label">🖼️ 选择详情大图</span>
343
+ </button>
344
+ <input type="file" id="banner-detail-img-file" accept="image/*" hidden>
345
+ </div>
346
+ </div>
347
+
348
+ <div class="grid-2">
349
+ <label class="field">
350
+ <span class="field-label">广告标题</span>
351
+ <input type="text" id="banner-title" placeholder="广告主标题(可选)">
352
+ </label>
353
+ <label class="field">
354
+ <span class="field-label">广告描述</span>
355
+ <input type="text" id="banner-desc" placeholder="一句话描述(可选)">
356
+ </label>
357
+ </div>
358
+
359
+ <label class="field">
360
+ <span class="field-label">详情内容</span>
361
+ <textarea id="banner-detail-content" rows="6" placeholder="点击广告后展示的详细内容(可选)"></textarea>
362
+ </label>
363
+
364
+ <div>
365
+ <button type="button" id="btn-banner-save" class="btn btn-primary">
366
+ <span class="btn-label">💾 保存广告配置</span>
367
+ <span class="btn-spinner" aria-hidden="true"></span>
368
+ </button>
369
+ </div>
370
+ </div>
371
+ </div>
372
+ </div>
373
+
374
+ <!-- ============ 页签 6:脚本执行 ============ -->
375
+ <div class="tab-panel" id="panel-scripts" role="tabpanel" hidden>
376
+ <section class="page-head">
377
+ <div class="page-head-text">
378
+ <h1>脚本执行</h1>
379
+ <p class="page-sub">执行服务端预置的运维脚本(仅限管理员)。</p>
380
+ </div>
381
+ </section>
382
+
383
+ <div class="admin-card">
384
+ <div class="admin-card-head">
385
+ <h3>🖥️ 运维脚本</h3>
386
+ <span class="badge badge-primary">开发工具</span>
387
+ </div>
388
+ <div class="admin-card-body">
389
+ <div class="script-row">
390
+ <label class="field script-field">
391
+ <span class="field-label">脚本名称</span>
392
+ <input type="text" id="script-name" placeholder="输入服务端预置的脚本名称…">
393
+ </label>
394
+ <button type="button" id="btn-script-run" class="btn btn-primary btn-script-run">
395
+ <span class="btn-label">▶️ 执行</span>
396
+ <span class="btn-spinner" aria-hidden="true"></span>
397
+ </button>
398
+ </div>
399
+ <pre id="script-result" class="script-result">等待执行…</pre>
400
+ </div>
401
+ </div>
402
+ </div>
403
  </main>
404
  </section>
405
 
 
570
  </div>
571
  </div>
572
 
573
+ <!-- ============ 申诉详情/裁决 模态框 ============ -->
574
+ <div id="modal-dispute" class="modal-mask" hidden>
575
+ <div class="modal modal-lg" role="dialog" aria-modal="true" aria-labelledby="modal-dispute-title">
576
+ <header class="modal-header">
577
+ <h3 id="modal-dispute-title">申诉详情</h3>
578
+ <button type="button" class="modal-close" data-close="modal-dispute" aria-label="关闭">×</button>
579
+ </header>
580
+ <div class="modal-body" id="dispute-detail-body">
581
+ <div class="state-block">
582
+ <div class="spinner-lg" aria-hidden="true"></div>
583
+ <p>正在加载申诉详情…</p>
584
+ </div>
585
+ </div>
586
+ <footer class="modal-footer">
587
+ <button type="button" class="btn btn-ghost" data-close="modal-dispute">关闭</button>
588
+ </footer>
589
+ </div>
590
+ </div>
591
+
592
  <!-- ============ 通用确认对话框 ============ -->
593
  <div id="modal-confirm" class="modal-mask" hidden>
594
  <div class="modal modal-sm" role="alertdialog" aria-modal="true" aria-labelledby="modal-confirm-title">
管理界面/style.css CHANGED
@@ -981,3 +981,334 @@ code, pre { font-family: var(--font-mono); }
981
  transition-duration: 0.01ms !important;
982
  }
983
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
981
  transition-duration: 0.01ms !important;
982
  }
983
  }
984
+
985
+ /* ===================================================================
986
+ * 🆕 管理功能迁移:页签导航 + 提现/仲裁/版本/公告广告/脚本模块
987
+ * (仅追加,复用上方设计令牌;既有规则零改动)
988
+ * =================================================================== */
989
+
990
+ /* ---------- 页签导航 ---------- */
991
+ .tab-bar {
992
+ display: flex;
993
+ flex-wrap: wrap;
994
+ gap: 4px;
995
+ padding: 6px;
996
+ background: var(--bg-elev);
997
+ border: 1px solid var(--border);
998
+ border-radius: var(--radius-lg);
999
+ box-shadow: var(--shadow-sm);
1000
+ }
1001
+ .tab-item {
1002
+ flex: 1 1 auto;
1003
+ padding: 9px 14px;
1004
+ min-height: 38px;
1005
+ font-size: 13.5px;
1006
+ font-weight: 500;
1007
+ color: var(--text-mute);
1008
+ background: transparent;
1009
+ border: none;
1010
+ border-radius: var(--radius);
1011
+ cursor: pointer;
1012
+ white-space: nowrap;
1013
+ transition: background 160ms var(--ease), color 160ms var(--ease);
1014
+ }
1015
+ .tab-item:hover { background: var(--bg-tint); color: var(--text); }
1016
+ .tab-item.is-active {
1017
+ background: var(--primary-soft);
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
+
1025
+ /* ---------- 状态筛选小页签 ---------- */
1026
+ .filter-tabs { display: flex; flex-wrap: wrap; gap: 8px; }
1027
+ .filter-tab {
1028
+ padding: 7px 14px;
1029
+ font-size: 13px;
1030
+ color: var(--text-soft);
1031
+ background: var(--bg-elev);
1032
+ border: 1px solid var(--border);
1033
+ border-radius: 999px;
1034
+ cursor: pointer;
1035
+ transition: all 160ms var(--ease);
1036
+ }
1037
+ .filter-tab:hover { border-color: var(--border-strong); color: var(--text); }
1038
+ .filter-tab.is-active {
1039
+ background: var(--primary);
1040
+ border-color: var(--primary);
1041
+ color: #fff;
1042
+ font-weight: 500;
1043
+ }
1044
+
1045
+ /* ---------- 管理卡片 ---------- */
1046
+ .admin-card {
1047
+ background: var(--bg-elev);
1048
+ border: 1px solid var(--border);
1049
+ border-radius: var(--radius-lg);
1050
+ box-shadow: var(--shadow-sm);
1051
+ overflow: hidden;
1052
+ }
1053
+ .admin-card-head {
1054
+ display: flex;
1055
+ align-items: center;
1056
+ justify-content: space-between;
1057
+ gap: 12px;
1058
+ padding: 16px 20px;
1059
+ border-bottom: 1px solid var(--border);
1060
+ background: var(--bg-elev-2);
1061
+ }
1062
+ .admin-card-head h3 { font-size: 15px; }
1063
+ .admin-card-body {
1064
+ padding: 20px;
1065
+ display: flex;
1066
+ flex-direction: column;
1067
+ gap: 16px;
1068
+ }
1069
+
1070
+ .admin-list { display: flex; flex-direction: column; gap: 12px; }
1071
+
1072
+ .admin-row-card { padding: 16px 18px; display: flex; flex-direction: column; gap: 12px; animation: fade-in 240ms var(--ease) both; }
1073
+ .admin-row-card.is-clickable { cursor: pointer; }
1074
+ .admin-row-card.is-clickable:hover {
1075
+ transform: translateY(-1px);
1076
+ box-shadow: var(--shadow-md);
1077
+ border-color: var(--border-strong);
1078
+ }
1079
+
1080
+ .row-card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
1081
+ .row-card-title { display: flex; align-items: center; gap: 10px; min-width: 0; flex-wrap: wrap; }
1082
+ .row-card-name { font-size: 14.5px; font-weight: 600; color: var(--text); word-break: break-all; }
1083
+ .row-card-amount { font-size: 16px; font-weight: 700; color: var(--danger); font-variant-numeric: tabular-nums; }
1084
+ .row-card-amount em { font-style: normal; font-size: 12px; color: var(--text-mute); font-weight: 400; margin-left: 4px; }
1085
+ .row-card-time { font-size: 12.5px; color: var(--text-dim); }
1086
+
1087
+ .row-card-meta {
1088
+ display: grid;
1089
+ grid-template-columns: 1fr 1fr;
1090
+ gap: 8px 14px;
1091
+ margin: 0;
1092
+ padding: 12px;
1093
+ background: var(--bg-tint);
1094
+ border-radius: var(--radius);
1095
+ }
1096
+ .row-card-meta dt { font-size: 11px; color: var(--text-dim); letter-spacing: 0.04em; margin-bottom: 2px; }
1097
+ .row-card-meta dd { margin: 0; font-size: 13px; color: var(--text); word-break: break-all; }
1098
+
1099
+ .row-card-action { display: flex; gap: 10px; align-items: center; }
1100
+ .row-input {
1101
+ flex: 1;
1102
+ padding: 9px 12px;
1103
+ font: inherit;
1104
+ font-size: 13px;
1105
+ color: var(--text);
1106
+ background: var(--bg-elev-2);
1107
+ border: 1px solid var(--border);
1108
+ border-radius: var(--radius);
1109
+ }
1110
+ .row-input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 4px var(--primary-soft); }
1111
+
1112
+ .row-card-done {
1113
+ padding: 10px 12px;
1114
+ background: var(--success-soft);
1115
+ border: 1px solid color-mix(in srgb, var(--success) 30%, transparent);
1116
+ border-radius: var(--radius);
1117
+ font-size: 13px;
1118
+ color: var(--success);
1119
+ word-break: break-all;
1120
+ }
1121
+
1122
+ .row-card-parties { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-soft); flex-wrap: wrap; }
1123
+ .row-card-parties .vs { font-style: normal; font-weight: 700; color: var(--danger); }
1124
+ .row-card-parties .initiator { margin-left: auto; font-size: 12px; color: var(--text-dim); }
1125
+ .row-card-reason {
1126
+ font-size: 13px;
1127
+ color: var(--text-soft);
1128
+ background: var(--bg-tint);
1129
+ padding: 10px 12px;
1130
+ border-radius: var(--radius);
1131
+ line-height: 1.55;
1132
+ overflow: hidden;
1133
+ display: -webkit-box;
1134
+ -webkit-line-clamp: 2;
1135
+ -webkit-box-orient: vertical;
1136
+ }
1137
+
1138
+ /* 状态徽章扩展色 */
1139
+ .badge.is-warn { background: var(--warning-soft); color: var(--warning); }
1140
+ .badge.is-info { background: var(--primary-soft); color: var(--primary); }
1141
+ .badge-warning { background: var(--warning-soft); color: var(--warning); }
1142
+ .badge-primary { background: var(--primary-soft); color: var(--primary); }
1143
+
1144
+ /* ---------- 版本管理 ---------- */
1145
+ .field select {
1146
+ width: 100%;
1147
+ padding: 10px 12px;
1148
+ font: inherit;
1149
+ font-size: 13.5px;
1150
+ color: var(--text);
1151
+ background: var(--bg-elev-2);
1152
+ border: 1px solid var(--border);
1153
+ border-radius: var(--radius);
1154
+ cursor: pointer;
1155
+ transition: border-color 160ms var(--ease), box-shadow 160ms var(--ease);
1156
+ }
1157
+ .field select:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 4px var(--primary-soft); }
1158
+
1159
+ .ver-inputs { display: flex; align-items: center; gap: 8px; }
1160
+ .ver-inputs input {
1161
+ width: 72px;
1162
+ padding: 10px 8px;
1163
+ font: inherit;
1164
+ font-size: 13.5px;
1165
+ text-align: center;
1166
+ color: var(--text);
1167
+ background: var(--bg-elev-2);
1168
+ border: 1px solid var(--border);
1169
+ border-radius: var(--radius);
1170
+ }
1171
+ .ver-inputs input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 4px var(--primary-soft); }
1172
+ .ver-dot { color: var(--text-dim); font-weight: 700; }
1173
+
1174
+ .ver-preview {
1175
+ padding: 12px 14px;
1176
+ background: var(--primary-soft);
1177
+ border: 1px dashed color-mix(in srgb, var(--primary) 35%, transparent);
1178
+ border-radius: var(--radius);
1179
+ font-size: 13px;
1180
+ font-weight: 500;
1181
+ color: var(--primary);
1182
+ font-variant-numeric: tabular-nums;
1183
+ }
1184
+
1185
+ /* ---------- 公告与广告 ---------- */
1186
+ .switch-row {
1187
+ display: flex;
1188
+ align-items: center;
1189
+ gap: 10px;
1190
+ padding: 10px 12px;
1191
+ background: var(--bg-tint);
1192
+ border-radius: var(--radius);
1193
+ font-size: 13.5px;
1194
+ cursor: pointer;
1195
+ }
1196
+ .switch-row input { width: 18px; height: 18px; accent-color: var(--success); cursor: pointer; }
1197
+
1198
+ .upload-preview {
1199
+ max-width: 100%;
1200
+ max-height: 90px;
1201
+ object-fit: contain;
1202
+ border-radius: var(--radius);
1203
+ border: 1px solid var(--border);
1204
+ background: var(--bg-tint);
1205
+ }
1206
+ .btn-upload { align-self: flex-start; border-style: dashed; }
1207
+
1208
+ /* ---------- 脚本执行 ---------- */
1209
+ .script-row { display: flex; gap: 10px; align-items: flex-end; }
1210
+ .script-field { flex: 1; }
1211
+ .btn-script-run { flex: none; min-height: 41px; }
1212
+ .script-result {
1213
+ margin: 0;
1214
+ padding: 14px;
1215
+ min-height: 90px;
1216
+ max-height: 360px;
1217
+ overflow: auto;
1218
+ background: var(--bg-tint);
1219
+ border: 1px solid var(--border);
1220
+ border-radius: var(--radius);
1221
+ font-size: 12.5px;
1222
+ line-height: 1.6;
1223
+ color: var(--text-soft);
1224
+ white-space: pre-wrap;
1225
+ word-break: break-all;
1226
+ }
1227
+ .script-result.is-error { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 35%, transparent); background: var(--danger-soft); }
1228
+
1229
+ /* ---------- 申诉详情弹窗 ---------- */
1230
+ .dispute-section { display: flex; flex-direction: column; gap: 6px; }
1231
+ .dispute-section-title { font-size: 13.5px; font-weight: 600; color: var(--text); }
1232
+ .dispute-section-body {
1233
+ padding: 12px 14px;
1234
+ background: var(--bg-tint);
1235
+ border-radius: var(--radius);
1236
+ font-size: 13.5px;
1237
+ color: var(--text-soft);
1238
+ line-height: 1.6;
1239
+ word-break: break-word;
1240
+ }
1241
+
1242
+ .evidence-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
1243
+ .evidence-row img {
1244
+ width: 64px; height: 64px;
1245
+ object-fit: cover;
1246
+ border-radius: var(--radius-sm);
1247
+ border: 1px solid var(--border);
1248
+ cursor: zoom-in;
1249
+ }
1250
+
1251
+ .resolve-box {
1252
+ display: flex;
1253
+ flex-direction: column;
1254
+ gap: 12px;
1255
+ padding: 16px;
1256
+ background: linear-gradient(135deg, var(--warning-soft), var(--primary-soft));
1257
+ border: 1px solid var(--border);
1258
+ border-radius: var(--radius-lg);
1259
+ }
1260
+ .resolve-box-title { font-weight: 600; color: var(--text); }
1261
+ .resolve-options { display: flex; flex-direction: column; gap: 8px; }
1262
+ .resolve-option {
1263
+ display: flex;
1264
+ align-items: center;
1265
+ gap: 10px;
1266
+ padding: 11px 12px;
1267
+ background: var(--bg-elev);
1268
+ border: 1px solid var(--border);
1269
+ border-radius: var(--radius);
1270
+ cursor: pointer;
1271
+ transition: border-color 160ms var(--ease), background 160ms var(--ease);
1272
+ }
1273
+ .resolve-option:hover { border-color: var(--primary); }
1274
+ .resolve-option.selected {
1275
+ border-color: var(--primary);
1276
+ background: var(--primary-soft);
1277
+ box-shadow: 0 0 0 1px var(--primary);
1278
+ }
1279
+ .resolve-option input { display: none; }
1280
+ .resolve-option-icon { font-size: 18px; }
1281
+ .resolve-option-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
1282
+ .resolve-option-text strong { font-size: 13.5px; color: var(--text); }
1283
+ .resolve-option-text em { font-style: normal; font-size: 12px; color: var(--text-mute); }
1284
+
1285
+ .split-row {
1286
+ display: flex;
1287
+ align-items: center;
1288
+ gap: 12px;
1289
+ padding: 10px 12px;
1290
+ background: var(--bg-elev);
1291
+ border-radius: var(--radius);
1292
+ font-size: 13px;
1293
+ }
1294
+ .split-row input[type="range"] { flex: 1; accent-color: var(--primary); }
1295
+ .split-row strong { min-width: 88px; text-align: center; color: var(--primary); font-variant-numeric: tabular-nums; }
1296
+
1297
+ .resolve-result {
1298
+ padding: 14px 16px;
1299
+ background: var(--success-soft);
1300
+ border: 1px solid color-mix(in srgb, var(--success) 30%, transparent);
1301
+ border-radius: var(--radius-lg);
1302
+ }
1303
+ .resolve-result-title { font-weight: 600; color: var(--success); margin-bottom: 4px; }
1304
+ .resolve-result p { font-size: 13px; color: var(--text-soft); }
1305
+
1306
+ /* ---------- 新模块响应式 ---------- */
1307
+ @media (max-width: 880px) {
1308
+ .tab-bar { gap: 2px; padding: 4px; }
1309
+ .tab-item { flex: 1 1 45%; font-size: 12.5px; padding: 8px 10px; }
1310
+ .row-card-meta { grid-template-columns: 1fr; }
1311
+ .row-card-action { flex-direction: column; align-items: stretch; }
1312
+ .script-row { flex-direction: column; align-items: stretch; }
1313
+ .row-card-parties .initiator { margin-left: 0; }
1314
+ }