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>
156 lines
6.5 KiB
JavaScript
156 lines
6.5 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* JioField 的 group / repeater 行身份稳定性契约。
|
|
*
|
|
* 背景:inline 展开模式用 vuedraggable 渲染行,vuedraggable 的 computeNodes 会把
|
|
* `node.key = getKey(element)`,而行 key 由 JioField 的 repeaterDragKey 提供。服务端
|
|
* 历史行没有 _id,key 来自以「对象身份」为键的 WeakMap。
|
|
*
|
|
* 关键坑:行数据是 reactive 的。updateRepeaterItem 一旦把行展开成一个新对象,渲染时
|
|
* 读到的是那个新对象的又一个 reactive 代理——身份再次改变,WeakMap 认不出它便生成
|
|
* 新 key,整行随之重挂载、输入框失焦,表现为「打一个字就退出编辑」。所以必须原地写,
|
|
* 让行身份跨渲染保持不变。本测试直接跑这两个真实函数来锁定该不变量。
|
|
*/
|
|
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const test = require('node:test');
|
|
|
|
const root = path.resolve(__dirname, '..', '..');
|
|
const fieldVue = fs.readFileSync(
|
|
path.join(root, 'assets', 'admin', 'src', 'components', 'form', 'JioField.vue'),
|
|
'utf8'
|
|
);
|
|
|
|
/**
|
|
* 模拟 Vue 的 reactive():每个原始对象只包一次代理并缓存,之后读多少次都返回同一个
|
|
* 代理。这正是让 WeakMap 身份在渲染与更新之间保持一致(或不一致)的原因。
|
|
*/
|
|
const proxyCache = new WeakMap();
|
|
const proxySet = new WeakSet();
|
|
function reactiveOf(value) {
|
|
if (!value || typeof value !== 'object' || proxySet.has(value)) return value;
|
|
let proxy = proxyCache.get(value);
|
|
if (!proxy) {
|
|
proxy = new Proxy(value, {
|
|
get(target, key, receiver) { return Reflect.get(target, key, receiver); },
|
|
set(target, key, next) { target[key] = next; return true; },
|
|
});
|
|
proxyCache.set(value, proxy);
|
|
proxySet.add(proxy);
|
|
}
|
|
return proxy;
|
|
}
|
|
|
|
/** 取一整行声明(WeakMap / 种子计数器都没有花括号体)。 */
|
|
function declaration(source, marker) {
|
|
const start = source.indexOf(marker);
|
|
assert.notStrictEqual(start, -1, `JioField.vue 缺少声明: ${marker}`);
|
|
const end = source.indexOf('\n', start);
|
|
return source.slice(start, end === -1 ? undefined : end);
|
|
}
|
|
|
|
/** 按花括号配平切出一个顶层函数(这些函数体内没有字符串/注释里的孤立花括号)。 */
|
|
function functionBlock(source, marker) {
|
|
const start = source.indexOf(marker);
|
|
assert.notStrictEqual(start, -1, `JioField.vue 缺少函数: ${marker}`);
|
|
const open = source.indexOf('{', start);
|
|
assert.notStrictEqual(open, -1, `函数没有函数体: ${marker}`);
|
|
let depth = 0;
|
|
for (let i = open; i < source.length; i++) {
|
|
if (source[i] === '{') depth++;
|
|
else if (source[i] === '}') {
|
|
depth--;
|
|
if (depth === 0) return source.slice(start, i + 1);
|
|
}
|
|
}
|
|
assert.fail(`花括号不配平: ${marker}`);
|
|
}
|
|
|
|
const prelude = [
|
|
declaration(fieldVue, 'const dragRowKeys = new WeakMap()'),
|
|
declaration(fieldVue, 'let dragRowKeySeed = 0'),
|
|
functionBlock(fieldVue, 'function repeaterDragKey(item)'),
|
|
functionBlock(fieldVue, 'function updateRepeaterItem(index, fieldKey, value)'),
|
|
].join('\n');
|
|
|
|
/** 用真实的 props / emit 形状驱动这两个函数。 */
|
|
function makeField(rows) {
|
|
const emitted = [];
|
|
// props.modelValue 是 reactive 视图,正如真实组件收到的那样。
|
|
const props = { modelValue: rows.map(reactiveOf) };
|
|
const emit = (event, value) => emitted.push({ event, value });
|
|
const factory = new Function('props', 'emit', `${prelude}\nreturn { repeaterDragKey, updateRepeaterItem };`);
|
|
const api = factory(props, emit);
|
|
// 父组件把 emit 出来的数组写回 reactive 树,读出来时行又被包成代理。
|
|
const roundTrip = () => {
|
|
props.modelValue = emitted[emitted.length - 1].value.map(reactiveOf);
|
|
};
|
|
return { api, props, emitted, roundTrip };
|
|
}
|
|
|
|
test('连续输入期间行身份不变,drag key 保持稳定', () => {
|
|
// 服务端历史行:没有 _id,key 只能由 WeakMap 派生。
|
|
const serverRows = [
|
|
{ points: 500, level: 1, time: 30, unit: 'day' },
|
|
{ points: 5000, level: 1, time: 'Permanent' },
|
|
];
|
|
const { api, props, emitted, roundTrip } = makeField(serverRows);
|
|
const firstKey = api.repeaterDragKey(props.modelValue[0]);
|
|
const secondKey = api.repeaterDragKey(props.modelValue[1]);
|
|
assert.notStrictEqual(firstKey, secondKey, '两行必须各有一个 key');
|
|
const firstRow = props.modelValue[0];
|
|
|
|
// 模拟逐字输入:每次 emit 后父组件把新数组回填 props.modelValue。
|
|
let rows = serverRows;
|
|
for (const value of ['5', '50', '500']) {
|
|
api.updateRepeaterItem(0, 'points', value);
|
|
rows = emitted[emitted.length - 1].value;
|
|
roundTrip();
|
|
}
|
|
|
|
assert.strictEqual(rows[0].points, '500', '输入值应写进行内');
|
|
// 核心不变量:行对象身份必须跨渲染保持不变,否则 WeakMap 会发新 key。
|
|
assert.strictEqual(props.modelValue[0], firstRow, '行身份必须在编辑期间保持不变');
|
|
assert.strictEqual(
|
|
api.repeaterDragKey(props.modelValue[0]),
|
|
firstKey,
|
|
'行 key 必须在编辑期间保持稳定,否则整行重挂载、输入框失焦'
|
|
);
|
|
assert.strictEqual(api.repeaterDragKey(props.modelValue[1]), secondKey, '未编辑的行 key 不应变');
|
|
});
|
|
|
|
test('emit 出去的是新数组,父组件仍会重渲染', () => {
|
|
const { api, props, emitted, roundTrip } = makeField([{ points: 500 }]);
|
|
const before = props.modelValue;
|
|
api.updateRepeaterItem(0, 'points', '9');
|
|
const after = emitted[emitted.length - 1].value;
|
|
assert.notStrictEqual(after, before, '必须发出新数组,否则父组件不会重渲染');
|
|
roundTrip();
|
|
assert.strictEqual(props.modelValue[0].points, '9');
|
|
});
|
|
|
|
test('行不是对象时退化为新建,不抛错', () => {
|
|
const { api, props, roundTrip } = makeField([]);
|
|
api.updateRepeaterItem(0, 'points', '7');
|
|
roundTrip();
|
|
assert.strictEqual(props.modelValue.length, 1);
|
|
assert.strictEqual(props.modelValue[0].points, '7');
|
|
});
|
|
|
|
test('带 _id 的行仍以 _id 作为 key', () => {
|
|
const rows = [{ _id: 'rpt_1', points: 100 }, { _id: 'rpt_2', points: 200 }];
|
|
const { api, props } = makeField(rows);
|
|
assert.strictEqual(api.repeaterDragKey(props.modelValue[0]), 'rpt_1');
|
|
assert.strictEqual(api.repeaterDragKey(props.modelValue[1]), 'rpt_2');
|
|
});
|
|
|
|
test('新增行获得独立 key,不与既有行冲突', () => {
|
|
const { api, props } = makeField([{ points: 500 }, { points: 5000 }]);
|
|
const keys = new Set(props.modelValue.map((row) => api.repeaterDragKey(row)));
|
|
assert.strictEqual(keys.size, 2);
|
|
assert.ok(!keys.has(api.repeaterDragKey({ points: 0 })), '新行必须拿到全新 key');
|
|
});
|