fix(payment): 卡密管理页标题按钮单点输出并修正排序箭头下沉

- 标题右侧按钮原在页面头部与列表渲染函数各输出一次(实际渲染 3 个
  page-title-action,「添加卡密」重复出现),改为只在页面头部的 tab switch 中
  拼一次;render_list() 收为 render_list(string $s),只渲染过滤链接与表格,
  搜索词在页面头部单次解析后传入,搜索提示仍按参考主题跟在按钮之后。
- .orderby-but 恢复参考主题写法(position: relative,不设 display)。
  箭头是绝对定位元素且不写 top/left,位置取自容器的静态位置;容器一旦变成零
  高度 inline-block,基线会退化为自身下边缘并与文字基线对齐,整组箭头下沉一
  个字高。同时补回参考主题的 padding-right: 1em,并加注释锁定该约束。
- 清理死代码:行循环中 custom 分支的 $type_html 赋值总被下方类型链覆盖,已删除。
- 新增 tests/admin/charge-cards-page-contract.test.cjs(4 例)作为回归护栏,
  并按 .gitignore 既有白名单惯例放开该文件。
This commit is contained in:
2026-09-17 10:14:19 +08:00
parent 282a12a90b
commit 9fffe7b590
3 changed files with 110 additions and 26 deletions
@@ -0,0 +1,76 @@
'use strict';
/**
* 「卡密管理」后台页契约:
* 1) 标题右侧按钮(page-title-action)由页面头部唯一输出,列表渲染不得再输出一次;
* 2) 表头排序箭头宿主(.orderby-but)必须保持 inline,不能改成零高度 inline-block,
* 否则绝对定位箭头的静态位置会落到文字基线上,整组箭头下沉一行字高。
*/
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 page = fs.readFileSync(path.join(root, 'src/Admin/Native/PayChargeCards.php'), 'utf8');
const count = (src, pattern) => (src.match(pattern) || []).length;
const header = page.slice(
page.indexOf('public function render_charge_cards_page('),
page.indexOf('private function render_list(')
);
const list = page.slice(
page.indexOf('private function render_list('),
page.indexOf('private function render_add_form(')
);
test('标题右侧按钮只在页面头部输出一次', () => {
assert.ok(header.length > 0 && list.length > 0);
// 添加/导出两个 tab 的「返回列表」各一次,列表页的「添加卡密 / 导出卡密」各一次。
assert.equal(count(header, /class="page-title-action"/g), 4);
assert.doesNotMatch(list, /class="page-title-action"/);
assert.equal(count(page, /class="page-title-action"/g), count(header, /class="page-title-action"/g));
// 列表页的两个入口按钮各自只有一处定义。
assert.equal(count(page, /add_query_arg\('tab', 'add'/g), 1);
assert.equal(count(page, /add_query_arg\('tab', 'export'/g), 1);
assert.match(header, /esc_html__\('添加卡密', 'aksrt'\)/);
assert.match(header, /esc_html__\('导出卡密', 'aksrt'\)/);
});
test('搜索提示仍由页面头部输出且列表不再自建头部', () => {
assert.equal(count(header, /update-nag/g), 1);
assert.match(header, /搜索 “%s” 的内容/);
assert.doesNotMatch(list, /update-nag/);
assert.doesNotMatch(list, /\$head_but/);
// 搜索词在页面头部解析后传入列表,避免两处各解析一次。
assert.match(page, /render_list\(\$s\)/);
assert.match(list, /private function render_list\(string \$s\): void/);
});
test('排序箭头宿主保持 inline,不使用零高度 inline-block 定位', () => {
const host = page.match(/\.orderby-but \{[^}]*\}/);
assert.ok(host, '.orderby-but 规则缺失');
assert.match(host[0], /position:\s*relative/);
assert.doesNotMatch(host[0], /display\s*:/);
assert.doesNotMatch(host[0], /margin-left/);
const arrow = page.match(/\.orderby-but > a \{[^}]*\}/);
assert.ok(arrow, '.orderby-but > a 规则缺失');
assert.match(arrow[0], /position:\s*absolute/);
assert.match(arrow[0], /padding-right:\s*1em/);
assert.match(page, /\.orderby-but > a \+ a \{ transform: translateY\(6px\); \}/);
assert.match(header + list, /<span class="orderby-but">/);
});
test('类型列链接每种类型只生成一次', () => {
assert.equal(count(page, /\$type_html = '<a/g), 4);
assert.equal(count(page, /add_query_arg\('type', 'custom'/g), 1);
assert.match(list, /if \(\$msg\['type'\] !== 'custom'\) \{/);
assert.doesNotMatch(list, /\$msg\['type'\] === 'custom'\)\s*\{\s*\$type_html/);
});