diff --git a/assets/app.js b/assets/app.js index 0c0d3f9..22ec0fb 100644 --- a/assets/app.js +++ b/assets/app.js @@ -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(); diff --git a/assets/style.css b/assets/style.css index 6e1d602..e28cdfd 100644 --- a/assets/style.css +++ b/assets/style.css @@ -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{ diff --git a/install.php b/install.php index bd2ee2e..4a95b14 100644 --- a/install.php +++ b/install.php @@ -424,7 +424,7 @@ function fb_install_self_delete() {

二、初始化站点

-
+ diff --git a/lib/admin_taxonomy.php b/lib/admin_taxonomy.php index 9844da2..af18d61 100644 --- a/lib/admin_taxonomy.php +++ b/lib/admin_taxonomy.php @@ -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 '
'; echo '' . ($esw ? '编辑软件「' . e((string)$esw['name']) . '」' : '添加软件') . ''; - echo ''; + echo ''; echo ' '; if ($esw) { echo ' '; } echo '
'; @@ -356,7 +362,7 @@ function fb_admin_taxonomy_page($pdo, $editSw, $editCat) { $ecat = $editCat > 0 ? fb_category($editCat) : null; echo '
'; echo '' . ($ecat ? '编辑分类「' . e((string)$ecat['name']) . '」' : '添加分类') . ''; - echo ''; + echo ''; echo ' '; echo ' '; if ($ecat) { echo ' '; } @@ -426,7 +432,7 @@ function fb_admin_status_page($pdo, $editId) { } echo '
'; echo '' . ($est ? '编辑状态「' . e((string)$est['name']) . '」' : '添加状态') . ''; - echo ''; + echo ''; echo ' '; if ($est) { echo ' '; } echo '
'; diff --git a/lib/bootstrap.php b/lib/bootstrap.php index 7fdf26a..eabe69d 100644 --- a/lib/bootstrap.php +++ b/lib/bootstrap.php @@ -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); } } /* ------------------------------------------------------------------ diff --git a/lib/db.php b/lib/db.php index 17f8121..5d371d3 100644 --- a/lib/db.php +++ b/lib/db.php @@ -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; } diff --git a/lib/layout.php b/lib/layout.php index 5798e8a..a84df27 100644 --- a/lib/layout.php +++ b/lib/layout.php @@ -66,7 +66,7 @@ function fb_layout_foot() {

- +