fix(payment): 修复添加卡密页「确认提交」按钮静默失效
数字输入(auto_num / auto_price / auto_rand_number_limit / auto_rand_password_limit)此前被平铺成原生 HTML5 约束属性 (min/max/step),叠加两个问题后表现为按钮点了毫无反应: 1) 数值彼此不自洽:参考主题 CSF spinner 的 default=20 / min=1 / step=10 合法格点是 1,11,21…,默认值 20 当场就非法; auto_rand_number_limit 同理(默认 20、格点 1,6,11…)。 2) 这些行是条件显隐的(field_row 输出 <tr data-dep>,不满足依赖时 display:none)。提交时浏览器仍会校验隐藏控件,而隐藏控件无法聚焦 去弹提示气泡,于是 Chrome 只往控制台丢一句 "An invalid form control ... is not focusable" 便静默拒绝提交—— 不跳转、不报错、无 notice,用户看到的就是按钮坏了。 处置:数字输入改用 data-min/data-max/data-step + step="any" (与参考主题 CSF_Field_spinner::render() 语义一致),不再写任何原生 约束属性;auto_price 一并去掉 min="0",面额下限改由服务端夹取兜底 (max(0.0, ...)),避免「去掉客户端下限」变成放行负面额。 验证: - 无头 Chrome 探针:btn.click() 触发的 submit 事件 0 → 1, 参与校验控件 23 个、无效 3 个 → 0 个; - 服务端扫描页面 25 个 input:带原生约束的 0 个; - 服务端面额夹取:-5 拒绝、0.001 拒绝、12.5 正常生成 1 张; - 新增 tests/admin/charge-cards-form-constraints.test.cjs(4 例), 并做负向对照(重引入 min/step 后 2 pass / 2 fail); - tests/admin 四个套件合计 22 pass / 0 fail。
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
/.mimosa/
|
||||
|
||||
# Project planning, internal docs, and local-only development files
|
||||
/.aksrt-chrome-profile/
|
||||
/.specs/
|
||||
/docs/
|
||||
/tools/*
|
||||
@@ -60,6 +61,7 @@
|
||||
!/tests/admin/charge-cards-page-contract.test.cjs
|
||||
!/tests/admin/charge-cards-dependency.test.cjs
|
||||
!/tests/admin/charge-cards-pagination.test.cjs
|
||||
!/tests/admin/charge-cards-form-constraints.test.cjs
|
||||
!/tests/front-submit/
|
||||
/tests/front-submit/*
|
||||
!/tests/front-submit/video-cover-contract.test.cjs
|
||||
|
||||
@@ -346,7 +346,7 @@ class PayChargeCards {
|
||||
);
|
||||
$this->field_row(
|
||||
__('面额', 'aksrt'),
|
||||
'<input type="number" step="0.01" min="0" name="auto_price" value="0" />',
|
||||
'<input type="number" step="any" name="auto_price" value="0" />',
|
||||
[
|
||||
['key' => 'add_type', 'op' => '==', 'val' => 'auto'],
|
||||
['key' => 'type', 'op' => 'any', 'val' => 'balance,credits'],
|
||||
@@ -703,6 +703,9 @@ class PayChargeCards {
|
||||
case 'balance':
|
||||
case 'credits':
|
||||
$auto_price = isset($_REQUEST['auto_price']) ? floatval(round((float) $_REQUEST['auto_price'], 2)) : 0;
|
||||
// 面额不接受负数。数字输入不再写原生 min(见 spinner() 注释:原生约束会让条件隐藏行
|
||||
// 阻止表单提交),因此下限由服务端兜底。
|
||||
$auto_price = max(0.0, $auto_price);
|
||||
if (!$auto_price) {
|
||||
$this->page_notice(__('错误!', 'aksrt'), __('请输入卡密的面额', 'aksrt'), 'error');
|
||||
return;
|
||||
@@ -999,8 +1002,25 @@ class PayChargeCards {
|
||||
return $html . '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 数字输入(对齐参考主题 CSF spinner 字段的语义)。
|
||||
*
|
||||
* **min / max / step 只放在 data-* 上,绝不写成原生约束属性。**
|
||||
* 参考主题 `CSF_Field_spinner::render()` 渲染的就是
|
||||
* `data-min/data-max/data-step` + `step="any"`,原生 min/max/step 一个都不写。
|
||||
* 移植时把它们平铺成原生属性,会引入两类问题:
|
||||
* 1) 这些值彼此不自洽 —— 例如 default=20 / min=1 / step=10 的合法格点是 1,11,21…,
|
||||
* 写成原生 step 会让**默认值当场非法**(20 不在格点上);
|
||||
* 2) 本表单的行是条件显隐的。HTML5 约束校验会检查**隐藏**控件,而隐藏控件无法聚焦、
|
||||
* 浏览器弹不出提示气泡,于是直接**静默拒绝提交**(只有控制台一句
|
||||
* "An invalid form control ... is not focusable"),页面毫无反应 ——
|
||||
* 用户看到的就是「确认提交按钮不起作用」。
|
||||
* 取值范围由服务端兜底:`ChargeCard::generate_cards()` 内已对数量与位数做 max/min 夹取。
|
||||
*/
|
||||
private function spinner(string $name, int $value, int $min, int $max, int $step, string $unit): string {
|
||||
return '<input type="number" class="small-text" name="' . esc_attr($name) . '" value="' . esc_attr((string) $value) . '" min="' . esc_attr((string) $min) . '" max="' . esc_attr((string) $max) . '" step="' . esc_attr((string) $step) . '" /> <span class="description">' . esc_html($unit) . '</span>';
|
||||
return '<input type="number" class="small-text" step="any" name="' . esc_attr($name) . '" value="' . esc_attr((string) $value) . '"'
|
||||
. ' data-min="' . esc_attr((string) $min) . '" data-max="' . esc_attr((string) $max) . '" data-step="' . esc_attr((string) $step) . '" />'
|
||||
. ' <span class="description">' . esc_html($unit) . '</span>';
|
||||
}
|
||||
|
||||
private function page_notice(string $title, string $msg, string $type = 'success'): void {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 「卡密管理 → 添加卡密」表单约束契约。
|
||||
*
|
||||
* 背景(已修复的缺陷):
|
||||
* 表格里的数字输入(auto_num / auto_price / auto_rand_number_limit / auto_rand_password_limit)
|
||||
* 一度被平铺成原生 HTML5 约束属性(min / max / step)。两个后果叠加,表现为
|
||||
* **「确认提交」按钮点了毫无反应**:
|
||||
*
|
||||
* 1) 数值彼此不自洽。参考主题 codestar-framework 的 spinner 字段语义是
|
||||
* default=20 / min=1 / max=1000 / step=10,合法格点其实是 1,11,21…,
|
||||
* 于是**默认值 20 当场就非法**;auto_rand_number_limit 同理(默认 20、格点 1,6,11…)。
|
||||
* 2) 这些行是**条件显隐**的(`field_row()` 输出 <tr data-dep>,不满足依赖时
|
||||
* display:none)。提交瞬间浏览器仍会校验隐藏控件,而隐藏控件又无法聚焦去弹提示气泡,
|
||||
* 于是 Chrome 只往控制台丢一句 "An invalid form control with name=... is not focusable",
|
||||
* 然后**静默拒绝提交** —— 页面不跳转、不报错、无 notice,用户看到的就是按钮坏了。
|
||||
*
|
||||
* 处置:数字输入一律不写原生约束,改用 `data-min/data-max/data-step` + `step="any"`
|
||||
* (与参考主题 CSF_Field_spinner::render() 一致),范围由服务端夹取兜底。
|
||||
*
|
||||
* 本契约固化三件事:
|
||||
* - spinner() 只输出 data-* 约束与 step="any",绝不输出原生 min/max/step;
|
||||
* - 添加表单内不得出现任何带原生约束属性的输入(min/max/pattern/required、数值 step);
|
||||
* - 表单也不得用 novalidate 掩盖问题(那会连带关掉可见字段的原生提示)。
|
||||
* 另附服务端兜底断言:面额不接受负数。
|
||||
*/
|
||||
|
||||
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 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 spinner = slice('private function spinner(', 'private function page_notice(');
|
||||
const addForm = slice('private function render_add_form(', 'private function render_export_form(');
|
||||
const handleAdd = slice('private function handle_add(', 'private function handle_import(');
|
||||
|
||||
/** 判定一段源码里是否出现原生约束属性(min/max/pattern/required、数值 step)。 */
|
||||
const nativeConstraints = (src) => {
|
||||
const found = [];
|
||||
if (/\smin="/.test(src)) found.push('min');
|
||||
if (/\smax="/.test(src)) found.push('max');
|
||||
if (/\srequired\b/.test(src)) found.push('required');
|
||||
if (/\spattern="/.test(src)) found.push('pattern');
|
||||
const step = /\sstep="([^"]*)"/.exec(src);
|
||||
if (step && step[1] !== 'any') found.push(`step=${step[1]}`);
|
||||
return found;
|
||||
};
|
||||
|
||||
test('spinner() 只输出 data-* 约束与 step="any",不写原生 min/max/step', () => {
|
||||
// 正值:语义完整的 data-* 三元组 + step="any"。
|
||||
assert.match(spinner, /step="any"/, 'spinner 必须写 step="any"');
|
||||
assert.match(spinner, /data-min="[^"]*"/);
|
||||
assert.match(spinner, /data-max="[^"]*"/);
|
||||
assert.match(spinner, /data-step="[^"]*"/);
|
||||
|
||||
// 反值:原生约束属性一个都不能有(这是「按钮点不动」的直接成因)。
|
||||
assert.doesNotMatch(spinner, /\smin="/, 'spinner 不得写原生 min');
|
||||
assert.doesNotMatch(spinner, /\smax="/, 'spinner 不得写原生 max');
|
||||
assert.doesNotMatch(spinner, /\sstep="(?!any")/, 'spinner 的 step 只能是 any');
|
||||
assert.doesNotMatch(spinner, /\brequired\b/);
|
||||
});
|
||||
|
||||
test('整个后台页源码内没有任何输入携带原生约束属性', () => {
|
||||
// 输入标签常被字符串拼接拆开(render_add_form 里的 input 就是拼接出来的),
|
||||
// 逐串取标签并不可靠;直接对整段源码扫 HTML 属性形态,覆盖 spinner()/button_set()
|
||||
// 等所有渲染助手,避免只查 render_add_form 而漏掉助手内部生成的控件。
|
||||
const numbers = [...page.matchAll(/type="number"/g)].length;
|
||||
assert.ok(numbers >= 1, `页面应至少有一个数字输入,实际 ${numbers} 个`);
|
||||
|
||||
assert.deepEqual(
|
||||
nativeConstraints(page),
|
||||
[],
|
||||
'条件显隐行里的原生约束会让浏览器静默拒绝提交'
|
||||
);
|
||||
|
||||
// 数字输入仍须明确 step="any",否则浏览器会按默认 step=1 校验,同样可能拦下提交。
|
||||
const stepsAny = [...page.matchAll(/type="number"[^>]*step="any"/g)].length;
|
||||
assert.equal(stepsAny, numbers, '每个数字输入都要写 step="any"');
|
||||
});
|
||||
|
||||
test('表单不用 novalidate 掩盖问题,且提交按钮仍是原生 submit', () => {
|
||||
assert.doesNotMatch(addForm, /novalidate/i, '不得通过关闭校验来绕过,应去掉约束本身');
|
||||
assert.match(addForm, /submit_button\(\s*__\('确认提交',\s*'aksrt'\)/);
|
||||
assert.match(addForm, /wp_nonce_field\(self::NONCE\)/);
|
||||
});
|
||||
|
||||
test('面额下限由服务端兜底夹取(spinner 不再有原生 min)', () => {
|
||||
assert.match(handleAdd, /\$auto_price\s*=\s*max\(0\.0,\s*\$auto_price\);/);
|
||||
});
|
||||
Reference in New Issue
Block a user