Store readable category snapshots separately from details and evidence, validate report categories, and preserve context in admin displays and moderation actions. Widen report categories to TEXT and bump the schema to 3.2.0 without legacy data conversion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
321 lines
16 KiB
JavaScript
321 lines
16 KiB
JavaScript
/**
|
|
* Frontend reporting for articles, comments and users.
|
|
* @package AKSRT
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
var config = window.aksrtData || {};
|
|
var API_ROOT = String(config.apiUrl || '/index.php?rest_route=/aksrt/v1').replace(/\/$/, '');
|
|
var NONCE = config.restNonce || '';
|
|
var MAX_IMAGES = 3;
|
|
var sequence = 0;
|
|
var TARGETS = {
|
|
user: { title: '举报用户', label: '用户' },
|
|
post: { title: '举报文章', label: '文章' },
|
|
comment: { title: '举报评论', label: '评论' },
|
|
};
|
|
|
|
function escapeHtml(value) {
|
|
return String(value == null ? '' : value)
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
|
|
function getReportCategories() {
|
|
return (Array.isArray(config.reportCategories) ? config.reportCategories : [])
|
|
.filter(function (cat) { return cat && cat.name && cat.name !== '其他原因'; });
|
|
}
|
|
|
|
function buildReportModalHtml(data, categories) {
|
|
var target = TARGETS[data.target_type];
|
|
var report = config.report || {};
|
|
var id = 'aksrt-report-' + (++sequence);
|
|
var name = data.target_name || target.label + ' #' + data.target_id;
|
|
var html = '<form id="' + id + '" class="aksrt-report-form" novalidate>';
|
|
html += '<div class="aksrt-report-body mini-scrollbar">';
|
|
html += '<div class="aksrt-report-target"><span class="aksrt-report-target__label">关联对象:</span>';
|
|
html += '<strong class="aksrt-report-target__name" title="' + escapeHtml(name) + '">' + escapeHtml(name) + '</strong></div>';
|
|
html += '<fieldset class="aksrt-report-reasons" aria-describedby="' + id + '-reason-error">';
|
|
html += '<legend>举报原因<span class="aksrt-report-hint">必选一项</span></legend>';
|
|
html += '<div class="aksrt-report-reasons__options">';
|
|
categories.forEach(function (cat, index) {
|
|
html += '<label class="aksrt-report-reason"><input type="radio" name="report_reason" required value="' + index + '">';
|
|
html += '<span>' + escapeHtml(cat.name) + '</span></label>';
|
|
});
|
|
html += '<label class="aksrt-report-reason"><input type="radio" name="report_reason" required value="other"><span>其他原因</span></label>';
|
|
html += '</div><div class="aksrt-report-other" hidden>';
|
|
html += '<label class="aksrt-report-label" for="' + id + '-other">请说明举报原因</label>';
|
|
html += '<textarea id="' + id + '-other" name="reason_other" rows="2" maxlength="200" placeholder="请简要说明你认为存在的问题" aria-describedby="' + id + '-reason-error"></textarea>';
|
|
html += '</div><p class="aksrt-report-error" id="' + id + '-reason-error" data-aksrt-report-reason-error role="alert" hidden></p></fieldset>';
|
|
html += '<div class="aksrt-report-field"><div class="aksrt-report-label-row">';
|
|
html += '<label class="aksrt-report-label" for="' + id + '-desc">补充说明<span class="aksrt-report-hint">选填</span></label>';
|
|
html += '<span class="aksrt-report-count" data-aksrt-report-count aria-hidden="true">0 / 2000</span></div>';
|
|
html += '<textarea id="' + id + '-desc" name="report_desc" rows="3" maxlength="2000" placeholder="例如:问题出现在什么位置,具体有哪些违规内容"></textarea></div>';
|
|
if (report.imgEnabled) {
|
|
html += '<section class="aksrt-report-field" aria-label="图片举证">';
|
|
html += '<div class="aksrt-report-label-row"><span class="aksrt-report-label">图片举证<span class="aksrt-report-hint">选填</span></span>';
|
|
html += '<span class="aksrt-report-count" data-aksrt-report-image-count aria-live="polite">0 / ' + MAX_IMAGES + '</span></div>';
|
|
html += '<div class="aksrt-report-images"><div class="aksrt-report-images__list" data-aksrt-report-images></div>';
|
|
html += '<button type="button" class="aksrt-report-add" data-aksrt-report-add><i class="fas fa-plus" aria-hidden="true"></i><span>添加图片</span></button></div>';
|
|
html += '<p class="aksrt-report-hint">支持 JPG、PNG、GIF,最多 ' + MAX_IMAGES + ' 张</p>';
|
|
html += '<input type="file" accept="image/gif,image/jpeg,image/png" multiple data-aksrt-report-file hidden></section>';
|
|
}
|
|
// The settings reader sanitizes this administrator-authored notice with wp_kses_post.
|
|
if (report.desc) html += '<div class="aksrt-report-notice">' + report.desc + '</div>';
|
|
html += '</div><footer class="aksrt-report-footer">';
|
|
html += '<p class="aksrt-report-error" data-aksrt-report-error role="alert" hidden></p>';
|
|
html += '<p class="aksrt-report-status" data-aksrt-report-status role="status"></p>';
|
|
html += '<div class="aksrt-report-actions"><button type="button" class="aksrt-report-cancel" data-aksrt-modal-close>取消</button>';
|
|
html += '<button type="submit" class="aksrt-report-submit" data-aksrt-report-submit>提交举报</button></div>';
|
|
html += '</footer></form>';
|
|
return html;
|
|
}
|
|
|
|
function showError(element, message) {
|
|
element.textContent = message;
|
|
element.hidden = !message;
|
|
}
|
|
|
|
function bindReportModal(shell, data, categories) {
|
|
var form = shell.querySelector('.aksrt-report-form');
|
|
var submit = form.querySelector('[data-aksrt-report-submit]');
|
|
var reasonError = form.querySelector('[data-aksrt-report-reason-error]');
|
|
var error = form.querySelector('[data-aksrt-report-error]');
|
|
var status = form.querySelector('[data-aksrt-report-status]');
|
|
var otherBox = form.querySelector('.aksrt-report-other');
|
|
var otherInput = form.querySelector('[name="reason_other"]');
|
|
var descInput = form.querySelector('[name="report_desc"]');
|
|
var fileInput = form.querySelector('[data-aksrt-report-file]');
|
|
var images = [];
|
|
var submitAttempts = 0;
|
|
var submitting = false;
|
|
var closed = false;
|
|
|
|
function isActive() {
|
|
return !closed && window.AksrtModalRuntime.getActive() === shell;
|
|
}
|
|
|
|
function syncState() {
|
|
var uploading = images.some(function (image) { return image.status === 'uploading'; });
|
|
submit.disabled = submitting || uploading;
|
|
submit.textContent = uploading ? '图片上传中…' : '提交举报';
|
|
form.setAttribute('aria-busy', String(submitting || uploading));
|
|
status.textContent = uploading ? '图片正在上传,完成后即可提交' : '';
|
|
var add = form.querySelector('[data-aksrt-report-add]');
|
|
if (add) {
|
|
add.disabled = submitting || images.length >= MAX_IMAGES;
|
|
form.querySelector('[data-aksrt-report-image-count]').textContent = images.length + ' / ' + MAX_IMAGES;
|
|
}
|
|
}
|
|
|
|
function deletePending(image) {
|
|
if (!image.attachmentId) return;
|
|
window.fetch(API_ROOT + '/upload/pending/' + encodeURIComponent(image.attachmentId), {
|
|
method: 'DELETE', headers: { 'X-WP-Nonce': NONCE },
|
|
}).catch(function () {});
|
|
}
|
|
|
|
function renderImage(image) {
|
|
var html = image.url ? '<img src="' + escapeHtml(image.url) + '" alt="举证图片">' : '';
|
|
html += '<div class="aksrt-report-image__state">';
|
|
if (image.status === 'uploading') html += '<span role="status">上传中…</span>';
|
|
if (image.status === 'error') {
|
|
html += '<span>上传失败</span><button type="button" data-aksrt-report-retry>重试</button>';
|
|
}
|
|
html += '</div><button type="button" class="aksrt-report-image__remove" data-aksrt-report-remove aria-label="删除举证图片"' + (submitting ? ' disabled' : '') + '>×</button>';
|
|
image.node.innerHTML = html;
|
|
image.node.setAttribute('data-upload-state', image.status);
|
|
}
|
|
|
|
function uploadImage(image) {
|
|
image.status = 'uploading';
|
|
renderImage(image);
|
|
syncState();
|
|
var body = new FormData();
|
|
body.append('image', image.file);
|
|
window.fetch(API_ROOT + '/report/upload-image', {
|
|
method: 'POST', headers: { 'X-WP-Nonce': NONCE }, body: body,
|
|
}).then(function (response) { return response.json(); }).then(function (result) {
|
|
if (!result || !result.success || !result.data || !result.data.url) {
|
|
throw new Error((result && result.message) || '图片上传失败,请重试');
|
|
}
|
|
image.attachmentId = result.data.id;
|
|
if (closed || images.indexOf(image) === -1) {
|
|
deletePending(image);
|
|
return;
|
|
}
|
|
image.url = result.data.url;
|
|
image.status = 'ready';
|
|
renderImage(image);
|
|
}).catch(function (failure) {
|
|
if (closed || images.indexOf(image) === -1) return;
|
|
image.status = 'error';
|
|
renderImage(image);
|
|
showError(error, failure.message || '图片上传失败,请重试');
|
|
}).then(function () {
|
|
if (!closed) syncState();
|
|
});
|
|
}
|
|
|
|
form.addEventListener('change', function (event) {
|
|
if (event.target.name === 'report_reason') {
|
|
var other = event.target.value === 'other';
|
|
otherBox.hidden = !other;
|
|
otherInput.disabled = !other;
|
|
otherInput.required = other;
|
|
otherInput.removeAttribute('aria-invalid');
|
|
showError(reasonError, '');
|
|
if (other) otherInput.focus();
|
|
}
|
|
if (event.target !== fileInput || submitting) return;
|
|
var files = Array.prototype.slice.call(fileInput.files || []);
|
|
showError(error, '');
|
|
files.forEach(function (file) {
|
|
if (images.length >= MAX_IMAGES) {
|
|
showError(error, '最多上传 ' + MAX_IMAGES + ' 张图片,请先删除不需要的图片');
|
|
return;
|
|
}
|
|
if (!/^image\/(jpeg|png|gif)$/.test(file.type)) {
|
|
showError(error, '请选择 JPG、PNG 或 GIF 图片');
|
|
return;
|
|
}
|
|
var image = { file: file, status: 'uploading', url: '', attachmentId: 0, node: document.createElement('div') };
|
|
image.node.className = 'aksrt-report-image';
|
|
images.push(image);
|
|
form.querySelector('[data-aksrt-report-images]').appendChild(image.node);
|
|
uploadImage(image);
|
|
});
|
|
fileInput.value = '';
|
|
});
|
|
|
|
form.addEventListener('input', function (event) {
|
|
if (event.target === descInput) form.querySelector('[data-aksrt-report-count]').textContent = descInput.value.length + ' / 2000';
|
|
if (event.target === otherInput) {
|
|
otherInput.removeAttribute('aria-invalid');
|
|
showError(reasonError, '');
|
|
}
|
|
});
|
|
|
|
form.addEventListener('click', function (event) {
|
|
var add = event.target.closest('[data-aksrt-report-add]');
|
|
if (add && !add.disabled && !submitting) fileInput.click();
|
|
var imageNode = event.target.closest('.aksrt-report-image');
|
|
if (!imageNode || submitting) return;
|
|
var image = images.find(function (entry) { return entry.node === imageNode; });
|
|
if (!image) return;
|
|
if (event.target.closest('[data-aksrt-report-remove]')) {
|
|
images.splice(images.indexOf(image), 1);
|
|
image.node.remove();
|
|
deletePending(image);
|
|
showError(error, '');
|
|
syncState();
|
|
} else if (event.target.closest('[data-aksrt-report-retry]') && image.status === 'error') {
|
|
showError(error, '');
|
|
uploadImage(image);
|
|
}
|
|
});
|
|
|
|
form.addEventListener('submit', function (event) {
|
|
event.preventDefault();
|
|
if (submitting || closed) return;
|
|
showError(error, '');
|
|
var selected = form.querySelector('[name="report_reason"]:checked');
|
|
if (!selected) {
|
|
showError(reasonError, '请选择一项举报原因');
|
|
form.querySelector('[name="report_reason"]').focus();
|
|
return;
|
|
}
|
|
var other = selected.value === 'other';
|
|
var category = other ? '其他原因' : String(categories[Number(selected.value)].name);
|
|
var explanation = other ? otherInput.value.trim() : '';
|
|
if (other && !explanation) {
|
|
showError(reasonError, '请填写举报原因');
|
|
otherInput.setAttribute('aria-invalid', 'true');
|
|
otherInput.focus();
|
|
return;
|
|
}
|
|
if (images.some(function (image) { return image.status !== 'ready'; })) {
|
|
showError(error, '请等待图片上传完成,或重试、删除上传失败的图片');
|
|
return;
|
|
}
|
|
var content = [];
|
|
if (explanation) content.push(explanation);
|
|
var description = descInput.value.trim();
|
|
if (description) content.push(description);
|
|
if (images.length) content.push('图片举证:\n' + images.map(function (image) { return image.url; }).join('\n'));
|
|
var reason = content.join('\n\n');
|
|
submitting = true;
|
|
syncState();
|
|
var toastId = form.id + '-submit-' + (++submitAttempts);
|
|
notyf('正在提交举报…', 'load', '', toastId);
|
|
form.querySelectorAll('input, textarea, [data-aksrt-report-remove], [data-aksrt-report-retry]').forEach(function (element) { element.disabled = true; });
|
|
window.fetch(API_ROOT + '/report/submit', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': NONCE },
|
|
body: JSON.stringify({ target_type: data.target_type, target_id: data.target_id, category: category, reason: reason }),
|
|
}).then(function (response) { return response.json(); }).then(function (result) {
|
|
if (!result || !result.success) throw new Error((result && result.message) || '举报提交失败,请重试');
|
|
// Submitted evidence belongs to the report even if its dialog has already closed.
|
|
images = [];
|
|
form.setAttribute('aria-busy', 'false');
|
|
submit.textContent = '举报已提交';
|
|
status.textContent = result.message || '举报已提交,我们会尽快处理';
|
|
form.querySelector('[data-aksrt-modal-close]').textContent = '关闭';
|
|
if (isActive()) window.AksrtModalRuntime.close('submit');
|
|
notyf(result.message || '举报已提交,我们会尽快处理', 'success', 5, toastId);
|
|
}).catch(function (failure) {
|
|
notyf(failure.message || '网络错误,请重试', 'danger', 5, toastId);
|
|
submitting = false;
|
|
if (closed) {
|
|
images.forEach(deletePending);
|
|
return;
|
|
}
|
|
form.querySelectorAll('input, textarea, [data-aksrt-report-remove], [data-aksrt-report-retry]').forEach(function (element) { element.disabled = false; });
|
|
otherInput.disabled = otherBox.hidden;
|
|
syncState();
|
|
});
|
|
});
|
|
|
|
function onClose(event) {
|
|
if (!event.detail || event.detail.shell !== shell) return;
|
|
closed = true;
|
|
document.removeEventListener('aksrt:modal:close', onClose);
|
|
// While submitting, the response decides whether pending evidence is still needed.
|
|
if (!submitting) images.forEach(deletePending);
|
|
}
|
|
document.addEventListener('aksrt:modal:close', onClose);
|
|
otherInput.disabled = true;
|
|
syncState();
|
|
}
|
|
|
|
function openReportModal(data, opener) {
|
|
var runtime = window.AksrtModalRuntime;
|
|
if (!runtime || typeof runtime.open !== 'function') {
|
|
notyf('举报组件加载失败,请刷新页面后重试', 'danger');
|
|
return;
|
|
}
|
|
var categories = getReportCategories();
|
|
var shell = runtime.open({
|
|
html: buildReportModalHtml(data, categories), title: TARGETS[data.target_type].title,
|
|
className: 'aksrt-report-modal', kind: 'report', mobileBottom: true, focusReturn: opener,
|
|
});
|
|
bindReportModal(shell, data, categories);
|
|
}
|
|
|
|
function initReportButtons() {
|
|
if (document.documentElement.dataset.aksrtReportInitialized === '1') return;
|
|
document.documentElement.dataset.aksrtReportInitialized = '1';
|
|
document.addEventListener('click', function (event) {
|
|
var button = event.target.closest('[data-aksrt-report]');
|
|
if (!button) return;
|
|
event.preventDefault();
|
|
var data;
|
|
try { data = JSON.parse(button.getAttribute('data-aksrt-report')); } catch (error) { return; }
|
|
if (data && TARGETS[data.target_type] && Number(data.target_id) > 0) openReportModal(data, button);
|
|
});
|
|
}
|
|
|
|
jQuery(document).off('auto_fun.aksrt-report').on('auto_fun.aksrt-report', initReportButtons);
|
|
})();
|