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\);/);
+});