ZHIWEI666 commited on
Commit
9fd590c
·
verified ·
1 Parent(s): b518712

Upload 5 files

Browse files
管理界面/app.js CHANGED
@@ -9,6 +9,45 @@
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: '脚本执行',
@@ -43,6 +82,45 @@
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',
@@ -232,7 +310,7 @@
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);
@@ -1220,7 +1298,8 @@
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 (_) {
@@ -1414,15 +1493,15 @@
1414
 
1415
  // -------------------- ⚖️ 申诉仲裁 --------------------
1416
  const DISPUTE_STATUS = {
1417
- pending: { label: '待回应', cls: 'is-warn' },
1418
- responded: { label: '待裁决', cls: 'is-info' },
1419
- resolved: { label: '已裁决', cls: 'is-on' }
1420
  };
1421
 
1422
  const RESOLUTION_OPTIONS = [
1423
- { value: 'favor_initiator', icon: '✅', label: '支持申诉方' },
1424
- { value: 'favor_respondent', icon: '❌', label: '支持被申诉方' },
1425
- { value: 'split', icon: '⚖️', label: '按比例分成', desc: '双方按比例分配任务金额' }
1426
  ];
1427
 
1428
  function setDisputeState(stateName, message) {
@@ -1430,6 +1509,7 @@
1430
  $('#dispute-empty').hidden = stateName !== 'empty';
1431
  $('#dispute-error').hidden = stateName !== 'error';
1432
  $('#dispute-list').hidden = stateName !== 'data';
 
1433
  if (stateName === 'error' && message) $('#dispute-error-msg').textContent = message;
1434
  }
1435
 
@@ -1446,7 +1526,7 @@
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
  }
@@ -1464,13 +1544,14 @@
1464
 
1465
  function buildDisputeCard(d) {
1466
  const st = DISPUTE_STATUS[d.status] || DISPUTE_STATUS.pending;
 
1467
  const card = document.createElement('article');
1468
  card.className = 'admin-card admin-row-card is-clickable';
1469
  card.innerHTML = `
1470
  <div class="row-card-head">
1471
  <div class="row-card-title">
1472
- <span class="row-card-name">${escapeHtml(d.task_title || '未知任务')}</span>
1473
- <span class="badge ${st.cls}">${st.label}</span>
1474
  </div>
1475
  <div class="row-card-time">${escapeHtml(formatDate(d.created_at))}</div>
1476
  </div>
@@ -1478,11 +1559,18 @@
1478
  <span>${escapeHtml(d.publisher_name || d.publisher || '?')}</span>
1479
  <em class="vs">VS</em>
1480
  <span>${escapeHtml(d.assignee_name || d.assignee || '?')}</span>
1481
- <span class="initiator">发起方:${d.initiator_role === 'publisher' ? '发布者' : '接单方'}</span>
1482
  </div>
1483
- <p class="row-card-reason">${escapeHtml(d.reason || '(未填写申诉理由)')}</p>
1484
  `;
1485
- card.addEventListener('click', () => openDisputeModal(d.id));
 
 
 
 
 
 
 
1486
  return card;
1487
  }
1488
 
@@ -1490,7 +1578,7 @@
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;
@@ -1498,8 +1586,8 @@
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
  }
@@ -1508,7 +1596,7 @@
1508
  function renderEvidence(images) {
1509
  if (!Array.isArray(images) || !images.length) return '';
1510
  return '<div class="evidence-row">' + images.map((src) =>
1511
- `<img src="${escapeHtml(src)}" alt="证据图片" loading="lazy" class="js-evidence-img">`
1512
  ).join('') + '</div>';
1513
  }
1514
 
@@ -1526,20 +1614,20 @@
1526
 
1527
  function renderDisputeDetail(body, d) {
1528
  const canResolve = d.status !== 'resolved';
1529
- const initiatorLabel = d.initiator_role === 'publisher' ? '发布者' : '接单方';
1530
- const respondentLabel = d.initiator_role === 'publisher' ? '接单方' : '发布者';
1531
  body.innerHTML = `
1532
  <div class="dispute-section">
1533
- <div class="dispute-section-title">📋 关联任务</div>
1534
- <div class="dispute-section-body">${escapeHtml(d.task_title || '未知任务')}</div>
1535
  </div>
1536
  <div class="dispute-section">
1537
- <div class="dispute-section-title">📝 申诉方陈述(${escapeHtml(initiatorLabel)})</div>
1538
- <div class="dispute-section-body">${escapeHtml(d.reason || '(无内容)')}${renderEvidence(d.evidence)}</div>
1539
  </div>
1540
  <div class="dispute-section">
1541
- <div class="dispute-section-title">💬 被申诉方回应(${escapeHtml(respondentLabel)})</div>
1542
- <div class="dispute-section-body">${escapeHtml(d.response || '(尚未回应)')}${renderEvidence(d.response_evidence)}</div>
1543
  </div>
1544
  ${canResolve ? renderResolveForm() : renderResolutionResult(d)}
1545
  `;
@@ -1550,29 +1638,29 @@
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>` : ''}
1562
  </span>
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>
1570
  <label class="field">
1571
- <span class="field-label">裁决说明(可选)</span>
1572
- <textarea id="resolve-note" rows="3" placeholder="说明裁决依据…"></textarea>
1573
  </label>
1574
  <button type="button" id="btn-resolve" class="btn btn-primary btn-block" disabled>
1575
- <span class="btn-label">确认裁决</span>
1576
  <span class="btn-spinner" aria-hidden="true"></span>
1577
  </button>
1578
  </div>`;
@@ -1580,13 +1668,13 @@
1580
 
1581
  function renderResolutionResult(d) {
1582
  const ratio = d.resolution_ratio ?? 50;
1583
- let text = '已裁决';
1584
- if (d.resolution === 'favor_initiator') text = '支持申诉方';
1585
- else if (d.resolution === 'favor_respondent') text = '支持被申诉方';
1586
- else if (d.resolution === 'split') text = `按比例分成(${ratio}% : ${100 - ratio}%)`;
1587
  return `
1588
  <div class="resolve-result">
1589
- <div class="resolve-result-title">✅ 裁决结果:${escapeHtml(text)}</div>
1590
  ${d.resolution_note ? `<p>${escapeHtml(d.resolution_note)}</p>` : ''}
1591
  </div>`;
1592
  }
@@ -1633,12 +1721,12 @@
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
  }
@@ -1646,11 +1734,24 @@
1646
  }
1647
 
1648
  function bindDisputes() {
 
 
 
 
 
 
 
 
 
 
1649
  $('#btn-dispute-refresh').addEventListener('click', () => refreshDisputes());
1650
  $$('#dispute-status-tabs .filter-tab').forEach((tab) => {
1651
  tab.addEventListener('click', () => {
1652
  state.disputeStatus = tab.dataset.status || '';
1653
- $$('#dispute-status-tabs .filter-tab').forEach((t) => t.classList.toggle('is-active', t === tab));
 
 
 
1654
  refreshDisputes();
1655
  });
1656
  });
 
9
  // 新增反馈集中在双语词典;管理页语言遵循文档lang。
10
  const ADMIN_MESSAGES = {
11
  zh: {
12
+ dispute_subtitle: '查看任务纠纷申诉,审核双方证据并做出裁决。',
13
+ dispute_list_loading: '正在加载申诉列表…',
14
+ dispute_empty_title: '暂无申诉',
15
+ dispute_empty_description: '当前筛选条件下没有申诉记录。',
16
+ dispute_list_failed: '加载申诉列表失败',
17
+ dispute_status_filter: '申诉状态筛选',
18
+ dispute_status_all: '全部',
19
+ dispute_status_pending: '待回应',
20
+ dispute_status_responded: '待裁决',
21
+ dispute_status_resolved: '已裁决',
22
+ dispute_refresh: '刷新',
23
+ dispute_initiated_by: '发起方',
24
+ dispute_no_reason: '(未填写申诉理由)',
25
+ dispute_open: '查看申诉详情',
26
+ dispute_details: '申诉详情', close_dialog: '关闭',
27
+ dispute_publisher: '发布者',
28
+ dispute_assignee: '接单方',
29
+ dispute_task: '关联任务',
30
+ dispute_unknown_task: '未知任务',
31
+ dispute_initiator: '申诉方陈述',
32
+ dispute_respondent: '被申诉方回应',
33
+ dispute_no_content: '(无内容)',
34
+ dispute_no_response: '(尚未回应)',
35
+ dispute_evidence: '证据图片',
36
+ resolve_title: '裁决操作',
37
+ resolve_favor_initiator: '支持申诉方',
38
+ resolve_favor_respondent: '支持被申诉方',
39
+ resolve_split: '按比例分成',
40
+ resolve_split_description: '双方按比例分配任务金额',
41
+ resolve_ratio: '申诉方分成',
42
+ resolve_note: '裁决说明(可选)',
43
+ resolve_note_placeholder: '说明裁决依据…',
44
+ resolve_confirm: '确认裁决',
45
+ resolve_resolved: '已裁决',
46
+ resolve_result: '裁决结果',
47
+ resolve_success: '裁决已生效',
48
+ resolve_failed: '裁决失败',
49
+ dispute_loading: '正在加载申诉详情…',
50
+ dispute_load_failed: '加载失败',
51
  tab_navigation: '功能导航', tab_plugins: '第三方插件', tab_withdrawals: '提现管理',
52
  tab_disputes: '申诉仲裁', tab_config: '版本管理', tab_broadcast: '公告与广告', tab_promptcats: '提示词分类',
53
  script_tab: '🖥️ 脚本执行', script_title: '脚本执行',
 
82
  banner_active: '广告展示中', banner_inactive: '无活跃广告', banner_unknown: '状态未知',
83
  },
84
  en: {
85
+ dispute_subtitle: 'Review task disputes, consider both parties’ evidence and issue a resolution.',
86
+ dispute_list_loading: 'Loading disputes…',
87
+ dispute_empty_title: 'No disputes',
88
+ dispute_empty_description: 'No disputes match the current filter.',
89
+ dispute_list_failed: 'Could not load disputes',
90
+ dispute_status_filter: 'Filter disputes by status',
91
+ dispute_status_all: 'All',
92
+ dispute_status_pending: 'Awaiting response',
93
+ dispute_status_responded: 'Awaiting resolution',
94
+ dispute_status_resolved: 'Resolved',
95
+ dispute_refresh: 'Refresh',
96
+ dispute_initiated_by: 'Initiated by',
97
+ dispute_no_reason: '(No dispute reason provided)',
98
+ dispute_open: 'View dispute details',
99
+ dispute_details: 'Dispute details', close_dialog: 'Close',
100
+ dispute_publisher: 'Publisher',
101
+ dispute_assignee: 'Assignee',
102
+ dispute_task: 'Related task',
103
+ dispute_unknown_task: 'Unknown task',
104
+ dispute_initiator: 'Initiator statement',
105
+ dispute_respondent: 'Respondent statement',
106
+ dispute_no_content: '(No content)',
107
+ dispute_no_response: '(No response yet)',
108
+ dispute_evidence: 'Evidence image',
109
+ resolve_title: 'Resolution',
110
+ resolve_favor_initiator: 'Favor the initiator',
111
+ resolve_favor_respondent: 'Favor the respondent',
112
+ resolve_split: 'Split proportionally',
113
+ resolve_split_description: 'Distribute the task amount between both parties.',
114
+ resolve_ratio: 'Initiator share',
115
+ resolve_note: 'Resolution note (optional)',
116
+ resolve_note_placeholder: 'Explain the basis for this resolution…',
117
+ resolve_confirm: 'Confirm resolution',
118
+ resolve_resolved: 'Resolved',
119
+ resolve_result: 'Resolution result',
120
+ resolve_success: 'Resolution applied',
121
+ resolve_failed: 'Resolution failed',
122
+ dispute_loading: 'Loading dispute details…',
123
+ dispute_load_failed: 'Could not load dispute details',
124
  tab_navigation: 'Administration sections', tab_plugins: 'Third-party plugins', tab_withdrawals: 'Withdrawals',
125
  tab_disputes: 'Dispute arbitration', tab_config: 'Version management', tab_broadcast: 'Announcements and advertisements', tab_promptcats: 'Prompt categories',
126
  script_tab: '🖥️ Scripts', script_title: 'Script execution',
 
310
  const top = () => stack[stack.length - 1];
311
  const controls = (mask) => $$('button, input:not([type="hidden"]), select, textarea, a[href], [tabindex]', mask)
312
  .filter(el => !el.disabled && el.tabIndex >= 0 && el.getClientRects().length && !el.closest('[hidden], [inert]'));
313
+ const dialog = (entry) => $('[role="dialog"], [role="alertdialog"]', entry.mask) || entry.mask;
314
  const focus = (entry) => {
315
  if (!entry) return;
316
  const target = controls(entry.mask)[0] || dialog(entry);
 
1298
  textarea.value = text;
1299
  textarea.style.position = 'fixed';
1300
  textarea.style.opacity = '0';
1301
+ const host = adminModalFocusController?.topMask() || document.body;
1302
+ host.appendChild(textarea);
1303
  textarea.select();
1304
  return document.execCommand('copy');
1305
  } catch (_) {
 
1493
 
1494
  // -------------------- ⚖️ 申诉仲裁 --------------------
1495
  const DISPUTE_STATUS = {
1496
+ pending: { labelKey: 'dispute_status_pending', cls: 'is-warn' },
1497
+ responded: { labelKey: 'dispute_status_responded', cls: 'is-info' },
1498
+ resolved: { labelKey: 'dispute_status_resolved', cls: 'is-on' }
1499
  };
1500
 
1501
  const RESOLUTION_OPTIONS = [
1502
+ { value: 'favor_initiator', icon: '✅', labelKey: 'resolve_favor_initiator' },
1503
+ { value: 'favor_respondent', icon: '❌', labelKey: 'resolve_favor_respondent' },
1504
+ { value: 'split', icon: '⚖️', labelKey: 'resolve_split', descKey: 'resolve_split_description' }
1505
  ];
1506
 
1507
  function setDisputeState(stateName, message) {
 
1509
  $('#dispute-empty').hidden = stateName !== 'empty';
1510
  $('#dispute-error').hidden = stateName !== 'error';
1511
  $('#dispute-list').hidden = stateName !== 'data';
1512
+ $('#dispute-list').setAttribute('aria-busy', String(stateName === 'loading'));
1513
  if (stateName === 'error' && message) $('#dispute-error-msg').textContent = message;
1514
  }
1515
 
 
1526
  } catch (err) {
1527
  if (isStaleSessionError(err) || !read.isCurrent()) return;
1528
  if (err instanceof ApiError && err.status === 401) return;
1529
+ setDisputeState('error', err.message || adminMessage('dispute_list_failed'));
1530
  } finally {
1531
  read.release();
1532
  }
 
1544
 
1545
  function buildDisputeCard(d) {
1546
  const st = DISPUTE_STATUS[d.status] || DISPUTE_STATUS.pending;
1547
+ const session = captureAdminSession();
1548
  const card = document.createElement('article');
1549
  card.className = 'admin-card admin-row-card is-clickable';
1550
  card.innerHTML = `
1551
  <div class="row-card-head">
1552
  <div class="row-card-title">
1553
+ <button type="button" class="row-card-name row-card-open" aria-label="${escapeHtml(adminMessage('dispute_open'))}: ${escapeHtml(d.task_title || adminMessage('dispute_unknown_task'))}">${escapeHtml(d.task_title || adminMessage('dispute_unknown_task'))}</button>
1554
+ <span class="badge ${st.cls}">${adminMessage(st.labelKey)}</span>
1555
  </div>
1556
  <div class="row-card-time">${escapeHtml(formatDate(d.created_at))}</div>
1557
  </div>
 
1559
  <span>${escapeHtml(d.publisher_name || d.publisher || '?')}</span>
1560
  <em class="vs">VS</em>
1561
  <span>${escapeHtml(d.assignee_name || d.assignee || '?')}</span>
1562
+ <span class="initiator">${adminMessage('dispute_initiated_by')}: ${adminMessage(d.initiator_role === 'publisher' ? 'dispute_publisher' : 'dispute_assignee')}</span>
1563
  </div>
1564
+ <p class="row-card-reason">${escapeHtml(d.reason || adminMessage('dispute_no_reason'))}</p>
1565
  `;
1566
+ const open = () => {
1567
+ if (card.isConnected && session.isCurrent()) openDisputeModal(d.id);
1568
+ };
1569
+ card.querySelector('.row-card-open').addEventListener('click', (event) => {
1570
+ event.stopPropagation();
1571
+ open();
1572
+ });
1573
+ card.addEventListener('click', open);
1574
  return card;
1575
  }
1576
 
 
1578
  const read = startAdminRead('dispute-detail');
1579
  openModal('modal-dispute');
1580
  const body = $('#dispute-detail-body');
1581
+ body.innerHTML = '<div class="state-block"><div class="spinner-lg" aria-hidden="true"></div><p>' + escapeHtml(adminMessage('dispute_loading')) + '</p></div>';
1582
  try {
1583
  const res = await apiRequest('GET', '/api/disputes/' + encodeURIComponent(disputeId));
1584
  if (!read.isCurrent()) return;
 
1586
  renderDisputeDetail(body, dispute);
1587
  } catch (err) {
1588
  if (isStaleSessionError(err) || !read.isCurrent()) return;
1589
+ body.innerHTML = '<div class="state-block state-error"><div class="empty-mark">!</div><h3>' + escapeHtml(adminMessage('dispute_load_failed')) + '</h3><p>' +
1590
+ escapeHtml(err.message || adminMessage('request_failed')) + '</p></div>';
1591
  } finally {
1592
  read.release();
1593
  }
 
1596
  function renderEvidence(images) {
1597
  if (!Array.isArray(images) || !images.length) return '';
1598
  return '<div class="evidence-row">' + images.map((src) =>
1599
+ `<img src="${escapeHtml(src)}" alt="${escapeHtml(adminMessage('dispute_evidence'))}" loading="lazy" class="js-evidence-img">`
1600
  ).join('') + '</div>';
1601
  }
1602
 
 
1614
 
1615
  function renderDisputeDetail(body, d) {
1616
  const canResolve = d.status !== 'resolved';
1617
+ const initiatorLabel = adminMessage(d.initiator_role === 'publisher' ? 'dispute_publisher' : 'dispute_assignee');
1618
+ const respondentLabel = adminMessage(d.initiator_role === 'publisher' ? 'dispute_assignee' : 'dispute_publisher');
1619
  body.innerHTML = `
1620
  <div class="dispute-section">
1621
+ <div class="dispute-section-title">📋 ${adminMessage('dispute_task')}</div>
1622
+ <div class="dispute-section-body">${escapeHtml(d.task_title || adminMessage('dispute_unknown_task'))}</div>
1623
  </div>
1624
  <div class="dispute-section">
1625
+ <div class="dispute-section-title">📝 ${adminMessage('dispute_initiator')} (${escapeHtml(initiatorLabel)})</div>
1626
+ <div class="dispute-section-body">${escapeHtml(d.reason || adminMessage('dispute_no_content'))}${renderEvidence(d.evidence)}</div>
1627
  </div>
1628
  <div class="dispute-section">
1629
+ <div class="dispute-section-title">💬 ${adminMessage('dispute_respondent')} (${escapeHtml(respondentLabel)})</div>
1630
+ <div class="dispute-section-body">${escapeHtml(d.response || adminMessage('dispute_no_response'))}${renderEvidence(d.response_evidence)}</div>
1631
  </div>
1632
  ${canResolve ? renderResolveForm() : renderResolutionResult(d)}
1633
  `;
 
1638
  function renderResolveForm() {
1639
  return `
1640
  <div class="resolve-box">
1641
+ <div class="resolve-box-title" id="resolve-title">🔨 ${adminMessage('resolve_title')}</div>
1642
  <div class="resolve-options" role="radiogroup" aria-labelledby="resolve-title">
1643
  ${RESOLUTION_OPTIONS.map((opt) => `
1644
  <label class="resolve-option" data-value="${opt.value}">
1645
  <input type="radio" name="resolution" value="${opt.value}">
1646
  <span class="resolve-option-icon" aria-hidden="true">${opt.icon}</span>
1647
  <span class="resolve-option-text">
1648
+ <strong>${adminMessage(opt.labelKey)}</strong>
1649
+ ${opt.descKey ? `<em>${adminMessage(opt.descKey)}</em>` : ''}
1650
  </span>
1651
  </label>`).join('')}
1652
  </div>
1653
  <div class="split-row" id="split-row" hidden>
1654
+ <label for="resolve-ratio">${adminMessage('resolve_ratio')}</label>
1655
  <input type="range" id="resolve-ratio" min="0" max="100" value="50">
1656
  <strong id="resolve-ratio-text">50% : 50%</strong>
1657
  </div>
1658
  <label class="field">
1659
+ <span class="field-label">${adminMessage('resolve_note')}</span>
1660
+ <textarea id="resolve-note" rows="3" placeholder="${escapeHtml(adminMessage('resolve_note_placeholder'))}"></textarea>
1661
  </label>
1662
  <button type="button" id="btn-resolve" class="btn btn-primary btn-block" disabled>
1663
+ <span class="btn-label">${adminMessage('resolve_confirm')}</span>
1664
  <span class="btn-spinner" aria-hidden="true"></span>
1665
  </button>
1666
  </div>`;
 
1668
 
1669
  function renderResolutionResult(d) {
1670
  const ratio = d.resolution_ratio ?? 50;
1671
+ let text = adminMessage('resolve_resolved');
1672
+ if (d.resolution === 'favor_initiator') text = adminMessage('resolve_favor_initiator');
1673
+ else if (d.resolution === 'favor_respondent') text = adminMessage('resolve_favor_respondent');
1674
+ else if (d.resolution === 'split') text = `${adminMessage('resolve_split')} (${ratio}% : ${100 - ratio}%)`;
1675
  return `
1676
  <div class="resolve-result">
1677
+ <div class="resolve-result-title">✅ ${adminMessage('resolve_result')}: ${escapeHtml(text)}</div>
1678
  ${d.resolution_note ? `<p>${escapeHtml(d.resolution_note)}</p>` : ''}
1679
  </div>`;
1680
  }
 
1721
  finishBusy.assertCurrent();
1722
  confirmAdminStatusWrite(response);
1723
  modal.assertCurrent();
1724
+ toast(adminMessage('resolve_success'), 'success');
1725
  closeModal('modal-dispute');
1726
  refreshDisputes();
1727
  } catch (err) {
1728
  if (isStaleSessionError(err) || !finishBusy.isCurrent() || !modal.isCurrent()) return;
1729
+ toast(err.message || adminMessage('resolve_failed'), 'error');
1730
  } finally {
1731
  finishBusy();
1732
  }
 
1734
  }
1735
 
1736
  function bindDisputes() {
1737
+ $$('[data-admin-dispute-text]').forEach((element) => {
1738
+ element.textContent = adminMessage(element.dataset.adminDisputeText);
1739
+ });
1740
+ $('#dispute-status-tabs').setAttribute('aria-label', adminMessage('dispute_status_filter'));
1741
+ $('#btn-dispute-refresh').setAttribute('title', adminMessage('dispute_refresh'));
1742
+ $('#modal-dispute-title').textContent = adminMessage('dispute_details');
1743
+ $$('#modal-dispute [data-close="modal-dispute"]').forEach((button) => {
1744
+ button.setAttribute('aria-label', adminMessage('close_dialog'));
1745
+ if (!button.classList.contains('modal-close')) button.textContent = adminMessage('close_dialog');
1746
+ });
1747
  $('#btn-dispute-refresh').addEventListener('click', () => refreshDisputes());
1748
  $$('#dispute-status-tabs .filter-tab').forEach((tab) => {
1749
  tab.addEventListener('click', () => {
1750
  state.disputeStatus = tab.dataset.status || '';
1751
+ $$('#dispute-status-tabs .filter-tab').forEach((t) => {
1752
+ t.classList.toggle('is-active', t === tab);
1753
+ t.setAttribute('aria-pressed', String(t === tab));
1754
+ });
1755
  refreshDisputes();
1756
  });
1757
  });
管理界面/index.html CHANGED
@@ -206,39 +206,39 @@
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>
210
- <p class="page-sub">查看任务纠纷申诉,审核双方证据并做出裁决。</p>
211
  </div>
212
  <div class="page-head-actions">
213
  <button type="button" id="btn-dispute-refresh" class="btn btn-secondary" title="刷新">
214
- <span class="btn-label">刷新</span>
215
  </button>
216
  </div>
217
  </section>
218
 
219
- <div class="filter-tabs" id="dispute-status-tabs">
220
- <button type="button" class="filter-tab is-active" data-status="">全部</button>
221
- <button type="button" class="filter-tab" data-status="pending">待回应</button>
222
- <button type="button" class="filter-tab" data-status="responded">待裁决</button>
223
- <button type="button" class="filter-tab" data-status="resolved">已裁决</button>
224
  </div>
225
 
226
  <section class="list-shell">
227
- <div id="dispute-loading" class="state-block" hidden>
228
  <div class="spinner-lg" aria-hidden="true"></div>
229
- <p>正在加载申诉列表…</p>
230
  </div>
231
- <div id="dispute-empty" class="state-block" hidden>
232
  <div class="empty-mark">📭</div>
233
- <h3>暂无申诉</h3>
234
- <p>当前筛选条件下没有申诉记录。</p>
235
  </div>
236
- <div id="dispute-error" class="state-block state-error" hidden>
237
  <div class="empty-mark">!</div>
238
- <h3>加载失败</h3>
239
- <p id="dispute-error-msg">请稍后重试。</p>
240
  </div>
241
- <div id="dispute-list" class="admin-list"></div>
242
  </section>
243
  </div>
244
 
 
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 data-admin-dispute-text="tab_disputes">申诉仲裁</h1>
210
+ <p class="page-sub" data-admin-dispute-text="dispute_subtitle">查看任务纠纷申诉,审核双方证据并做出裁决。</p>
211
  </div>
212
  <div class="page-head-actions">
213
  <button type="button" id="btn-dispute-refresh" class="btn btn-secondary" title="刷新">
214
+ <span class="btn-label" data-admin-dispute-text="dispute_refresh">刷新</span>
215
  </button>
216
  </div>
217
  </section>
218
 
219
+ <div class="filter-tabs" id="dispute-status-tabs" role="group" aria-label="申诉状态筛选">
220
+ <button type="button" class="filter-tab is-active" data-status="" aria-pressed="true" data-admin-dispute-text="dispute_status_all">全部</button>
221
+ <button type="button" class="filter-tab" data-status="pending" aria-pressed="false" data-admin-dispute-text="dispute_status_pending">待回应</button>
222
+ <button type="button" class="filter-tab" data-status="responded" aria-pressed="false" data-admin-dispute-text="dispute_status_responded">待裁决</button>
223
+ <button type="button" class="filter-tab" data-status="resolved" aria-pressed="false" data-admin-dispute-text="dispute_status_resolved">已裁决</button>
224
  </div>
225
 
226
  <section class="list-shell">
227
+ <div id="dispute-loading" role="status" aria-atomic="true" class="state-block" hidden>
228
  <div class="spinner-lg" aria-hidden="true"></div>
229
+ <p data-admin-dispute-text="dispute_list_loading">正在加载申诉列表…</p>
230
  </div>
231
+ <div id="dispute-empty" role="status" aria-atomic="true" class="state-block" hidden>
232
  <div class="empty-mark">📭</div>
233
+ <h3 data-admin-dispute-text="dispute_empty_title">暂无申诉</h3>
234
+ <p data-admin-dispute-text="dispute_empty_description">当前筛选条件下没有申诉记录。</p>
235
  </div>
236
+ <div id="dispute-error" role="status" aria-atomic="true" class="state-block state-error" hidden>
237
  <div class="empty-mark">!</div>
238
+ <h3 data-admin-dispute-text="dispute_list_failed">加载失败</h3>
239
+ <p id="dispute-error-msg" data-admin-dispute-text="request_failed">请稍后重试。</p>
240
  </div>
241
+ <div id="dispute-list" class="admin-list" aria-busy="false"></div>
242
  </section>
243
  </div>
244
 
管理界面/style.css CHANGED
@@ -1082,6 +1082,8 @@ code, pre { font-family: var(--font-mono); }
1082
  .row-card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
1083
  .row-card-title { display: flex; align-items: center; gap: 10px; min-width: 0; flex-wrap: wrap; }
1084
  .row-card-name { font-size: 14.5px; font-weight: 600; color: var(--text); word-break: break-all; }
 
 
1085
  .row-card-amount { font-size: 16px; font-weight: 700; color: var(--danger); font-variant-numeric: tabular-nums; }
1086
  .row-card-amount em { font-style: normal; font-size: 12px; color: var(--text-mute); font-weight: 400; margin-left: 4px; }
1087
  .row-card-time { font-size: 12.5px; color: var(--text-dim); }
@@ -1344,7 +1346,7 @@ code, pre { font-family: var(--font-mono); }
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;
 
1082
  .row-card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
1083
  .row-card-title { display: flex; align-items: center; gap: 10px; min-width: 0; flex-wrap: wrap; }
1084
  .row-card-name { font-size: 14.5px; font-weight: 600; color: var(--text); word-break: break-all; }
1085
+ .row-card-open { background: transparent; border: 0; padding: 2px 0; text-align: start; font-family: inherit; cursor: pointer; max-width: 100%; }
1086
+ .row-card-open:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; border-radius: 3px; }
1087
  .row-card-amount { font-size: 16px; font-weight: 700; color: var(--danger); font-variant-numeric: tabular-nums; }
1088
  .row-card-amount em { font-style: normal; font-size: 12px; color: var(--text-mute); font-weight: 400; margin-left: 4px; }
1089
  .row-card-time { font-size: 12.5px; color: var(--text-dim); }
 
1346
  text-decoration-thickness: 2px;
1347
  text-underline-offset: 4px;
1348
  }
1349
+ .btn:focus-visible, .row-card-open:focus-visible, .tab-item:focus-visible, .tab-panel:focus-visible,
1350
  .script-result:focus-visible, .modal-close:focus-visible, .modal:focus-visible, .resolve-option:focus-within,
1351
  input:focus-visible, select:focus-visible, textarea:focus-visible {
1352
  outline: 2px solid Highlight;