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

322 lines
15 KiB
JavaScript

'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
function runtime({ autoopen = false, empty = false } = {}) {
const ids = new Map();
const doc = { activeElement: null };
class Element {
constructor(attrs = {}) {
this.attrs = attrs;
this.listeners = {};
this.queries = new Map();
this.disabled = false;
this.hidden = false;
this.value = attrs.value || '';
this.name = attrs.name || '';
this.checked = false;
this.required = false;
this.validity = '';
this.textContent = '';
const classes = new Set();
this.classList = {
toggle(key, on) { if (on) classes.add(key); else classes.delete(key); },
contains(key) { return classes.has(key); },
};
}
getAttribute(name) { return this.attrs[name] ?? null; }
hasAttribute(name) { return Object.hasOwn(this.attrs, name); }
addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); }
fire(type, values = {}) {
const event = { target: this, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; }, ...values };
(this.listeners[type] || []).forEach((fn) => fn(event));
return event;
}
querySelector(selector) {
const result = this.queries.get(selector);
return Array.isArray(result) ? result[0] || null : result || null;
}
querySelectorAll(selector) {
const result = this.queries.get(selector);
return Array.isArray(result) ? result : result ? [result] : [];
}
map(selector, elements) { this.queries.set(selector, elements); return this; }
focus() { doc.activeElement = this; }
setCustomValidity(value) { this.validity = value; }
closest(selector) { return this.row; }
replaceChildren(child) { this.content = child; }
getBoundingClientRect() { return { left: 100, top: 100, right: 500, bottom: 500 }; }
}
const root = new Element();
const window = new Element();
doc.documentElement = new Element();
doc.getElementById = (id) => ids.get(id) || null;
doc.querySelector = (selector) => selector === '.jio-messages-admin' ? root : null;
const dialogs = [];
function dialog(id) {
const node = new Element();
node.open = false;
node.showModal = () => { node.previous = doc.activeElement; node.open = true; };
node.close = () => { node.open = false; node.previous?.focus(); node.fire('close'); };
node.cancel = () => { node.fire('cancel'); node.close(); };
node.closeButton = new Element();
node.closeButton.addEventListener('click', () => {});
node.map('[data-jio-dialog-close]', [node.closeButton]);
ids.set(id, node);
dialogs.push(node);
return node;
}
const compose = dialog('jio-msg-compose');
const detail = dialog('jio-msg-detail');
const confirm = dialog('jio-msg-confirm');
confirm.attrs = { 'data-i18n-delete-title': '删除消息', 'data-i18n-delete-text': '删除 {count} 条消息', 'data-i18n-delete-send': '确认删除' };
const accept = new Element();
const cancel = confirm.closeButton;
const confirmation = new Element();
confirm.map('[data-jio-msg-confirm]', accept).map('[data-jio-dialog-title]', new Element()).map('#jio-msg-confirm-text', confirmation).map('[data-jio-dialog-close][autofocus]', cancel);
const detailBody = new Element();
detail.map('[data-jio-msg-detail-body]', detailBody);
ids.set('jio-msg-detail-8', { content: { cloneNode: () => ({ text: 'Full <script> message' }) } });
root.map('.jio-msg-dialog', dialogs);
const originalQuery = root.querySelector.bind(root);
root.querySelector = (selector) => selector === 'dialog[open]' ? dialogs.find((node) => node.open) || null : originalQuery(selector);
function field(name, value = '', required = false) {
const node = new Element({ name, value });
node.required = required;
return node;
}
function form(elements, checks = []) {
const node = new Element();
node.elements = elements;
node.submissions = [];
node.reportValidity = () => Object.values(elements).every((field) => field.disabled || (!field.validity && (!field.required || field.value)));
node.requestSubmit = (submitter) => {
if (!node.reportValidity()) return;
const event = node.fire('submit', { submitter });
if (event.defaultPrevented) return;
const data = {};
Object.entries(elements).forEach(([key, field]) => { if (!field.disabled) data[key] = field.value; });
if (checks.length) data.ids = checks.filter((check) => check.checked && !check.disabled).map((check) => check.value);
node.submissions.push(data);
};
return node;
}
const checks = empty ? [] : [field('ids[]', '8'), field('ids[]', '9')];
checks.forEach((check) => { check.row = new Element(); });
const list = form({ bulk_action: field('bulk_action'), message_id: field('message_id') }, checks);
const checkAll = new Element();
const selection = new Element({ 'data-label': '已选 %d 条' });
const bulkRead = new Element({ 'data-jio-msg-action': 'mark_read' });
const bulkDelete = new Element({ 'data-jio-msg-action': 'delete' });
const rowRead = new Element({ 'data-jio-msg-action': 'mark_read', 'data-id': '8' });
const rowDelete = new Element({ 'data-jio-msg-action': 'delete', 'data-id': '8' });
const view = new Element({ 'data-jio-msg-view': '8' });
const actions = [bulkRead, bulkDelete, rowRead, rowDelete];
list.map('[data-jio-msg-check]', checks).map('[data-jio-msg-check-all]', checkAll).map('[data-jio-msg-selection]', selection)
.map('[data-jio-msg-action]:not([data-id])', [bulkRead, bulkDelete]).map('[data-jio-msg-action]', actions)
.map('button', [...actions, view]).map('button[data-id], button[data-jio-msg-view]', [rowRead, rowDelete, view]);
root.map('[data-jio-msg-list]', list).map('[data-jio-msg-view]', [view]);
const role = field('role', '', true);
role.options = [new Element({ 'data-count': '0' }), new Element({ 'data-count': '3', 'data-label': '订阅者' })];
role.options[0].disabled = true;
role.selectedIndex = 0;
const composeForm = form({
users: field('users', '', true), role, title: field('title'), content: field('content', '', true), target_url: field('target_url'),
confirmed_role: field('confirmed_role'), confirmed_count: field('confirmed_count'),
});
composeForm.attrs = {
'data-i18n-count-error': '最多 {count} 字', 'data-i18n-role-hint': '发送给 {count} 人',
'data-i18n-role-title': '确认群发', 'data-i18n-role-confirm': '{role}: {count} 人', 'data-i18n-role-send': '确认发送',
};
const usersRadio = new Element({ value: 'users' });
usersRadio.checked = true;
const roleRadio = new Element({ value: 'role' });
const usersPane = new Element({ 'data-jio-msg-pane': 'users' });
const rolePane = new Element({ 'data-jio-msg-pane': 'role' });
usersPane.map('input, select, textarea', [composeForm.elements.users]);
rolePane.map('input, select, textarea', [role]);
const titleCount = new Element();
const contentCount = new Element();
const roleHint = new Element();
const send = new Element();
composeForm.elements.title.attrs['data-count-limit'] = '191';
composeForm.elements.content.attrs['data-count-limit'] = '2000';
composeForm.map('[data-jio-msg-send]', send).map('[data-count-limit]', [composeForm.elements.title, composeForm.elements.content])
.map('[data-count-for="title"]', titleCount).map('[data-count-for="content"]', contentCount).map('[data-jio-msg-role-hint]', roleHint)
.map('[data-jio-msg-pane]', [usersPane, rolePane]).map('input[name="recipient_mode"]', [usersRadio, roleRadio]);
const formQuery = composeForm.querySelector.bind(composeForm);
composeForm.querySelector = (selector) => selector === 'input[name="recipient_mode"]:checked' ? (usersRadio.checked ? usersRadio : roleRadio) : formQuery(selector);
compose.map('[data-jio-msg-compose]', composeForm);
if (autoopen) compose.attrs['data-jio-dialog-autoopen'] = '';
const trigger = new Element();
root.map('[data-jio-dialog-open]', [trigger]);
vm.runInNewContext(fs.readFileSync(path.resolve(__dirname, '../../assets/js/admin-messages.js'), 'utf8'), { document: doc, window });
function chooseRole() {
usersRadio.checked = false;
roleRadio.checked = true;
roleRadio.fire('change');
role.value = 'subscriber';
role.selectedIndex = 1;
role.fire('change');
}
function select(index, checked = true) { checks[index].checked = checked; checks[index].fire('change'); }
function input(name, value) { composeForm.elements[name].value = value; composeForm.fire('input'); }
return { doc, window, compose, detail, confirm, trigger, accept, cancel, confirmation, detailBody, list, checks, checkAll, selection,
bulkRead, bulkDelete, rowRead, rowDelete, view, composeForm, role, usersPane, rolePane, roleHint, send, titleCount, contentCount, chooseRole, select, input };
}
test('selection count, indeterminate state and bulk availability stay in sync', () => {
const r = runtime();
assert.equal(r.bulkRead.disabled, true);
assert.equal(r.selection.textContent, '已选 0 条');
r.select(0);
assert.equal(r.checkAll.indeterminate, true);
assert.equal(r.bulkDelete.disabled, false);
assert.equal(r.checks[0].row.classList.contains('is-selected'), true);
r.checkAll.checked = true;
r.checkAll.fire('change');
assert.equal(r.selection.textContent, '已选 2 条');
assert.equal(r.checkAll.indeterminate, false);
assert.equal(runtime({ empty: true }).checkAll.disabled, true);
});
test('bulk deletion requires confirmation, cancel and Escape do not submit', () => {
const r = runtime();
r.select(0); r.select(1);
r.bulkDelete.fire('click');
assert.equal(r.confirm.open, true);
assert.match(r.confirmation.textContent, /2 条/);
assert.equal(r.list.submissions.length, 0);
r.cancel.fire('click');
r.accept.fire('click');
assert.equal(r.list.submissions.length, 0);
r.bulkDelete.fire('click'); r.confirm.cancel();
r.accept.fire('click');
assert.equal(r.list.submissions.length, 0);
r.bulkDelete.fire('click'); r.accept.fire('click');
assert.deepEqual(r.list.submissions[0], { bulk_action: 'delete', message_id: '', ids: ['8', '9'] });
assert.equal(r.bulkDelete.disabled, true);
assert.equal(r.checks[0].disabled, false);
r.bulkRead.fire('click');
assert.equal(r.list.submissions.length, 1);
});
test('single actions isolate one row even when other rows are selected', () => {
const r = runtime();
r.select(1);
r.rowDelete.fire('click');
assert.match(r.confirmation.textContent, /1 条/);
r.accept.fire('click');
assert.equal(r.list.submissions[0].message_id, '8');
const read = runtime();
read.rowRead.fire('click');
assert.equal(read.confirm.open, false);
assert.equal(read.list.submissions[0].bulk_action, 'mark_read');
assert.equal(read.list.submissions[0].message_id, '8');
});
test('details clone safe pre-rendered content without posting or changing read status', () => {
const r = runtime();
r.view.fire('click');
assert.equal(r.detail.open, true);
assert.equal(r.detailBody.content.text, 'Full <script> message');
assert.equal(r.list.submissions.length, 0);
});
test('recipient panes disable inactive inputs, switch required fields and show role counts', () => {
const r = runtime();
assert.equal(r.role.disabled, true);
assert.equal(r.usersPane.hidden, false);
r.chooseRole();
assert.equal(r.role.disabled, false);
assert.equal(r.composeForm.elements.users.disabled, true);
assert.equal(r.usersPane.hidden, true);
assert.equal(r.roleHint.textContent, '发送给 3 人');
});
test('Unicode counters count emoji once and block over-limit input', () => {
const r = runtime();
r.input('title', '😀你好');
assert.equal(r.titleCount.textContent, '3 / 191');
r.input('content', '😀'.repeat(2001));
assert.equal(r.contentCount.textContent, '2001 / 2000');
assert.equal(r.contentCount.classList.contains('is-over'), true);
assert.match(r.composeForm.elements.content.validity, /2000/);
r.input('content', '字'.repeat(2000));
assert.equal(r.composeForm.elements.content.validity, '');
});
test('role sending uses a nested confirmation with the confirmed role and count', () => {
const r = runtime();
r.chooseRole(); r.input('content', 'Hello');
r.trigger.fire('click');
r.composeForm.requestSubmit(r.send);
assert.equal(r.compose.open, true);
assert.equal(r.confirm.open, true);
assert.equal(r.confirmation.textContent, '订阅者: 3 人');
assert.equal(r.composeForm.submissions.length, 0);
r.cancel.fire('click');
assert.equal(r.compose.open, true);
assert.equal(r.composeForm.submissions.length, 0);
assert.equal(r.doc.documentElement.classList.contains('jio-msg-modal-open'), true);
r.composeForm.requestSubmit(r.send); r.accept.fire('click');
assert.equal(r.composeForm.submissions.length, 1);
assert.equal(r.composeForm.submissions[0].confirmed_role, 'subscriber');
assert.equal(r.composeForm.submissions[0].confirmed_count, '3');
assert.equal(r.send.disabled, true);
r.composeForm.requestSubmit();
assert.equal(r.composeForm.submissions.length, 1);
});
test('confirmation snapshots cannot approve changed content', () => {
const r = runtime();
r.chooseRole(); r.input('content', 'One'); r.composeForm.requestSubmit();
r.input('content', 'Two'); r.accept.fire('click');
assert.equal(r.composeForm.submissions.length, 0);
assert.equal(r.composeForm.elements.confirmed_role.value, '');
});
test('direct user sending does not ask for role confirmation; bfcache restores buttons', () => {
const r = runtime();
r.input('users', '7'); r.input('content', 'Hello'); r.composeForm.requestSubmit();
assert.equal(r.confirm.open, false);
assert.equal(r.composeForm.submissions.length, 1);
r.window.fire('pageshow');
assert.equal(r.send.disabled, false);
r.composeForm.requestSubmit();
assert.equal(r.composeForm.submissions.length, 2);
});
test('returning to the page clears the previous mutation target and restores selection controls', () => {
const r = runtime();
r.select(1); r.rowRead.fire('click');
assert.equal(r.list.elements.message_id.value, '8');
r.window.fire('pageshow');
assert.equal(r.list.elements.message_id.value, '');
assert.equal(r.list.elements.bulk_action.value, '');
assert.equal(r.rowRead.disabled, false);
assert.equal(r.bulkRead.disabled, false);
r.list.requestSubmit();
assert.equal(r.list.submissions.length, 1);
});
test('failed sends reopen compose and backdrop clicks must start outside the dialog', () => {
const r = runtime({ autoopen: true });
assert.equal(r.compose.open, true);
r.compose.fire('pointerdown', { clientX: 120, clientY: 120 });
r.compose.fire('click', { clientX: 120, clientY: 120 });
assert.equal(r.compose.open, true);
r.compose.fire('pointerdown', { clientX: 0, clientY: 0 });
r.compose.fire('click', { clientX: 0, clientY: 0 });
assert.equal(r.compose.open, false);
assert.equal(r.doc.documentElement.classList.contains('jio-msg-modal-open'), false);
});