fix(payment): 添加卡密页提示块自带依赖状态,脱离所在行也不再铺开

用户复报「四条导入提示仍然全部铺开」。其 DevTools 实测给出关键判据:
同一页面上 formClass=1 / submsg=4 / depRows=17 与线上一致,但四条提示的
父级 <tr> 为 null、computed display 为 block —— 也就是说提示块虽在 DOM 中,
却不在任何表格行内。此时依赖状态只挂在 <tr> 上,隐藏与切换都无处落地,
于是四条提示永久可见、切换卡密类型/添加方式也没有反应。

修复:把 data-dep 与初始隐藏状态同时挂到内层提示块上,求值器改为
「谁带 data-dep 就管谁」,行与提示块各自落自己的 display。

变更(src/Admin/Native/PayChargeCards.php):
- submessage_row():<tr> 与内层 <div class="aksrt-submsg"> 共用同一份
  data-dep;初始隐藏状态合并进提示块原有的 margin/padding 内联样式,
  不产生重复的 style 属性。原「只给 <tr> 加 style」的写法保留给行本身。
- evalDeps():选择器由 '.aksrt-cardpass-form tr[data-dep]' 放宽为
  '.aksrt-cardpass-form [data-dep]',并改用 el.style.display 落值。

测试(tests/admin/charge-cards-dependency.test.cjs):
- DOM 桩按真实结构为每条提示建「行 + 提示块」两个元素,新增用例
  「提示块与所在行同层落 display」断言两层始终同状态(默认 8 个元素全隐藏、
  命中依赖时仅该提示的两层可见、切回默认两层同时收起)。
- 契约用例新增三条静态断言:提示块必须自带 data-dep、必须自带初始隐藏状态、
  求值选择器必须同时覆盖行与提示块。
- 负向验证:把提示块的 data-dep 去掉后跑测试 → pass 5 / fail 1,护栏生效。

验证:
- node --test 两个 admin 契约测试 10/10 通过;php -l 无语法错误;
- 线上抓取:tr_total=19 / tr_with_dep=17 / div_with_dep=4 / display_none=18;
  四条提示的 div 均带 data-dep="…" 与 display:none。
- 真实浏览器模拟「提示块被搬出所在行」(foster-parent 到 <table> 之前)后:
  默认态四条全部 none;切到「会员兑换 + 导入卡密」时,只有已脱离行的
  「导入会员兑换卡密」变为可见(ownStyle 中的 display:none 被移除),
  其余三条维持 none;切回默认四条全部收 none。
This commit is contained in:
2026-09-17 11:41:05 +08:00
parent 5243bbf0d4
commit 670e271ff6
2 changed files with 56 additions and 12 deletions
+44 -7
View File
@@ -120,17 +120,22 @@ function createForm() {
addInput('only_pass_s', 'checkbox', '1', false);
addInput('vip_exchange[time]', 'text', '', false);
const rows = addRows.map((row) => {
// 每个依赖行对应一个 <tr>;提示行(submessage_row)还会把同一份 data-dep 与隐藏状态
// 额外挂在 <td> 内的提示块上,因此这里也为它建第二个元素(模拟提示块脱离行的情况)。
const rows = [];
for (const row of addRows) {
const json = JSON.stringify(row.deps);
return { label: row.label, deps: row.deps, el: { style: {}, getAttribute: (n) => (n === 'data-dep' ? json : null) } };
});
const makeEl = () => ({ style: {}, getAttribute: (n) => (n === 'data-dep' ? json : null) });
rows.push({ label: row.label, deps: row.deps, el: makeEl() });
if (HINTS.includes(row.label)) rows.push({ label: row.label, deps: row.deps, el: makeEl() });
}
const handlers = {};
const document = {
readyState: 'loading',
querySelectorAll(sel) {
if (sel === '.aksrt-cardpass-form [name]') return inputs;
if (sel === '.aksrt-cardpass-form tr[data-dep]') return rows.map((row) => row.el);
if (sel === '.aksrt-cardpass-form [data-dep]') return rows.map((row) => row.el);
return [];
},
addEventListener(type, fn) { (handlers[type] = handlers[type] || []).push(fn); },
@@ -147,10 +152,14 @@ function createForm() {
});
fire('DOMContentLoaded');
const visible = () => rows.filter((row) => row.el.style.display !== 'none').map((row) => row.label);
const visible = () => [...new Set(rows.filter((row) => row.el.style.display !== 'none').map((row) => row.label))];
/** 提示的「行 + 提示块」两层各自的显示状态,用于验证两层不会分叉。 */
const hintLayers = () => rows
.filter((row) => HINTS.includes(row.label))
.map((row) => row.label + ':' + (row.el.style.display === 'none' ? 'off' : 'on'));
return {
setRadio, setChecked, visible,
setRadio, setChecked, visible, hintLayers,
change() { fire('change', { target: { closest: () => ({}) } }); },
hintVisible: () => visible().filter((label) => HINTS.includes(label)),
count: rows.length,
@@ -194,6 +203,11 @@ test('两种行渲染都接入服务端预隐藏,避免首帧铺开', () => {
assert.match(page, /private function field_row\([\s\S]*?\$hidden\s*=\s*\(\$deps && !\$this->deps_visible\(\$deps\)\)/);
assert.match(page, /private function submessage_row\([\s\S]*?\$hidden\s*=\s*\$this->deps_visible\(\$deps\)/);
assert.match(script, /document\.addEventListener\('DOMContentLoaded', evalDeps\)/);
// 提示块必须自带 data-dep 与初始隐藏状态:脱离所在行时(表格解析器把非法节点
// foster-parent 到 <table> 之前)仍能被求值并保持隐藏。
assert.match(page, /<td colspan="2"><div'\s*\.\s*\$dep_attr/, '提示块必须自带 data-dep');
assert.match(page, /style="margin:6px 0;padding:6px 12px;'\s*\.\s*\$hidden/, '提示块必须自带初始隐藏状态');
assert.match(script, /querySelectorAll\('\.aksrt-cardpass-form \[data-dep\]'\)/, '求值选择器必须同时覆盖行与提示块');
});
test('行内脚本不再保留解析期的空操作求值(死代码)', () => {
@@ -203,7 +217,7 @@ test('行内脚本不再保留解析期的空操作求值(死代码)', () =>
test('添加卡密:导入提示跟随「卡密类型 / 添加方式」切换,且不同时铺开', () => {
const form = createForm();
assert.equal(form.count, 17, '添加卡密的依赖行数量发生变化,请同步本测试');
assert.equal(form.count, 21, '添加卡密的依赖元素数量发生变化(13 行 + 4 提示块×2),请同步本测试');
assert.deepEqual(form.hintVisible(), [], '默认(系统自动生成)下不应显示任何导入提示');
assert.ok(form.visible().includes('生成数量'));
assert.ok(!form.visible().includes('卡密数据'));
@@ -244,3 +258,26 @@ test('添加卡密:导入提示跟随「卡密类型 / 添加方式」切换
form.change();
assert.ok(form.visible().includes('自定义卡号位数'), '高级选项勾选后应显示自定义位数');
});
test('提示块与所在行同层落 display,提示块脱离行也不会失效', () => {
const form = createForm();
const HINT_LABELS = ['导入卡密', '导入会员兑换卡密', '自定义卡密导入', '自定义卡密导入(单密码模式)'];
const layersFor = (labels) => HINT_LABELS.flatMap((l) => [l + ':' + (labels.includes(l) ? 'on' : 'off'), l + ':' + (labels.includes(l) ? 'on' : 'off')]);
// 默认态(系统自动生成 + 余额充值):两层共 8 个元素必须全部隐藏。
assert.deepEqual(
form.hintLayers(),
HINT_LABELS.flatMap((l) => [l + ':off', l + ':off']),
'默认态下每条提示的行与提示块都必须隐藏'
);
form.setRadio('type', 'vip');
form.setRadio('add_type', 'import');
form.change();
assert.deepEqual(form.hintLayers(), layersFor(['导入会员兑换卡密']), '只有命中依赖的那条提示的两层应当显示');
form.setRadio('add_type', 'auto');
form.setRadio('type', 'balance');
form.change();
assert.deepEqual(form.hintLayers(), HINT_LABELS.flatMap((l) => [l + ':off', l + ':off']), '切回默认后两层必须同时收起');
});