fix(frontend): simplify report dialog controls and feedback
Use highlight-only reason choices, show the associated target on one line, and replace inline submission progress with shared toast feedback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
80aecab586
commit
b2811cc248
4 files changed
+86
-49
No files matched your search
@@ -66,34 +66,22 @@
|
|||||||
|
|
||||||
.aksrt-report-target {
|
.aksrt-report-target {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: baseline;
|
||||||
gap: 12px;
|
margin-bottom: 22px;
|
||||||
padding: 14px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--modal-surface-3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-report-target > div { min-width: 0; }
|
.aksrt-report-target__label {
|
||||||
|
|
||||||
.aksrt-report-target__icon {
|
|
||||||
display: grid;
|
|
||||||
flex: none;
|
flex: none;
|
||||||
place-items: center;
|
color: var(--modal-muted);
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border: 1px solid var(--modal-line);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--modal-surface);
|
|
||||||
color: var(--modal-ink-2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-report-target__name {
|
.aksrt-report-target__name {
|
||||||
display: block;
|
min-width: 0;
|
||||||
margin: 2px 0 4px;
|
overflow: hidden;
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
overflow-wrap: anywhere;
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-report-hint,
|
.aksrt-report-hint,
|
||||||
@@ -104,7 +92,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-report-form p { margin: 6px 0 0; }
|
.aksrt-report-form p { margin: 6px 0 0; }
|
||||||
.aksrt-report-target p { margin: 0; }
|
|
||||||
.aksrt-report-count { font-variant-numeric: tabular-nums; }
|
.aksrt-report-count { font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
.aksrt-report-reasons {
|
.aksrt-report-reasons {
|
||||||
@@ -135,9 +122,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-report-reason {
|
.aksrt-report-reason {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 9px;
|
justify-content: center;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -145,25 +133,31 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--modal-surface);
|
background: var(--modal-surface);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-report-reason input[type="radio"] {
|
.aksrt-report-reason input[type="radio"] {
|
||||||
position: static;
|
position: absolute;
|
||||||
display: block;
|
width: 1px;
|
||||||
flex: none;
|
height: 1px;
|
||||||
width: 16px;
|
padding: 0;
|
||||||
height: 16px;
|
margin: -1px;
|
||||||
margin: 0;
|
overflow: hidden;
|
||||||
opacity: 1;
|
border: 0;
|
||||||
appearance: auto;
|
clip-path: inset(50%);
|
||||||
accent-color: var(--modal-primary);
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-report-reason:has(input:checked) {
|
.aksrt-report-reason:has(input:checked) {
|
||||||
border-color: var(--modal-primary);
|
border-color: var(--modal-primary);
|
||||||
background: color-mix(in srgb, var(--modal-primary) 6%, var(--modal-surface));
|
background: color-mix(in srgb, var(--modal-primary) 10%, var(--modal-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.aksrt-report-reason:has(input:focus-visible) {
|
||||||
|
outline: 2px solid var(--modal-primary);
|
||||||
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-report-reason input:checked + span { font-weight: 600; }
|
.aksrt-report-reason input:checked + span { font-weight: 600; }
|
||||||
|
|||||||
+14
-13
@@ -11,9 +11,9 @@
|
|||||||
var MAX_IMAGES = 3;
|
var MAX_IMAGES = 3;
|
||||||
var sequence = 0;
|
var sequence = 0;
|
||||||
var TARGETS = {
|
var TARGETS = {
|
||||||
user: { title: '举报用户', label: '用户', icon: 'fa-user' },
|
user: { title: '举报用户', label: '用户' },
|
||||||
post: { title: '举报文章', label: '文章', icon: 'fa-file-alt' },
|
post: { title: '举报文章', label: '文章' },
|
||||||
comment: { title: '举报评论', label: '评论', icon: 'fa-comment' },
|
comment: { title: '举报评论', label: '评论' },
|
||||||
};
|
};
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
@@ -31,13 +31,11 @@
|
|||||||
var target = TARGETS[data.target_type];
|
var target = TARGETS[data.target_type];
|
||||||
var report = config.report || {};
|
var report = config.report || {};
|
||||||
var id = 'aksrt-report-' + (++sequence);
|
var id = 'aksrt-report-' + (++sequence);
|
||||||
var html = '<form class="aksrt-report-form" novalidate>';
|
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-body mini-scrollbar">';
|
||||||
html += '<div class="aksrt-report-target">';
|
html += '<div class="aksrt-report-target"><span class="aksrt-report-target__label">关联对象:</span>';
|
||||||
html += '<span class="aksrt-report-target__icon" aria-hidden="true"><i class="fas ' + target.icon + '"></i></span>';
|
html += '<strong class="aksrt-report-target__name" title="' + escapeHtml(name) + '">' + escapeHtml(name) + '</strong></div>';
|
||||||
html += '<div><span class="aksrt-report-hint">举报对象 · ' + target.label + '</span>';
|
|
||||||
html += '<strong class="aksrt-report-target__name">' + escapeHtml(data.target_name || target.label + ' #' + data.target_id) + '</strong>';
|
|
||||||
html += '<p class="aksrt-report-hint">已自动关联该' + target.label + ',无需填写链接</p></div></div>';
|
|
||||||
html += '<fieldset class="aksrt-report-reasons" aria-describedby="' + id + '-reason-error">';
|
html += '<fieldset class="aksrt-report-reasons" aria-describedby="' + id + '-reason-error">';
|
||||||
html += '<legend>举报原因<span class="aksrt-report-hint">必选一项</span></legend>';
|
html += '<legend>举报原因<span class="aksrt-report-hint">必选一项</span></legend>';
|
||||||
html += '<div class="aksrt-report-reasons__options">';
|
html += '<div class="aksrt-report-reasons__options">';
|
||||||
@@ -90,6 +88,7 @@
|
|||||||
var descInput = form.querySelector('[name="report_desc"]');
|
var descInput = form.querySelector('[name="report_desc"]');
|
||||||
var fileInput = form.querySelector('[data-aksrt-report-file]');
|
var fileInput = form.querySelector('[data-aksrt-report-file]');
|
||||||
var images = [];
|
var images = [];
|
||||||
|
var submitAttempts = 0;
|
||||||
var submitting = false;
|
var submitting = false;
|
||||||
var closed = false;
|
var closed = false;
|
||||||
|
|
||||||
@@ -100,9 +99,9 @@
|
|||||||
function syncState() {
|
function syncState() {
|
||||||
var uploading = images.some(function (image) { return image.status === 'uploading'; });
|
var uploading = images.some(function (image) { return image.status === 'uploading'; });
|
||||||
submit.disabled = submitting || uploading;
|
submit.disabled = submitting || uploading;
|
||||||
submit.textContent = submitting ? '正在提交…' : uploading ? '图片上传中…' : '提交举报';
|
submit.textContent = uploading ? '图片上传中…' : '提交举报';
|
||||||
form.setAttribute('aria-busy', String(submitting || uploading));
|
form.setAttribute('aria-busy', String(submitting || uploading));
|
||||||
status.textContent = submitting ? '正在提交举报,请稍候' : uploading ? '图片正在上传,完成后即可提交' : '';
|
status.textContent = uploading ? '图片正在上传,完成后即可提交' : '';
|
||||||
var add = form.querySelector('[data-aksrt-report-add]');
|
var add = form.querySelector('[data-aksrt-report-add]');
|
||||||
if (add) {
|
if (add) {
|
||||||
add.disabled = submitting || images.length >= MAX_IMAGES;
|
add.disabled = submitting || images.length >= MAX_IMAGES;
|
||||||
@@ -244,6 +243,8 @@
|
|||||||
if (images.length) reason += '\n图片举证:\n' + images.map(function (image) { return image.url; }).join('\n');
|
if (images.length) reason += '\n图片举证:\n' + images.map(function (image) { return image.url; }).join('\n');
|
||||||
submitting = true;
|
submitting = true;
|
||||||
syncState();
|
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; });
|
form.querySelectorAll('input, textarea, [data-aksrt-report-remove], [data-aksrt-report-retry]').forEach(function (element) { element.disabled = true; });
|
||||||
window.fetch(API_ROOT + '/report/submit', {
|
window.fetch(API_ROOT + '/report/submit', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -258,8 +259,9 @@
|
|||||||
status.textContent = result.message || '举报已提交,我们会尽快处理';
|
status.textContent = result.message || '举报已提交,我们会尽快处理';
|
||||||
form.querySelector('[data-aksrt-modal-close]').textContent = '关闭';
|
form.querySelector('[data-aksrt-modal-close]').textContent = '关闭';
|
||||||
if (isActive()) window.AksrtModalRuntime.close('submit');
|
if (isActive()) window.AksrtModalRuntime.close('submit');
|
||||||
notyf(result.message || '举报已提交,我们会尽快处理', 'success');
|
notyf(result.message || '举报已提交,我们会尽快处理', 'success', 5, toastId);
|
||||||
}).catch(function (failure) {
|
}).catch(function (failure) {
|
||||||
|
notyf(failure.message || '网络错误,请重试', 'danger', 5, toastId);
|
||||||
submitting = false;
|
submitting = false;
|
||||||
if (closed) {
|
if (closed) {
|
||||||
images.forEach(deletePending);
|
images.forEach(deletePending);
|
||||||
@@ -268,7 +270,6 @@
|
|||||||
form.querySelectorAll('input, textarea, [data-aksrt-report-remove], [data-aksrt-report-retry]').forEach(function (element) { element.disabled = false; });
|
form.querySelectorAll('input, textarea, [data-aksrt-report-remove], [data-aksrt-report-retry]').forEach(function (element) { element.disabled = false; });
|
||||||
otherInput.disabled = otherBox.hidden;
|
otherInput.disabled = otherBox.hidden;
|
||||||
syncState();
|
syncState();
|
||||||
showError(error, failure.message || '网络错误,请重试');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -399,7 +399,7 @@ test('report modal uses accessible reasons, automatic target association and opt
|
|||||||
assert.match(scripts, /report_settings\(\)/)
|
assert.match(scripts, /report_settings\(\)/)
|
||||||
|
|
||||||
const reportJs = read('assets/js/report.js')
|
const reportJs = read('assets/js/report.js')
|
||||||
for (const part of ['aksrt-report-target', 'name="report_reason"', 'type="radio"', '其他原因', '补充说明', '图片举证', '提交举报', '无需填写链接']) {
|
for (const part of ['aksrt-report-target', 'name="report_reason"', 'type="radio"', '其他原因', '补充说明', '图片举证', '提交举报', '关联对象:']) {
|
||||||
assert.ok(reportJs.includes(part), `report.js missing form contract: ${part}`)
|
assert.ok(reportJs.includes(part), `report.js missing form contract: ${part}`)
|
||||||
}
|
}
|
||||||
assert.doesNotMatch(reportJs, /report_url|违规链接|data-aksrt-report-reason=|bindAutoHeight/)
|
assert.doesNotMatch(reportJs, /report_url|违规链接|data-aksrt-report-reason=|bindAutoHeight/)
|
||||||
@@ -413,6 +413,10 @@ test('report modal uses accessible reasons, automatic target association and opt
|
|||||||
assert.match(css, /\.aksrt-report-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/)
|
assert.match(css, /\.aksrt-report-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/)
|
||||||
assert.match(css, /600px/)
|
assert.match(css, /600px/)
|
||||||
assert.match(css, /:focus-visible/)
|
assert.match(css, /:focus-visible/)
|
||||||
|
assert.match(css, /\.aksrt-report-reason:has\(input:checked\)/)
|
||||||
|
assert.match(css, /clip-path: inset\(50%\)/)
|
||||||
|
assert.match(css, /text-overflow: ellipsis/)
|
||||||
|
assert.doesNotMatch(reportJs, /aksrt-report-target__icon|无需填写链接|正在提交举报,请稍候/)
|
||||||
assert.match(css, /safe-area-inset-bottom/)
|
assert.match(css, /safe-area-inset-bottom/)
|
||||||
assert.match(css, /\[hidden\] \{ display: none !important;/)
|
assert.match(css, /\[hidden\] \{ display: none !important;/)
|
||||||
assert.doesNotMatch(modalRuntimeCss, /aksrt-report-modal/, 'retired report styles must not remain in the shared stylesheet')
|
assert.doesNotMatch(modalRuntimeCss, /aksrt-report-modal/, 'retired report styles must not remain in the shared stylesheet')
|
||||||
|
|||||||
@@ -208,6 +208,9 @@ test('opens from an article button, associates the target, omits URL input, and
|
|||||||
assert.equal(modal.shell.querySelector('[name="report_url"]'), null);
|
assert.equal(modal.shell.querySelector('[name="report_url"]'), null);
|
||||||
assert.equal(modal.shell.querySelector('input[type="url"]'), null);
|
assert.equal(modal.shell.querySelector('input[type="url"]'), null);
|
||||||
assert.ok(modal.shell.querySelector('.aksrt-report-target'));
|
assert.ok(modal.shell.querySelector('.aksrt-report-target'));
|
||||||
|
assert.equal(modal.shell.querySelector('.aksrt-report-target__label').textContent, '关联对象:');
|
||||||
|
assert.equal(modal.shell.querySelector('.aksrt-report-target__icon'), null);
|
||||||
|
assert.equal(target.getAttribute('title'), target.textContent);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('requires a radio reason and other text, then sends canonical fields with description', async () => {
|
test('requires a radio reason and other text, then sends canonical fields with description', async () => {
|
||||||
@@ -373,7 +376,8 @@ test('server failure preserves values, reenables controls, and allows a retry',
|
|||||||
assert.equal(otherInput.disabled, false);
|
assert.equal(otherInput.disabled, false);
|
||||||
assert.equal(description.disabled, false);
|
assert.equal(description.disabled, false);
|
||||||
assert.equal(submitButton.disabled, false);
|
assert.equal(submitButton.disabled, false);
|
||||||
assert.equal(form.querySelector('[data-aksrt-report-error]').textContent, 'Server rejected report');
|
assert.equal(form.querySelector('[data-aksrt-report-error]').hidden, true);
|
||||||
|
assert.deepEqual(runtime.notyfCalls.at(-1).slice(0, 3), ['Server rejected report', 'danger', 5]);
|
||||||
|
|
||||||
submit(modal);
|
submit(modal);
|
||||||
const retry = callsFor(runtime, '/report/submit')[1];
|
const retry = callsFor(runtime, '/report/submit')[1];
|
||||||
@@ -382,6 +386,40 @@ test('server failure preserves values, reenables controls, and allows a retry',
|
|||||||
await flushPromises();
|
await flushPromises();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('uses a persistent shared toast per submit attempt without inline submitting text', async () => {
|
||||||
|
const runtime = createRuntime();
|
||||||
|
const modal = runtime.openReport();
|
||||||
|
selectReason(modal, 0);
|
||||||
|
const form = modal.shell.querySelector('.aksrt-report-form');
|
||||||
|
const button = form.querySelector('[data-aksrt-report-submit]');
|
||||||
|
const status = form.querySelector('[data-aksrt-report-status]');
|
||||||
|
|
||||||
|
submit(modal);
|
||||||
|
const firstToast = runtime.notyfCalls.at(-1);
|
||||||
|
assert.deepEqual(firstToast.slice(0, 3), ['正在提交举报…', 'load', '']);
|
||||||
|
assert.ok(firstToast[3].startsWith(form.id + '-submit-'));
|
||||||
|
assert.equal(button.textContent, '提交举报');
|
||||||
|
assert.equal(button.disabled, true);
|
||||||
|
assert.equal(status.textContent, '');
|
||||||
|
assert.equal(form.getAttribute('aria-busy'), 'true');
|
||||||
|
|
||||||
|
submit(modal);
|
||||||
|
assert.equal(runtime.notyfCalls.length, 1);
|
||||||
|
callsFor(runtime, '/report/submit')[0].resolve({ success: false, message: 'Retry report' });
|
||||||
|
await flushPromises();
|
||||||
|
assert.deepEqual(runtime.notyfCalls.at(-1), ['Retry report', 'danger', 5, firstToast[3]]);
|
||||||
|
assert.equal(button.disabled, false);
|
||||||
|
assert.equal(status.textContent, '');
|
||||||
|
|
||||||
|
submit(modal);
|
||||||
|
const secondToast = runtime.notyfCalls.at(-1);
|
||||||
|
assert.equal(secondToast[1], 'load');
|
||||||
|
assert.notEqual(secondToast[3], firstToast[3]);
|
||||||
|
callsFor(runtime, '/report/submit')[1].resolve({ success: true, message: 'Report accepted' });
|
||||||
|
await flushPromises();
|
||||||
|
assert.deepEqual(runtime.notyfCalls.at(-1), ['Report accepted', 'success', 5, secondToast[3]]);
|
||||||
|
});
|
||||||
|
|
||||||
test('blocks duplicate submits and closing an old submitting dialog cannot close the new one', async () => {
|
test('blocks duplicate submits and closing an old submitting dialog cannot close the new one', async () => {
|
||||||
const runtime = createRuntime();
|
const runtime = createRuntime();
|
||||||
const oldModal = runtime.openReport();
|
const oldModal = runtime.openReport();
|
||||||
|
|||||||
Reference in new issue
Block a user