fix(payment): 修复卡密管理分页:页码夹取、显式基准 URL、补齐每页条数入口

- render_pagenavi():页码先夹到 [1, total_pages],禁用状态由 $on_first / $on_last
  一次导出。旧写法是一串 if/elseif,页码越界时三个分支一个都不命中,
  上一页与下一页同时停在 '#':既不打 disabled 也点不动。
- 翻页链接改用显式基准 URL(page_url + 当前筛选参数),不再依赖
  add_query_arg() 默认取 $_SERVER['REQUEST_URI'] 的行为;
  status / type / other / s / orderby / desc / ice_perpage 随链接带走。
- render_list():页码越界(手改 URL、或筛选后总数变小)时回退到最后一页重查,
  消除「空白表格 + 一个分页控件都没有」的死胡同。
- 新增「每页显示」选择器(ice_perpage)。该参数此前只有服务端读取、界面上没有
  任何入口能改,条数小于默认每页条数时永远只有一页,分页条恒为 disabled。
- 禁用按钮加 pointer-events:none,避免点空还滚回页首。
- 新增 tests/admin/charge-cards-pagination.test.cjs(6 条契约)。

验证:容器内真实渲染 7 组 URL(默认 / 每页 5 / 第 2 页 / paged=2 越界 /
paged=99 越界 / 带 status 筛选 / 非预设每页 7),无头 Chrome 截图核对版式;
node --test 三套 18/18 通过;负向对照(还原旧写法)按要求失败 2 条;php -l 通过。
This commit is contained in:
2026-09-17 12:45:22 +08:00
parent 1e5286d36b
commit 0e7b888277
3 changed files with 242 additions and 35 deletions
@@ -0,0 +1,109 @@
'use strict';
/**
* 「卡密管理」分页契约。
*
* 背景(已修复的缺陷):
* 1) 翻页链接此前写作 add_query_arg('paged', N) —— 只有两个参数时 WordPress 会落到
* $_SERVER['REQUEST_URI'],链接绑在当前请求上,随请求形态漂移,列条件也容易丢。
* 2) render_pagenavi() 的 URL 推导是一串 if/elseif。页码越界时(例如 per_page 缩小后
* URL 里还留着 paged=2,而总数只剩 1 页)三个分支一个都不命中,上一页/下一页
* 同时停在 '#':按钮既不打 disabled 也点不动,还停在「第 1 页」状态无法自洽。
* 极端情况下 offset 落到数据之外,表格直接变空态并且连分页条都不输出,只能手改 URL 退出。
* 3) ice_perpage 只在服务端被读取,界面上没有任何入口能改每页条数。条数小于默认每页条数时
* 永远只有一页,四个按钮恒为 disabled —— 看上去就是「分页是坏的」。
*
* 本契约固化三件事:
* - 页码先夹到 [1, total_pages],禁用状态由夹取后的页码推导;
* - 翻页链接基于显式基准 URL(page_url + 当前筛选参数),不再依赖 REQUEST_URI;
* - 列表页提供「每页显示」选择器(name=ice_perpage),并把筛选参数带进翻页链接。
*/
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 slice = (from, to) => {
const start = page.indexOf(from);
const end = page.indexOf(to);
if (start === -1 || end === -1 || end <= start) {
throw new Error(`切片失败:${from} → ${to}`);
}
return page.slice(start, end);
};
const list = slice('private function render_list(', 'private function render_add_form(');
const args = slice('private function list_query_args(', 'private function per_page_selector(');
const perpage = slice('private function per_page_selector(', 'private function render_pagenavi(');
const pager = slice('private function render_pagenavi(', 'private function print_assets(');
test('翻页链接使用显式基准 URL,不再依赖 REQUEST_URI', () => {
// 反面写法:只有两个参数的 add_query_arg —— 会退回到 $_SERVER['REQUEST_URI']。
assert.doesNotMatch(page, /add_query_arg\('paged',\s*[^,)]+\)/);
assert.match(pager, /\$base\s*=\s*\$query_args\s*\?\s*add_query_arg\(\$query_args,\s*\$this->page_url\)\s*:\s*\$this->page_url;/);
assert.match(pager, /add_query_arg\('paged',\s*\$page,\s*\$base\)/);
assert.equal(count(pager, /add_query_arg\('paged'/g), 1, '翻页 URL 只应有一个构造点');
});
test('页码夹取到有效范围,禁用状态由夹取后的页码推导', () => {
assert.match(pager, /\$total_pages\s*=\s*max\(1,\s*\(int\)\s*ceil\(\$total_count \/ \$number_per_page\)\)/);
assert.match(pager, /\$current_page\s*=\s*min\(max\(1,\s*\(int\)\s*\(\$_GET\['paged'\] \?\? 1\)\),\s*\$total_pages\)/);
// 越界时 if/elseif 全不命中的分支结构不得回归。
assert.match(pager, /\$on_first\s*=\s*\$current_page <= 1;/);
assert.match(pager, /\$on_last\s*=\s*\$current_page >= \$total_pages;/);
assert.equal(count(pager, /\$on_first \? '#' :/g), 2, '第一页/上一页都要按 on_first 落到 #');
assert.equal(count(pager, /\$on_last \? '#' :/g), 2, '下一页/最后一页都要按 on_last 落到 #');
assert.doesNotMatch(pager, /\$current_page == \$total_pages\b/);
assert.doesNotMatch(pager, /\$prev_page_url\s*=\s*'#';/);
});
test('越界页码回退到最后一页,不再出现无分页条的死胡同', () => {
assert.match(list, /\$max_page\s*=\s*max\(1,\s*\(int\)\s*\(\$result\['max_pages'\] \?\? 1\)\)/);
assert.match(list, /if \(!\$items && \$count_all > 0 && \$paged > \$max_page\)/);
assert.match(list, /\$paged\s*=\s*\$max_page;/);
// 回退之后必须重查,否则表格依旧是空的。
assert.equal(count(list, /\$svc->get_list\(/g), 2);
// 分页条必须在空态分支之后渲染,否则空态 return 会把分页条一起吞掉。
assert.ok(list.indexOf('render_pagenavi(') > list.indexOf('未找到对应内容'), '分页渲染必须晚于空态处理');
});
test('每页条数由界面提供,且与筛选参数保持一致', () => {
assert.match(page, /public const DEFAULT_PER_PAGE = 30;/);
assert.match(list, /\(int\) \(\$_REQUEST\['ice_perpage'\] \?\? self::DEFAULT_PER_PAGE\)/);
assert.match(perpage, /method="get"/);
assert.match(perpage, /name="ice_perpage"/);
assert.match(perpage, /name="page" value="' \. esc_attr\(self::PAGE_SLUG\)/);
assert.match(perpage, /selected\(\$per_page, \$choice, false\)/);
assert.match(perpage, /onchange="this\.form\.submit\(\);"/);
// 非预设值也要出现在下拉里,否则当前状态无法回显、一提交就被重置。
assert.match(perpage, /if \(!in_array\(\$per_page, \$choices, true\)\) \{/);
assert.match(list, /\$query_args = \$this->list_query_args\(/);
assert.match(list, /per_page_selector\(\$per_page, \$query_args\)/);
assert.match(list, /render_pagenavi\(\$count_all, \$per_page, \$query_args\)/);
});
test('筛选参数白名单化拼装,排序与每页条数随链接带走', () => {
for (const key of ['status', 'type', 'other', 's', 'orderby', 'desc', 'ice_perpage']) {
assert.match(args, new RegExp(`\\$args\\['${key}'\\] =`), `缺少 ${key}`);
}
// 默认值不进 URL,避免链接里塞满无意义参数。
assert.match(args, /\$orderby !== 'modified_time'/);
assert.match(args, /\$order === 'ASC'/);
assert.match(args, /\$per_page !== self::DEFAULT_PER_PAGE/);
// paged 由 render_pagenavi 单独追加,不能混进基准参数里(否则会被自己覆盖)。
assert.doesNotMatch(args, /\$args\['paged'\]/);
});
test('禁用的翻页按钮不可点击,避免点空还滚回页首', () => {
assert.match(page, /\.tablenav-pages a\.disabled \{ pointer-events: none; \}/);
});