修复:英文标识留空真能自动生成;表单必填前端就地提示不刷新 (#1)

## 问题 1:文案说「留空自动生成」,实际却报错
界面上英文标识写的是「留空自动生成」,且不带红星;但纯中文名
(如「同步日历」)经 slugify 过滤后是空串,服务端照旧报「请填写英文标识」。
文案与行为自相矛盾,用户不明所以。

根因:fb_sw_slugify() 只保留 ASCII,中文全被过滤掉。

修法(不靠加红星了事):给 slugify 加兜底前缀 + 序号
- 软件:中文名 → sw<序号>(如 sw2)
- 分类:中文名 → cat<序号>
- 编辑时留空 = 保持原标识,不因改名把已有网址打散
- 纯英文名行为不变(FeedLite → feedlite)

实测:同步日历 → sw1 / 我的软件 → sw2 / FeedLite → feedlite

## 问题 2:必填项没填,整页刷新才看到错误
原先完全靠服务端校验:提交 → POST → 整页重刷 → 页面顶部弹一条错误。
用户视角就是「什么都没填,页面闪了一下」。

新增 bindInlineValidate()(assets/app.js):
- 给 form 加 data-validate 即启用,零配置
- 就地校验:字段下方显示红框 + 原因,聚焦并滚到可见
- 用户一改动立刻撤掉提示,不一直红着
- 规则取自原生 required / minlength / pattern / type=email

🔴 关键实现细节:用**捕获阶段**监听,并调 stopImmediatePropagation()。
因为 bindNoDoubleSubmit 会在 submit 时禁用按钮 5 秒,若校验排在它之后,
被拦下的表单按钮会被锁住,用户以为点坏了。所以校验必须先跑且拦住它。

## 服务端仍保留兜底
JS 被绕过(禁用脚本、直接构造请求)时,后端照样拒绝空用户名、短口令。

## 顺带收敛资源版本
app.js?v=5 与 style.css?v=N 原先各自写死,已统一到 FB_ASSET_V(升到 11)。
加上前一轮的 layout.php / install.php,现在全站只有 bootstrap.php 一处定义。

验证:
- 标识生成与前端校验专项 21/21
- 全新安装 33/33、老基线 25/25、附件专项 22/22
- 安装向导 60/60、后台管理 30/30
This commit is contained in:
2026-11-01 15:21:27 +08:00
parent b835afcd04
commit 1434a6dfb6
7 changed files with 136 additions and 16 deletions
+84
View File
@@ -611,9 +611,93 @@
} }
} }
/* ---------- 表单就地校验(不刷新页面,不打扰排版) ----------
* 目标:必填项没填时,在字段下方立刻给出提示并把焦点移过去,
* 而不是先 POST 上去、整页重刷、再在顶部弹一条错误。
* 规则来源:input 上原生 required / minlength / pattern / type=email。
* 用法:给 form 加 data-validate 即可,其余零配置。
*
* ⚠️ 必须在 bindNoDoubleSubmit 之前绑:后者会在 submit 时禁用按钮 5 秒,
* 若校验放在它之后,被拦下的表单按钮会被锁住,用户以为点坏了。
*/
function bindInlineValidate() {
var forms = document.querySelectorAll('form[data-validate]');
for (var i = 0; i < forms.length; i++) {
(function (form) {
form.setAttribute('novalidate', 'novalidate'); // 关掉浏览器默认的整页提示
form.addEventListener('submit', function (ev) {
var bad = null, msg = '';
var fields = form.querySelectorAll('input, textarea, select');
for (var j = 0; j < fields.length; j++) {
var f = fields[j];
if (f.disabled || f.type === 'hidden' || f.type === 'submit') { continue; }
var v = (f.value || '').trim();
msg = '';
if (f.hasAttribute('required') && v === '') {
msg = '这一项必须填写。';
} else if (v !== '' && f.hasAttribute('minlength') &&
v.length < Number(f.getAttribute('minlength'))) {
msg = '至少 ' + f.getAttribute('minlength') + ' 个字符,现在 ' + v.length + ' 个。';
} else if (v !== '' && f.type === 'email' &&
!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v)) {
msg = '邮箱格式不正确。';
} else if (v !== '' && f.hasAttribute('pattern') &&
!new RegExp('^(?:' + f.getAttribute('pattern') + ')$').test(v)) {
msg = f.getAttribute('data-pattern-msg') || '格式不正确。';
}
if (msg) { bad = f; break; }
}
if (bad) {
ev.preventDefault();
ev.stopImmediatePropagation(); // 拦住后续的"禁用按钮"逻辑
fieldError(bad, msg);
return false;
}
clearAllErrors(form);
}, true); // ★ 捕获阶段:先于 bindNoDoubleSubmit 的冒泡处理
})(forms[i]);
}
}
/** 在字段下显示错误;重复调用只更新文字,不叠加 */
function fieldError(field, msg) {
var wrap = field.closest('.field') || field.parentNode;
if (!wrap) { return; }
wrap.classList.add('has-error');
var tip = wrap.querySelector('.field-error');
if (!tip) {
tip = document.createElement('i');
tip.className = 'field-error';
wrap.appendChild(tip);
}
tip.textContent = msg;
// 卷到可见 + 聚焦,方便直接改
field.focus({ preventScroll: false });
field.scrollIntoView({ block: 'center', behavior: 'smooth' });
// 用户一改就撤掉提示,不要一直红着
var clear = function () {
wrap.classList.remove('has-error');
if (tip && tip.parentNode) { tip.parentNode.removeChild(tip); }
field.removeEventListener('input', clear);
field.removeEventListener('change', clear);
};
field.addEventListener('input', clear);
field.addEventListener('change', clear);
}
function clearAllErrors(form) {
var errs = form.querySelectorAll('.field-error');
for (var i = 0; i < errs.length; i++) {
if (errs[i].parentNode) { errs[i].parentNode.removeChild(errs[i]); }
}
var wraps = form.querySelectorAll('.has-error');
for (var k = 0; k < wraps.length; k++) { wraps[k].classList.remove('has-error'); }
}
function boot() { function boot() {
bindCaptchaRefresh(); bindCaptchaRefresh();
bindAdminToggle(); bindAdminToggle();
bindInlineValidate(); // ★ 必须在 bindNoDoubleSubmit 之前
bindNoDoubleSubmit(); bindNoDoubleSubmit();
bindUploaders(); bindUploaders();
bindLightbox(); bindLightbox();
+14
View File
@@ -81,6 +81,20 @@ main.wrap{padding-top:24px; padding-bottom:40px; min-height:60vh}
.page-head h1{margin-bottom:4px} .page-head h1{margin-bottom:4px}
.page-head .muted{font-size:14px; margin:0} .page-head .muted{font-size:14px; margin:0}
/* ---------- 表单就地校验的错误提示 ---------- */
.field.has-error > input,
.field.has-error > textarea,
.field.has-error > select,
.field.has-error .chips{
border-color:#d9534f !important;
box-shadow:0 0 0 3px rgba(217,83,79,.12) !important;
}
.field-error{
display:block; margin-top:6px; font-style:normal;
font-size:12.5px; color:#c9302c; line-height:1.5;
}
.field-error::before{content:"⚠ "; font-style:normal}
/* ---------- 后台:软件 / 分类 / 状态 ---------- */ /* ---------- 后台:软件 / 分类 / 状态 ---------- */
.card-list{display:flex; flex-direction:column; gap:8px; margin-bottom:16px} .card-list{display:flex; flex-direction:column; gap:8px; margin-bottom:16px}
.tax-row{ .tax-row{
+1 -1
View File
@@ -424,7 +424,7 @@ function fb_install_self_delete() {
<h2 style="margin:22px 0 10px">二、初始化站点</h2> <h2 style="margin:22px 0 10px">二、初始化站点</h2>
<?php fb_flash_box($err, 'error'); ?> <?php fb_flash_box($err, 'error'); ?>
<form method="post" action="install.php" class="card-form"> <form method="post" action="install.php" class="card-form" data-validate>
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>"> <input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="site_desc" value="<?php echo e(isset($_POST['site_desc']) ? (string)$_POST['site_desc'] : ''); ?>"> <input type="hidden" name="site_desc" value="<?php echo e(isset($_POST['site_desc']) ? (string)$_POST['site_desc'] : ''); ?>">
<input type="hidden" name="admin_email" value="<?php echo e(isset($_POST['admin_email']) ? (string)$_POST['admin_email'] : ''); ?>"> <input type="hidden" name="admin_email" value="<?php echo e(isset($_POST['admin_email']) ? (string)$_POST['admin_email'] : ''); ?>">
+17 -11
View File
@@ -21,8 +21,11 @@ function fb_admin_taxonomy_action($pdo, $act, $post) {
$note = trim((string)(isset($post['note']) ? $post['note'] : '')); $note = trim((string)(isset($post['note']) ? $post['note'] : ''));
if ($name === '') { return ['软件名称不能为空。', 'error']; } if ($name === '') { return ['软件名称不能为空。', 'error']; }
if (fb_len($name) > 40) { return ['软件名称最多 40 个字符。', 'error']; } if (fb_len($name) > 40) { return ['软件名称最多 40 个字符。', 'error']; }
if ($slug === '') { $slug = fb_sw_slugify($name); } // 留空就自动生成。中文名生成不出 ASCII 时退化成 sw<序号>,不打扰用户。
if ($slug === '') { return ['请填写英文标识(用于网址)。', 'error']; } if ($slug === '') {
$next = (int)$pdo->query('SELECT COALESCE(MAX(id),0)+1 FROM software')->fetchColumn();
$slug = fb_sw_slugify($name, 'sw', $next);
}
if (fb_software($slug)) { return ['英文标识「' . $slug . '」已被占用,换一个。', 'error']; } if (fb_software($slug)) { return ['英文标识「' . $slug . '」已被占用,换一个。', 'error']; }
$sort = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM software')->fetchColumn(); $sort = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM software')->fetchColumn();
$pdo->prepare('INSERT INTO software(slug,name,note,sort_order,enabled,created_at) VALUES(?,?,?,?,1,?)') $pdo->prepare('INSERT INTO software(slug,name,note,sort_order,enabled,created_at) VALUES(?,?,?,?,1,?)')
@@ -45,8 +48,8 @@ function fb_admin_taxonomy_action($pdo, $act, $post) {
$slug = fb_sw_slugify(isset($post['slug']) ? $post['slug'] : ''); $slug = fb_sw_slugify(isset($post['slug']) ? $post['slug'] : '');
$note = trim((string)(isset($post['note']) ? $post['note'] : '')); $note = trim((string)(isset($post['note']) ? $post['note'] : ''));
if ($name === '') { return ['软件名称不能为空。', 'error']; } if ($name === '') { return ['软件名称不能为空。', 'error']; }
if ($slug === '') { $slug = fb_sw_slugify($name); } // 编辑时留空 = 保持原标识,避免改名把已有网址打散
if ($slug === '') { return ['请填写英文标识。', 'error']; } if ($slug === '') { $slug = (string)$sw['slug']; }
$other = fb_software($slug); $other = fb_software($slug);
if ($other && (int)$other['id'] !== $id) { return ['英文标识「' . $slug . '」已被其他软件占用。', 'error']; } if ($other && (int)$other['id'] !== $id) { return ['英文标识「' . $slug . '」已被其他软件占用。', 'error']; }
$pdo->prepare('UPDATE software SET name=?, slug=?, note=?, sort_order=? WHERE id=?') $pdo->prepare('UPDATE software SET name=?, slug=?, note=?, sort_order=? WHERE id=?')
@@ -92,8 +95,11 @@ function fb_admin_taxonomy_action($pdo, $act, $post) {
$slug = fb_sw_slugify(isset($post['slug']) ? $post['slug'] : ''); $slug = fb_sw_slugify(isset($post['slug']) ? $post['slug'] : '');
if ($name === '') { return ['分类名称不能为空。', 'error']; } if ($name === '') { return ['分类名称不能为空。', 'error']; }
if (fb_len($name) > 30) { return ['分类名称最多 30 个字符。', 'error']; } if (fb_len($name) > 30) { return ['分类名称最多 30 个字符。', 'error']; }
if ($slug === '') { $slug = fb_sw_slugify($name); } // 留空自动生成;中文名退化成 cat<序号>(在本软件内取号,保证不冲突)
if ($slug === '') { return ['请填写英文标识。', 'error']; } if ($slug === '') {
$next = (int)$pdo->query('SELECT COALESCE(MAX(id),0)+1 FROM categories')->fetchColumn();
$slug = fb_sw_slugify($name, 'cat', $next);
}
if (fb_category_by_slug($swId, $slug)) { return ['该软件下已有标识为「' . $slug . '」的分类。', 'error']; } if (fb_category_by_slug($swId, $slug)) { return ['该软件下已有标识为「' . $slug . '」的分类。', 'error']; }
$sort = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM categories WHERE software_id = ' . $swId)->fetchColumn(); $sort = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM categories WHERE software_id = ' . $swId)->fetchColumn();
$pdo->prepare('INSERT INTO categories(software_id,slug,name,sort_order,enabled,created_at) VALUES(?,?,?,?,1,?)') $pdo->prepare('INSERT INTO categories(software_id,slug,name,sort_order,enabled,created_at) VALUES(?,?,?,?,1,?)')
@@ -108,8 +114,8 @@ function fb_admin_taxonomy_action($pdo, $act, $post) {
$name = trim((string)(isset($post['name']) ? $post['name'] : '')); $name = trim((string)(isset($post['name']) ? $post['name'] : ''));
$slug = fb_sw_slugify(isset($post['slug']) ? $post['slug'] : ''); $slug = fb_sw_slugify(isset($post['slug']) ? $post['slug'] : '');
if ($name === '') { return ['分类名称不能为空。', 'error']; } if ($name === '') { return ['分类名称不能为空。', 'error']; }
if ($slug === '') { $slug = fb_sw_slugify($name); } // 编辑时留空 = 保持原标识,避免改名把已有链接打散
if ($slug === '') { return ['请填写英文标识。', 'error']; } if ($slug === '') { $slug = (string)$cat['slug']; }
$other = fb_category_by_slug((int)$cat['software_id'], $slug); $other = fb_category_by_slug((int)$cat['software_id'], $slug);
if ($other && (int)$other['id'] !== $id) { return ['该软件下已有标识为「' . $slug . '」的分类。', 'error']; } if ($other && (int)$other['id'] !== $id) { return ['该软件下已有标识为「' . $slug . '」的分类。', 'error']; }
$pdo->prepare('UPDATE categories SET name=?, slug=?, sort_order=? WHERE id=?') $pdo->prepare('UPDATE categories SET name=?, slug=?, sort_order=? WHERE id=?')
@@ -285,7 +291,7 @@ function fb_admin_taxonomy_page($pdo, $editSw, $editCat) {
if ($editSw > 0) { $esw = fb_software($editSw); } if ($editSw > 0) { $esw = fb_software($editSw); }
echo '<details class="tax-form-box"' . ($esw ? ' open' : '') . '>'; echo '<details class="tax-form-box"' . ($esw ? ' open' : '') . '>';
echo '<summary>' . ($esw ? '编辑软件「' . e((string)$esw['name']) . '」' : '添加软件') . '</summary>'; echo '<summary>' . ($esw ? '编辑软件「' . e((string)$esw['name']) . '」' : '添加软件') . '</summary>';
echo '<form method="post" action="admin.php?tab=taxonomy" class="card-form sm-form">'; echo '<form method="post" action="admin.php?tab=taxonomy" class="card-form sm-form" data-validate>';
echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">'; echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">';
if ($esw) { echo ' <input type="hidden" name="id" value="' . (int)$esw['id'] . '">'; } if ($esw) { echo ' <input type="hidden" name="id" value="' . (int)$esw['id'] . '">'; }
echo ' <div class="row2">'; echo ' <div class="row2">';
@@ -356,7 +362,7 @@ function fb_admin_taxonomy_page($pdo, $editSw, $editCat) {
$ecat = $editCat > 0 ? fb_category($editCat) : null; $ecat = $editCat > 0 ? fb_category($editCat) : null;
echo '<details class="tax-form-box"' . ($ecat ? ' open' : '') . '>'; echo '<details class="tax-form-box"' . ($ecat ? ' open' : '') . '>';
echo '<summary>' . ($ecat ? '编辑分类「' . e((string)$ecat['name']) . '」' : '添加分类') . '</summary>'; echo '<summary>' . ($ecat ? '编辑分类「' . e((string)$ecat['name']) . '」' : '添加分类') . '</summary>';
echo '<form method="post" action="admin.php?tab=taxonomy&sw=' . (int)$curSw['id'] . '" class="card-form sm-form">'; echo '<form method="post" action="admin.php?tab=taxonomy&sw=' . (int)$curSw['id'] . '" class="card-form sm-form" data-validate>';
echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">'; echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">';
echo ' <input type="hidden" name="software_id" value="' . (int)$curSw['id'] . '">'; echo ' <input type="hidden" name="software_id" value="' . (int)$curSw['id'] . '">';
if ($ecat) { echo ' <input type="hidden" name="id" value="' . (int)$ecat['id'] . '">'; } if ($ecat) { echo ' <input type="hidden" name="id" value="' . (int)$ecat['id'] . '">'; }
@@ -426,7 +432,7 @@ function fb_admin_status_page($pdo, $editId) {
} }
echo '<details class="tax-form-box"' . ($est ? ' open' : '') . '>'; echo '<details class="tax-form-box"' . ($est ? ' open' : '') . '>';
echo '<summary>' . ($est ? '编辑状态「' . e((string)$est['name']) . '」' : '添加状态') . '</summary>'; echo '<summary>' . ($est ? '编辑状态「' . e((string)$est['name']) . '」' : '添加状态') . '</summary>';
echo '<form method="post" action="admin.php?tab=statuses" class="card-form sm-form">'; echo '<form method="post" action="admin.php?tab=statuses" class="card-form sm-form" data-validate>';
echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">'; echo ' <input type="hidden" name="csrf" value="' . e(fb_csrf_token()) . '">';
if ($est) { echo ' <input type="hidden" name="id" value="' . (int)$est['id'] . '">'; } if ($est) { echo ' <input type="hidden" name="id" value="' . (int)$est['id'] . '">'; }
echo ' <div class="row2">'; echo ' <div class="row2">';
+1 -1
View File
@@ -8,7 +8,7 @@ if (!defined('FB_ROOT')) {
define('FB_ROOT', dirname(__DIR__)); define('FB_ROOT', dirname(__DIR__));
// 静态资源版本号:改 assets/ 下的 css/js 后把这个数字 +1, // 静态资源版本号:改 assets/ 下的 css/js 后把这个数字 +1,
// 让浏览器与 CDN 缓存失效。全站只此一处,别再往页面里写死数字。 // 让浏览器与 CDN 缓存失效。全站只此一处,别再往页面里写死数字。
if (!defined('FB_ASSET_V')) { define('FB_ASSET_V', 10); } if (!defined('FB_ASSET_V')) { define('FB_ASSET_V', 11); }
} }
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
+18 -2
View File
@@ -396,9 +396,25 @@ SQL
} }
/** 生成 URL 友好的 slug(只留小写字母数字和连字符) */ /** 生成 URL 友好的 slug(只留小写字母数字和连字符) */
function fb_sw_slugify($s) { /**
* 生成 URL 友好的 slug(只留小写字母数字和连字符)。
*
* ⚠️ 纯中文名(如「同步日历」)过滤后是空串。早期调用方拿到空串就报
* 「请填写英文标识」,但界面上写的是「留空自动生成」—— 自相矛盾。
* 所以这里再加一层兜底:拿不到 ASCII 就退化成 前缀+序号,
* 保证「留空」真的能自动生成,不用用户去猜。
*
* @param string $s 原始名称
* @param string $prefix 兜底前缀,如 'sw' / 'cat';传空串表示不兜底
* @param int $seq 兜底序号(调用方传"当前最大 id + 1")
*/
function fb_sw_slugify($s, $prefix = '', $seq = 1) {
$s = strtolower(trim((string)$s)); $s = strtolower(trim((string)$s));
$s = preg_replace('/[^a-z0-9_-]+/', '-', $s); $s = preg_replace('/[^a-z0-9_-]+/', '-', $s);
$s = trim((string)$s, '-'); $s = trim((string)$s, '-');
return $s === '' ? '' : substr($s, 0, 40); $s = $s === '' ? '' : substr($s, 0, 40);
if ($s === '' && $prefix !== '') {
$s = $prefix . max(1, (int)$seq);
}
return $s;
} }
+1 -1
View File
@@ -66,7 +66,7 @@ function fb_layout_foot() {
</p> </p>
</div> </div>
</footer> </footer>
<script src="assets/app.js?v=5"></script> <script src="assets/app.js?v=<?php echo (int)FB_ASSET_V; ?>"></script>
</body> </body>
</html><?php </html><?php
} }