Spaces:
Running
Running
Upload 5 files
Browse files- 管理界面/app.js +142 -41
- 管理界面/index.html +17 -17
- 管理界面/style.css +3 -1
管理界面/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 |
-
|
|
|
|
| 1224 |
textarea.select();
|
| 1225 |
return document.execCommand('copy');
|
| 1226 |
} catch (_) {
|
|
@@ -1414,15 +1493,15 @@
|
|
| 1414 |
|
| 1415 |
// -------------------- ⚖️ 申诉仲裁 --------------------
|
| 1416 |
const DISPUTE_STATUS = {
|
| 1417 |
-
pending: {
|
| 1418 |
-
responded: {
|
| 1419 |
-
resolved: {
|
| 1420 |
};
|
| 1421 |
|
| 1422 |
const RESOLUTION_OPTIONS = [
|
| 1423 |
-
{ value: 'favor_initiator', icon: '✅',
|
| 1424 |
-
{ value: 'favor_respondent', icon: '❌',
|
| 1425 |
-
{ value: 'split', icon: '⚖️',
|
| 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 |
-
<
|
| 1473 |
-
<span class="badge ${st.cls}">${st.
|
| 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">
|
| 1482 |
</div>
|
| 1483 |
-
<p class="row-card-reason">${escapeHtml(d.reason || '
|
| 1484 |
`;
|
| 1485 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|
| 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>
|
| 1502 |
-
escapeHtml(err.message || '
|
| 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="
|
| 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">📋
|
| 1534 |
-
<div class="dispute-section-body">${escapeHtml(d.task_title || '
|
| 1535 |
</div>
|
| 1536 |
<div class="dispute-section">
|
| 1537 |
-
<div class="dispute-section-title">📝
|
| 1538 |
-
<div class="dispute-section-body">${escapeHtml(d.reason || '
|
| 1539 |
</div>
|
| 1540 |
<div class="dispute-section">
|
| 1541 |
-
<div class="dispute-section-title">💬
|
| 1542 |
-
<div class="dispute-section-body">${escapeHtml(d.response || '
|
| 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">🔨
|
| 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.
|
| 1561 |
-
${opt.
|
| 1562 |
</span>
|
| 1563 |
</label>`).join('')}
|
| 1564 |
</div>
|
| 1565 |
<div class="split-row" id="split-row" hidden>
|
| 1566 |
-
<label for="resolve-ratio">
|
| 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">
|
| 1572 |
-
<textarea id="resolve-note" rows="3" placeholder="
|
| 1573 |
</label>
|
| 1574 |
<button type="button" id="btn-resolve" class="btn btn-primary btn-block" disabled>
|
| 1575 |
-
<span class="btn-label">
|
| 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 = `
|
| 1587 |
return `
|
| 1588 |
<div class="resolve-result">
|
| 1589 |
-
<div class="resolve-result-title">✅
|
| 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('
|
| 1637 |
closeModal('modal-dispute');
|
| 1638 |
refreshDisputes();
|
| 1639 |
} catch (err) {
|
| 1640 |
if (isStaleSessionError(err) || !finishBusy.isCurrent() || !modal.isCurrent()) return;
|
| 1641 |
-
toast(err.message || '
|
| 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) =>
|
|
|
|
|
|
|
|
|
|
| 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;
|