修复:英文标识留空真能自动生成;表单必填前端就地提示不刷新 (#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:
@@ -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() {
|
||||
bindCaptchaRefresh();
|
||||
bindAdminToggle();
|
||||
bindInlineValidate(); // ★ 必须在 bindNoDoubleSubmit 之前
|
||||
bindNoDoubleSubmit();
|
||||
bindUploaders();
|
||||
bindLightbox();
|
||||
|
||||
@@ -81,6 +81,20 @@ main.wrap{padding-top:24px; padding-bottom:40px; min-height:60vh}
|
||||
.page-head h1{margin-bottom:4px}
|
||||
.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}
|
||||
.tax-row{
|
||||
|
||||
+1
-1
@@ -424,7 +424,7 @@ function fb_install_self_delete() {
|
||||
|
||||
<h2 style="margin:22px 0 10px">二、初始化站点</h2>
|
||||
<?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="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'] : ''); ?>">
|
||||
|
||||
+17
-11
@@ -21,8 +21,11 @@ function fb_admin_taxonomy_action($pdo, $act, $post) {
|
||||
$note = trim((string)(isset($post['note']) ? $post['note'] : ''));
|
||||
if ($name === '') { return ['软件名称不能为空。', 'error']; }
|
||||
if (fb_len($name) > 40) { return ['软件名称最多 40 个字符。', 'error']; }
|
||||
if ($slug === '') { $slug = fb_sw_slugify($name); }
|
||||
if ($slug === '') { return ['请填写英文标识(用于网址)。', 'error']; }
|
||||
// 留空就自动生成。中文名生成不出 ASCII 时退化成 sw<序号>,不打扰用户。
|
||||
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']; }
|
||||
$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,?)')
|
||||
@@ -45,8 +48,8 @@ function fb_admin_taxonomy_action($pdo, $act, $post) {
|
||||
$slug = fb_sw_slugify(isset($post['slug']) ? $post['slug'] : '');
|
||||
$note = trim((string)(isset($post['note']) ? $post['note'] : ''));
|
||||
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);
|
||||
if ($other && (int)$other['id'] !== $id) { return ['英文标识「' . $slug . '」已被其他软件占用。', 'error']; }
|
||||
$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'] : '');
|
||||
if ($name === '') { return ['分类名称不能为空。', 'error']; }
|
||||
if (fb_len($name) > 30) { return ['分类名称最多 30 个字符。', 'error']; }
|
||||
if ($slug === '') { $slug = fb_sw_slugify($name); }
|
||||
if ($slug === '') { return ['请填写英文标识。', 'error']; }
|
||||
// 留空自动生成;中文名退化成 cat<序号>(在本软件内取号,保证不冲突)
|
||||
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']; }
|
||||
$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,?)')
|
||||
@@ -108,8 +114,8 @@ function fb_admin_taxonomy_action($pdo, $act, $post) {
|
||||
$name = trim((string)(isset($post['name']) ? $post['name'] : ''));
|
||||
$slug = fb_sw_slugify(isset($post['slug']) ? $post['slug'] : '');
|
||||
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);
|
||||
if ($other && (int)$other['id'] !== $id) { return ['该软件下已有标识为「' . $slug . '」的分类。', 'error']; }
|
||||
$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); }
|
||||
echo '<details class="tax-form-box"' . ($esw ? ' open' : '') . '>';
|
||||
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()) . '">';
|
||||
if ($esw) { echo ' <input type="hidden" name="id" value="' . (int)$esw['id'] . '">'; }
|
||||
echo ' <div class="row2">';
|
||||
@@ -356,7 +362,7 @@ function fb_admin_taxonomy_page($pdo, $editSw, $editCat) {
|
||||
$ecat = $editCat > 0 ? fb_category($editCat) : null;
|
||||
echo '<details class="tax-form-box"' . ($ecat ? ' open' : '') . '>';
|
||||
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="software_id" value="' . (int)$curSw['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 '<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()) . '">';
|
||||
if ($est) { echo ' <input type="hidden" name="id" value="' . (int)$est['id'] . '">'; }
|
||||
echo ' <div class="row2">';
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ if (!defined('FB_ROOT')) {
|
||||
define('FB_ROOT', dirname(__DIR__));
|
||||
// 静态资源版本号:改 assets/ 下的 css/js 后把这个数字 +1,
|
||||
// 让浏览器与 CDN 缓存失效。全站只此一处,别再往页面里写死数字。
|
||||
if (!defined('FB_ASSET_V')) { define('FB_ASSET_V', 10); }
|
||||
if (!defined('FB_ASSET_V')) { define('FB_ASSET_V', 11); }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
|
||||
+18
-2
@@ -396,9 +396,25 @@ SQL
|
||||
}
|
||||
|
||||
/** 生成 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 = preg_replace('/[^a-z0-9_-]+/', '-', $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
@@ -66,7 +66,7 @@ function fb_layout_foot() {
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
<script src="assets/app.js?v=5"></script>
|
||||
<script src="assets/app.js?v=<?php echo (int)FB_ASSET_V; ?>"></script>
|
||||
</body>
|
||||
</html><?php
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user