From 481f40378ae6ba9964eb844db7c63981ac5bb1e7 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 17 Sep 2026 13:19:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(payment):=20=E4=BF=AE=E5=A4=8D=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E5=8D=A1=E5=AF=86=E9=A1=B5=E3=80=8C=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E3=80=8D=E6=8C=89=E9=92=AE=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 数字输入(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 输出 ,不满足依赖时 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。 --- .gitignore | 2 + src/Admin/Native/PayChargeCards.php | 24 ++++- .../charge-cards-form-constraints.test.cjs | 102 ++++++++++++++++++ 3 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 tests/admin/charge-cards-form-constraints.test.cjs diff --git a/.gitignore b/.gitignore index 400eae2d..e12785d7 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/src/Admin/Native/PayChargeCards.php b/src/Admin/Native/PayChargeCards.php index 4cc815b3..1aa8b69b 100644 --- a/src/Admin/Native/PayChargeCards.php +++ b/src/Admin/Native/PayChargeCards.php @@ -346,7 +346,7 @@ class PayChargeCards { ); $this->field_row( __('面额', 'aksrt'), - '', + '', [ ['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 . ''; } + /** + * 数字输入(对齐参考主题 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 ' ' . esc_html($unit) . ''; + return '' + . ' ' . esc_html($unit) . ''; } private function page_notice(string $title, string $msg, string $type = 'success'): void { diff --git a/tests/admin/charge-cards-form-constraints.test.cjs b/tests/admin/charge-cards-form-constraints.test.cjs new file mode 100644 index 00000000..ba770b57 --- /dev/null +++ b/tests/admin/charge-cards-form-constraints.test.cjs @@ -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()` 输出 ,不满足依赖时 + * 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\);/); +});