Files
kate522andClaude Opus 5 35bb96e82f refactor: rename the theme brand and every identifier from AKSRT to Jio
Full-project rename. `git grep -i aksrt` is now empty apart from the
DBMigrator 3.3.0 changelog note that documents the rename itself.

Code identifiers
- PHP namespace AKSRT\ -> Jio\ (composer PSR-4 + 378 declarations)
- global constants AKSRT_* -> JIO_* (VERSION/DIR/URI, PAY_TYPE_*, CAPTCHA_*)
- procedural functions, hooks, options, meta keys, table names,
  cookies, query vars, ajax actions and nonces: aksrt_ -> jio_
- text domain aksrt -> jio; Theme Name -> Jio
- JS globals: aksrtData/aksrtAdminData/... -> jioData/jioAdminData/...,
  window.AKSRTCaptcha & friends -> window.JioCaptcha, Aksrt* -> Jio*
- Vue components Aks*.vue -> Jio*.vue (15 files renamed + imports)
- CSS classes/data-attrs/custom properties: aksrt- -> jio-
- REST namespace aksrt/v1 -> jio/v1 (incl. payment gateway callbacks)

Storage layer
- DB tables {prefix}aksrt_* -> {prefix}jio_*; SCHEMA_VERSION 3.2.0 -> 3.3.0
  so dbDelta creates the new names on install
- widget id_base aksrt_widget_* -> jio_widget_*
- uninstall.php drop list rebuilt from the actual migrator tables
  (dropped 3 stale entries, added the 17 missing ones)

Removed
- hardcoded https://api.aksrt.com/version.json default update endpoint;
  get_endpoint() now reads the 「更新端点」 setting and do_check() no-ops
  when it is empty

Files renamed: inc/helpers/aksrt-*.php -> jio-*.php (54),
assets/admin/src/components/form/Aks*.vue -> Jio*.vue (15).
Build artifacts regenerated: assets/css/build, assets/js/runtime,
assets/admin/build.

Verified: audit-psr4-paths errors=0, audit-admin-contracts errors=0
(warnings=56, unchanged baseline), 813/813 widget-system tests,
44/44 phpunit, php -l clean across all PHP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-11-01 20:05:04 +08:00

547 lines
21 KiB
JavaScript

'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const test = require('node:test');
const { MiniDocument, MiniElement } = require('../helpers/mini-dom.cjs');
const root = path.resolve(__dirname, '..', '..');
class ReportElement extends MiniElement {
constructor(tagName) {
super(tagName);
this._value = null;
this.files = [];
}
get value() { return this._value ?? this.getAttribute('value') ?? ''; }
set value(value) { this._value = String(value ?? ''); }
get name() { return this.getAttribute('name') || ''; }
get type() { return this.getAttribute('type') || ''; }
get checked() { return this.hasAttribute('checked'); }
set checked(value) { value ? this.setAttribute('checked', '') : this.removeAttribute('checked'); }
get disabled() { return this.hasAttribute('disabled'); }
set disabled(value) { value ? this.setAttribute('disabled', '') : this.removeAttribute('disabled'); }
get hidden() { return this.hasAttribute('hidden'); }
set hidden(value) { value ? this.setAttribute('hidden', '') : this.removeAttribute('hidden'); }
focus() { this.ownerDocument.activeElement = this; }
click() { this.dispatch('click'); }
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
querySelectorAll(selector) {
const groups = String(selector).split(',');
return [...new Set(groups.flatMap((group) => {
const checked = /:checked\b/.test(group);
const disabled = /:disabled\b/.test(group);
const plainSelector = group.replace(/:(?:checked|disabled)\b/g, '').trim();
return super.querySelectorAll(plainSelector).filter((element) =>
(!checked || element.checked) && (!disabled || element.disabled));
}))];
}
}
class ReportDocument extends MiniDocument {
constructor() {
super();
this.documentElement = this;
this.activeElement = null;
}
createElement(tagName) {
const element = new ReportElement(tagName);
element.ownerDocument = this;
return element;
}
}
class MiniFormData {
constructor() { this.values = []; }
append(name, value) { this.values.push([name, value]); }
get(name) { return this.values.find(([key]) => key === name)?.[1] ?? null; }
}
function createRuntime({ imgEnabled = true, categories = [
{ name: '垃圾信息' },
{ name: '人身攻击' },
] } = {}) {
const document = new ReportDocument();
const modals = [];
const fetchCalls = [];
const notyfCalls = [];
const closeCalls = [];
let activeModal = null;
let autoInit = null;
function fetch(url, options = {}) {
let resolveRequest;
let rejectRequest;
const promise = new Promise((resolve, reject) => {
resolveRequest = resolve;
rejectRequest = reject;
});
const call = {
url: String(url),
options,
resolve(data) {
resolveRequest({ json: () => Promise.resolve(data) });
},
reject(error) { rejectRequest(error); },
};
fetchCalls.push(call);
if (options.method === 'DELETE') call.resolve({ success: true });
return promise;
}
const modalRuntime = {
open(options) {
const shell = document.createElement('div');
shell.className = options.className;
shell.innerHTML = options.html;
document.body.appendChild(shell);
activeModal = shell;
modals.push({ shell, options });
return shell;
},
getActive() { return activeModal; },
close(reason, shell = activeModal) {
closeCalls.push({ reason, shell });
if (!shell) return;
if (activeModal === shell) activeModal = null;
shell.remove();
document.dispatch('jio:modal:close', { detail: { shell } });
},
};
const window = {
jioData: {
apiUrl: '/wp-json/jio/v1',
restNonce: 'test-nonce',
reportCategories: categories,
report: { imgEnabled },
},
JioModalRuntime: modalRuntime,
fetch,
};
const jquery = () => ({
off() { return this; },
on(event, callback) {
if (event === 'auto_fun.jio-report') autoInit = callback;
return this;
},
});
vm.runInNewContext(fs.readFileSync(path.join(root, 'assets/js/report.js'), 'utf8'), {
window,
document,
jQuery: jquery,
FormData: MiniFormData,
Promise,
console,
notyf: (...args) => notyfCalls.push(args),
});
assert.equal(typeof autoInit, 'function');
autoInit();
return {
document,
window,
modalRuntime,
modals,
fetchCalls,
notyfCalls,
closeCalls,
get activeModal() { return activeModal; },
openReport(target = { target_type: 'post', target_id: 42, target_name: 'Article' }) {
const opener = document.createElement('button');
opener.setAttribute('data-jio-report', JSON.stringify(target));
document.body.appendChild(opener);
const event = opener.dispatch('click');
return { ...modals.at(-1), opener, event };
},
};
}
function selectReason(modal, value) {
const radios = modal.shell.querySelectorAll('[name="report_reason"]');
const selected = radios.find((radio) => radio.value === String(value));
assert.ok(selected, `missing reason ${value}`);
radios.forEach((radio) => { radio.checked = radio === selected; });
selected.dispatch('change');
return selected;
}
function submit(modal) {
return modal.shell.querySelector('.jio-report-form').dispatch('submit');
}
function callsFor(runtime, endpoint) {
return runtime.fetchCalls.filter((call) => call.url.includes(endpoint));
}
function payloadFor(call) {
assert.equal(typeof call.options.body, 'string');
return JSON.parse(call.options.body);
}
async function flushPromises() {
await new Promise((resolve) => setImmediate(resolve));
}
test('opens from an article button, associates the target, omits URL input, and escapes rendered names', () => {
const runtime = createRuntime({ categories: [{ name: '<svg onload="run()">reason</svg>' }] });
const targetName = '<img src=x onerror="run()"> &';
const modal = runtime.openReport({ target_type: 'post', target_id: 73, target_name: targetName });
const target = modal.shell.querySelector('.jio-report-target__name');
const reason = modal.shell.querySelector('.jio-report-reason span');
assert.equal(modal.event.defaultPrevented, true);
assert.equal(modal.options.title, '举报文章');
assert.equal(modal.options.focusReturn, modal.opener);
assert.equal(runtime.activeModal, modal.shell);
assert.ok(target.textContent.includes('&lt;img'));
assert.equal(target.querySelector('img'), null);
assert.equal(reason.querySelector('svg'), null);
assert.equal(modal.shell.querySelector('[name="report_url"]'), null);
assert.equal(modal.shell.querySelector('input[type="url"]'), null);
assert.ok(modal.shell.querySelector('.jio-report-target'));
assert.equal(modal.shell.querySelector('.jio-report-target__label').textContent, '关联对象:');
assert.equal(modal.shell.querySelector('.jio-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 () => {
const runtime = createRuntime();
const modal = runtime.openReport();
const form = modal.shell.querySelector('.jio-report-form');
const reasonError = form.querySelector('[data-jio-report-reason-error]');
const otherBox = form.querySelector('.jio-report-other');
const otherInput = form.querySelector('[name="reason_other"]');
const description = form.querySelector('[name="report_desc"]');
submit(modal);
assert.equal(reasonError.hidden, false);
assert.equal(reasonError.textContent, '请选择一项举报原因');
assert.equal(runtime.document.activeElement, form.querySelector('[name="report_reason"]'));
assert.equal(callsFor(runtime, '/report/submit').length, 0);
selectReason(modal, 'other');
assert.equal(otherBox.hidden, false);
assert.equal(otherInput.disabled, false);
submit(modal);
assert.equal(reasonError.textContent, '请填写举报原因');
assert.equal(otherInput.getAttribute('aria-invalid'), 'true');
assert.equal(runtime.document.activeElement, otherInput);
assert.equal(callsFor(runtime, '/report/submit').length, 0);
otherInput.value = '自定义原因';
otherInput.dispatch('input');
description.value = '补充背景';
description.dispatch('input');
submit(modal);
const call = callsFor(runtime, '/report/submit')[0];
const payload = payloadFor(call);
assert.deepEqual(Object.keys(payload).sort(), ['category', 'reason', 'target_id', 'target_type']);
assert.deepEqual(payload, {
target_type: 'post',
target_id: 42,
category: '其他原因',
reason: '自定义原因\n\n补充背景',
});
call.resolve({ success: true, message: '已提交' });
await flushPromises();
});
test('preset reason ignores hidden custom text while including description in reason', () => {
const runtime = createRuntime();
const modal = runtime.openReport();
const otherInput = modal.shell.querySelector('[name="reason_other"]');
const description = modal.shell.querySelector('[name="report_desc"]');
otherInput.value = 'hidden custom text';
description.value = 'More context';
selectReason(modal, 1);
assert.equal(modal.shell.querySelector('.jio-report-other').hidden, true);
assert.equal(otherInput.disabled, true);
submit(modal);
const payload = payloadFor(callsFor(runtime, '/report/submit')[0]);
assert.deepEqual(payload, {
target_type: 'post',
target_id: 42,
category: '人身攻击',
reason: 'More context',
});
});
test('allows a preset-only report without copying the category into content', async () => {
const runtime = createRuntime();
const modal = runtime.openReport();
selectReason(modal, 0);
submit(modal);
const request = callsFor(runtime, '/report/submit')[0];
assert.deepEqual(payloadFor(request), {
target_type: 'post', target_id: 42, category: '垃圾信息', reason: '',
});
request.resolve({ success: true });
await flushPromises();
});
test('keeps a single other option and preserves explicitly entered category words in content', () => {
const runtime = createRuntime({ categories: [{ name: '其他原因' }, { name: '垃圾信息' }] });
const modal = runtime.openReport();
const radios = modal.shell.querySelectorAll('[name="report_reason"]');
assert.equal(radios.length, 2);
selectReason(modal, 0);
modal.shell.querySelector('[name="report_desc"]').value = '垃圾信息中包含了违规内容';
submit(modal);
assert.deepEqual(payloadFor(callsFor(runtime, '/report/submit')[0]), {
target_type: 'post', target_id: 42, category: '垃圾信息', reason: '垃圾信息中包含了违规内容',
});
});
test('omits all image controls when image evidence is disabled', () => {
const runtime = createRuntime({ imgEnabled: false });
const modal = runtime.openReport();
assert.equal(modal.shell.querySelector('[data-jio-report-file]'), null);
assert.equal(modal.shell.querySelector('[data-jio-report-add]'), null);
assert.equal(modal.shell.querySelector('[data-jio-report-images]'), null);
assert.equal(modal.shell.querySelector('[data-jio-report-image-count]'), null);
});
test('counts in-flight images once, accepts exactly three, and blocks submit until ready', async () => {
const runtime = createRuntime();
const modal = runtime.openReport();
selectReason(modal, 0);
const fileInput = modal.shell.querySelector('[data-jio-report-file]');
fileInput.files = [1, 2, 3].map((index) => ({ name: `evidence-${index}.png`, type: 'image/png' }));
fileInput.dispatch('change');
assert.equal(callsFor(runtime, '/report/upload-image').length, 3);
assert.equal(modal.shell.querySelector('[data-jio-report-image-count]').textContent, '3 / 3');
assert.equal(modal.shell.querySelector('[data-jio-report-submit]').disabled, true);
assert.equal(modal.shell.querySelector('[data-jio-report-status]').textContent, '图片正在上传,完成后即可提交');
submit(modal);
assert.equal(callsFor(runtime, '/report/submit').length, 0);
callsFor(runtime, '/report/upload-image').forEach((call, index) => call.resolve({
success: true,
data: { id: 800 + index, url: `https://evidence.test/${index + 1}.png` },
}));
await flushPromises();
assert.equal(modal.shell.querySelectorAll('.jio-report-image').length, 3);
assert.equal(modal.shell.querySelector('[data-jio-report-image-count]').textContent, '3 / 3');
assert.equal(modal.shell.querySelector('[data-jio-report-add]').disabled, true);
fileInput.files = [{ name: 'fourth.png', type: 'image/png' }];
fileInput.dispatch('change');
assert.equal(callsFor(runtime, '/report/upload-image').length, 3);
submit(modal);
const call = callsFor(runtime, '/report/submit')[0];
const payload = payloadFor(call);
for (const index of [1, 2, 3]) assert.ok(payload.reason.includes(`https://evidence.test/${index}.png`));
call.resolve({ success: true });
await flushPromises();
});
test('retries a failed upload and deletes its pending attachment when removed', async () => {
const runtime = createRuntime();
const modal = runtime.openReport();
const fileInput = modal.shell.querySelector('[data-jio-report-file]');
fileInput.files = [{ name: 'retry.png', type: 'image/png' }];
fileInput.dispatch('change');
callsFor(runtime, '/report/upload-image')[0].resolve({ success: false, message: 'Upload failed' });
await flushPromises();
const image = modal.shell.querySelector('.jio-report-image');
assert.equal(image.getAttribute('data-upload-state'), 'error');
assert.match(modal.shell.querySelector('[data-jio-report-error]').textContent, /Upload failed/);
image.querySelector('[data-jio-report-retry]').dispatch('click');
const retry = callsFor(runtime, '/report/upload-image')[1];
assert.ok(retry);
retry.resolve({ success: true, data: { id: 901, url: 'https://evidence.test/retry.png' } });
await flushPromises();
assert.equal(image.getAttribute('data-upload-state'), 'ready');
image.querySelector('[data-jio-report-remove]').dispatch('click');
const deletion = callsFor(runtime, '/upload/pending/901')[0];
assert.ok(deletion);
assert.equal(deletion.options.method, 'DELETE');
assert.equal(modal.shell.querySelectorAll('.jio-report-image').length, 0);
assert.equal(modal.shell.querySelector('[data-jio-report-image-count]').textContent, '0 / 3');
});
test('server failure preserves values, reenables controls, and allows a retry', async () => {
const runtime = createRuntime();
const modal = runtime.openReport();
selectReason(modal, 'other');
const form = modal.shell.querySelector('.jio-report-form');
const otherInput = form.querySelector('[name="reason_other"]');
const description = form.querySelector('[name="report_desc"]');
const submitButton = form.querySelector('[data-jio-report-submit]');
otherInput.value = 'A custom report reason';
description.value = 'Additional context';
submit(modal);
const failedRequest = callsFor(runtime, '/report/submit')[0];
assert.equal(submitButton.disabled, true);
assert.equal(otherInput.disabled, true);
failedRequest.resolve({ success: false, message: 'Server rejected report' });
await flushPromises();
assert.equal(otherInput.value, 'A custom report reason');
assert.equal(description.value, 'Additional context');
assert.equal(form.querySelector('[name="report_reason"]:checked').value, 'other');
assert.equal(otherInput.disabled, false);
assert.equal(description.disabled, false);
assert.equal(submitButton.disabled, false);
assert.equal(form.querySelector('[data-jio-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];
assert.equal(payloadFor(retry).reason, 'A custom report reason\n\nAdditional context');
retry.resolve({ success: true });
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('.jio-report-form');
const button = form.querySelector('[data-jio-report-submit]');
const status = form.querySelector('[data-jio-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();
selectReason(oldModal, 0);
submit(oldModal);
submit(oldModal);
assert.equal(callsFor(runtime, '/report/submit').length, 1);
const newModal = runtime.openReport({ target_type: 'comment', target_id: 88, target_name: 'Comment' });
runtime.modalRuntime.close('dismiss', oldModal.shell);
callsFor(runtime, '/report/submit')[0].resolve({ success: true });
await flushPromises();
assert.equal(runtime.activeModal, newModal.shell);
assert.equal(runtime.closeCalls.length, 1);
assert.equal(runtime.closeCalls[0].shell, oldModal.shell);
});
test('cleans pending attachments on close and when an upload completes after close', async () => {
const runtime = createRuntime();
const readyModal = runtime.openReport();
const readyFile = readyModal.shell.querySelector('[data-jio-report-file]');
readyFile.files = [{ name: 'ready.png', type: 'image/png' }];
readyFile.dispatch('change');
callsFor(runtime, '/report/upload-image')[0].resolve({
success: true,
data: { id: 1001, url: 'https://evidence.test/ready.png' },
});
await flushPromises();
runtime.modalRuntime.close('cancel', readyModal.shell);
const readyDeletion = callsFor(runtime, '/upload/pending/1001')[0];
assert.ok(readyDeletion);
assert.equal(readyDeletion.options.method, 'DELETE');
const pendingModal = runtime.openReport();
const pendingFile = pendingModal.shell.querySelector('[data-jio-report-file]');
pendingFile.files = [{ name: 'pending.png', type: 'image/png' }];
pendingFile.dispatch('change');
const pendingUpload = callsFor(runtime, '/report/upload-image')[1];
runtime.modalRuntime.close('cancel', pendingModal.shell);
assert.equal(callsFor(runtime, '/upload/pending/1002').length, 0);
pendingUpload.resolve({
success: true,
data: { id: 1002, url: 'https://evidence.test/pending.png' },
});
await flushPromises();
const lateDeletion = callsFor(runtime, '/upload/pending/1002')[0];
assert.ok(lateDeletion);
assert.equal(lateDeletion.options.method, 'DELETE');
});
test('keeps a live child modal open and marks the finished report after success', async () => {
const runtime = createRuntime();
const report = runtime.openReport();
selectReason(report, 0);
submit(report);
const request = callsFor(runtime, '/report/submit')[0];
const child = runtime.modalRuntime.open({ className: 'jio-child-modal', html: '<p>Child dialog</p>' });
request.resolve({ success: true });
await flushPromises();
const form = report.shell.querySelector('.jio-report-form');
assert.equal(runtime.activeModal, child);
assert.equal(runtime.closeCalls.length, 0);
assert.equal(form.getAttribute('aria-busy'), 'false');
assert.equal(form.querySelector('[data-jio-report-submit]').textContent, '举报已提交');
assert.ok(report.shell.querySelector('[data-jio-report-status]'));
});
test('retains claimed evidence on post-close success and deletes it on post-close failure', async () => {
const runtime = createRuntime();
async function submitClosedReport(id, result) {
const modal = runtime.openReport();
selectReason(modal, 0);
const fileInput = modal.shell.querySelector('[data-jio-report-file]');
fileInput.files = [{ name: `${id}.png`, type: 'image/png' }];
fileInput.dispatch('change');
callsFor(runtime, '/report/upload-image').at(-1).resolve({
success: true,
data: { id, url: `https://evidence.test/${id}.png` },
});
await flushPromises();
submit(modal);
const request = callsFor(runtime, '/report/submit').at(-1);
runtime.modalRuntime.close('dismiss', modal.shell);
request.resolve(result);
await flushPromises();
}
await submitClosedReport(1101, { success: true });
assert.equal(callsFor(runtime, '/upload/pending/1101').length, 0);
await submitClosedReport(1102, { success: false, message: 'Rejected' });
const deletion = callsFor(runtime, '/upload/pending/1102')[0];
assert.ok(deletion);
assert.equal(deletion.options.method, 'DELETE');
});