diff --git a/assets/css/frontend/report.css b/assets/css/frontend/report.css index 63a32bf0..552e9185 100644 --- a/assets/css/frontend/report.css +++ b/assets/css/frontend/report.css @@ -66,34 +66,22 @@ .aksrt-report-target { display: flex; - align-items: flex-start; - gap: 12px; - padding: 14px; - margin-bottom: 24px; - border-radius: 8px; - background: var(--modal-surface-3); + align-items: baseline; + margin-bottom: 22px; } -.aksrt-report-target > div { min-width: 0; } - -.aksrt-report-target__icon { - display: grid; +.aksrt-report-target__label { flex: none; - place-items: center; - width: 36px; - height: 36px; - border: 1px solid var(--modal-line); - border-radius: 8px; - background: var(--modal-surface); - color: var(--modal-ink-2); + color: var(--modal-muted); } .aksrt-report-target__name { - display: block; - margin: 2px 0 4px; - font-size: 15px; - font-weight: 600; - overflow-wrap: anywhere; + min-width: 0; + overflow: hidden; + font-size: 14px; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; } .aksrt-report-hint, @@ -104,7 +92,6 @@ } .aksrt-report-form p { margin: 6px 0 0; } -.aksrt-report-target p { margin: 0; } .aksrt-report-count { font-variant-numeric: tabular-nums; } .aksrt-report-reasons { @@ -135,9 +122,10 @@ } .aksrt-report-reason { + position: relative; display: flex; align-items: center; - gap: 9px; + justify-content: center; min-height: 44px; padding: 10px 12px; margin: 0; @@ -145,25 +133,31 @@ border-radius: 8px; background: var(--modal-surface); font-weight: 400; + text-align: center; cursor: pointer; overflow-wrap: anywhere; } .aksrt-report-reason input[type="radio"] { - position: static; - display: block; - flex: none; - width: 16px; - height: 16px; - margin: 0; - opacity: 1; - appearance: auto; - accent-color: var(--modal-primary); + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + border: 0; + clip-path: inset(50%); + white-space: nowrap; } .aksrt-report-reason:has(input:checked) { 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; } diff --git a/assets/js/report.js b/assets/js/report.js index 3e887155..e27ab4bb 100644 --- a/assets/js/report.js +++ b/assets/js/report.js @@ -11,9 +11,9 @@ var MAX_IMAGES = 3; var sequence = 0; var TARGETS = { - user: { title: '举报用户', label: '用户', icon: 'fa-user' }, - post: { title: '举报文章', label: '文章', icon: 'fa-file-alt' }, - comment: { title: '举报评论', label: '评论', icon: 'fa-comment' }, + user: { title: '举报用户', label: '用户' }, + post: { title: '举报文章', label: '文章' }, + comment: { title: '举报评论', label: '评论' }, }; function escapeHtml(value) { @@ -31,13 +31,11 @@ var target = TARGETS[data.target_type]; var report = config.report || {}; var id = 'aksrt-report-' + (++sequence); - var html = '
'; + var name = data.target_name || target.label + ' #' + data.target_id; + var html = ''; html += '
'; - html += '
'; - html += ''; - html += '
举报对象 · ' + target.label + ''; - html += '' + escapeHtml(data.target_name || target.label + ' #' + data.target_id) + ''; - html += '

已自动关联该' + target.label + ',无需填写链接

'; + html += '
关联对象:'; + html += '' + escapeHtml(name) + '
'; html += '
'; html += '举报原因必选一项'; html += '
'; @@ -90,6 +88,7 @@ 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; @@ -100,9 +99,9 @@ function syncState() { var uploading = images.some(function (image) { return image.status === 'uploading'; }); submit.disabled = submitting || uploading; - submit.textContent = submitting ? '正在提交…' : uploading ? '图片上传中…' : '提交举报'; + submit.textContent = uploading ? '图片上传中…' : '提交举报'; form.setAttribute('aria-busy', String(submitting || uploading)); - status.textContent = submitting ? '正在提交举报,请稍候' : uploading ? '图片正在上传,完成后即可提交' : ''; + status.textContent = uploading ? '图片正在上传,完成后即可提交' : ''; var add = form.querySelector('[data-aksrt-report-add]'); if (add) { 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'); 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', @@ -258,8 +259,9 @@ status.textContent = result.message || '举报已提交,我们会尽快处理'; form.querySelector('[data-aksrt-modal-close]').textContent = '关闭'; if (isActive()) window.AksrtModalRuntime.close('submit'); - notyf(result.message || '举报已提交,我们会尽快处理', 'success'); + notyf(result.message || '举报已提交,我们会尽快处理', 'success', 5, toastId); }).catch(function (failure) { + notyf(failure.message || '网络错误,请重试', 'danger', 5, toastId); submitting = false; if (closed) { 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; }); otherInput.disabled = otherBox.hidden; syncState(); - showError(error, failure.message || '网络错误,请重试'); }); }); diff --git a/tests/widget-system/report-ban-contract.test.cjs b/tests/widget-system/report-ban-contract.test.cjs index f674e1c7..c9835d23 100644 --- a/tests/widget-system/report-ban-contract.test.cjs +++ b/tests/widget-system/report-ban-contract.test.cjs @@ -399,7 +399,7 @@ test('report modal uses accessible reasons, automatic target association and opt assert.match(scripts, /report_settings\(\)/) 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.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, /600px/) 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, /\[hidden\] \{ display: none !important;/) assert.doesNotMatch(modalRuntimeCss, /aksrt-report-modal/, 'retired report styles must not remain in the shared stylesheet') diff --git a/tests/widget-system/report-modal-runtime.test.cjs b/tests/widget-system/report-modal-runtime.test.cjs index 11797e6b..eb4366a8 100644 --- a/tests/widget-system/report-modal-runtime.test.cjs +++ b/tests/widget-system/report-modal-runtime.test.cjs @@ -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('input[type="url"]'), null); 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 () => { @@ -373,7 +376,8 @@ test('server failure preserves values, reenables controls, and allows a retry', assert.equal(otherInput.disabled, false); assert.equal(description.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); const retry = callsFor(runtime, '/report/submit')[1]; @@ -382,6 +386,40 @@ test('server failure preserves values, reenables controls, and allows a retry', 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 () => { const runtime = createRuntime(); const oldModal = runtime.openReport();