下拉框改为自绘列表;修复新建软件缺排序字段

用户两个反馈:
① 「排序那个怎么删了?排序还得留着呀」
② 「所有下拉框拉下来之后全是灰色,不好看,重新设计」

一、排序字段(是误解,但确实有缺陷)

排序字段没有被删,一直在 if ($esw) 分支里渲染。
用户看到"没有",是因为他打开的是「添加软件」——
新建时 $esw 为空,整块不渲染,所以确实没有这一项。
这是真实缺陷:新建软件时无法指定排序。

修法:
- 把排序字段移出 if ($esw),新建/编辑都显示
- 新建时默认值 = 当前最大 sort_order + 1(排到最后)
- 后端 sw_add 改为优先用表单值(用 isset 判断,
  用户明确填 0 是合法值——排最前面,不能被当成"没填")

与「下载地址」不同:排序是真实会被用到的字段
(前台软件列表、顶栏三横线菜单都按它排),不能删。

二、下拉框重设计

为什么必须自绘:原生 select 的展开列表由浏览器绘制,
CSS 只能改 option 的底色,改不了圆角/行高/hover 高亮,
各平台样式还不一样(Windows 是灰方框)。用户说的"全是灰色"就是这个。

但不重写 HTML:全站 16 个 select,逐个改容易漏,
也会破坏 onchange 提交这类内联逻辑。

做法:原生 select 保留(视觉隐藏,仍参与提交与 change 事件),
JS 在旁生成按钮 + 浮层列表。
- 浮层用 position:fixed 挂 body,避免被 overflow:hidden 裁(踩过)
- 位置自动避让视口,下方不够翻上方
- 支持键盘:上下键 Home End Enter Esc
- 选中项带对勾
- 无 JS 时原生 select 仍可用

配色踩坑:选中项最初用 var(--brand-soft),而该变量会被软件主题色覆盖 ——
同步日历主题色 #ff3f44 时 --brand-soft 成了 #ffe8e9(粉红),
下拉里一片粉很难看。下拉是工具性控件,不该跟软件换色,
改用中性色:#eef1f5 选中 / #f1f3f6 hover,四个软件下完全一致。

验证(真实页面实测):
- 2 个 select 均被增强,原生已隐藏
- 面板白底 rgb(255,255,255)、圆角 11px、双层柔和阴影、7 个列表项
- 选中项 rgb(238,241,245)、hover rgb(241,243,246)、带对勾
- 四个软件(#0057b8/#ff3f44/#a556f7/#3571fb)下选中项底色完全相同,
  不再随主题色变粉
- 键盘下方向键三次 → 高亮「处理中」,底色正确
- 选择后 selectedIndex/value 同步、面板自动关闭、change 事件已触发
- 浮层 fixed 定位、完整可见、不被裁切
- 排序字段 3 处(软件/分类/状态)齐全;5 个路由 200
This commit is contained in:
2026-11-04 19:27:01 +08:00
parent 27614d8ca8
commit 9b971a5b92
4 changed files with 343 additions and 6 deletions
+216
View File
@@ -1109,6 +1109,221 @@
} }
} }
/* ---------- 自定义下拉:替换原生 <select> 的展开列表 ----------
* 用户 2026-11-04:「所有下拉框拉下来之后怎么全是灰色的?不好看,重新设计。」
*
* 🔴 为什么必须自绘:原生 <select> 的展开列表是**浏览器渲染的原生控件**,
* CSS 只能改 <option> 的 background/color,改不了圆角、行高、hover 高亮、
* 分组间距 —— 而且各平台样式完全不同(macOS 是圆角浮层、Windows 是灰方框)。
* 想让"展开后"好看,只能自己画一个列表。
*
* 设计取舍:
* - **保留原生 select 作为数据源**:不重写 HTML(全站 16 个 select,
* 逐个改容易漏、也容易破坏 onchange 内联逻辑)。
* 把原生 select 视觉隐藏,在旁边放一个按钮 + 浮层列表。
* - 原生 select 仍参与表单提交(值同步),onchange 也照样触发
* —— 调用 this.form.submit() 的那些筛选下拉因此不用改动。
* - 无 JS 时原生 select 正常可用(只是样式朴素),不会白屏。
* - 浮层用 position:fixed 定位,避免被 overflow:hidden 容器裁掉(踩过)。
*/
function bindSelect() {
var sels = document.querySelectorAll('select');
for (var i = 0; i < sels.length; i++) {
(function (sel) {
// 已经增强过、或明确排除的跳过
if (sel.getAttribute('data-enhanced') === '1') { return; }
if (sel.hasAttribute('data-no-enhance')) { return; }
// 隐藏域 / 多选 / 尺寸>1 的列表框不动
if (sel.multiple || (parseInt(sel.getAttribute('size'), 10) || 0) > 1) { return; }
sel.setAttribute('data-enhanced', '1');
// 原生 select:保留在 DOM 里(提交用),但视觉隐藏
sel.classList.add('sel-native');
// 包一层,方便定位
var wrap = document.createElement('span');
wrap.className = 'sel';
sel.parentNode.insertBefore(wrap, sel);
wrap.appendChild(sel);
// 触发按钮
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'sel-btn';
btn.setAttribute('aria-haspopup', 'listbox');
btn.setAttribute('aria-expanded', 'false');
wrap.appendChild(btn);
// 浮层列表(挂到 body,避开一切 overflow 裁剪)
var panel = document.createElement('div');
panel.className = 'sel-panel';
panel.hidden = true;
panel.setAttribute('role', 'listbox');
document.body.appendChild(panel);
var active = -1; // 键盘高亮项
function curText() {
var o = sel.options[sel.selectedIndex];
return o ? o.textContent : '';
}
function syncBtn() {
btn.innerHTML = '<span class="sel-txt"></span><span class="sel-caret" aria-hidden="true"></span>';
btn.querySelector('.sel-txt').textContent = curText();
btn.disabled = sel.disabled;
btn.classList.toggle('is-placeholder', sel.selectedIndex <= 0 && sel.options.length > 0
&& (sel.options[0].value === ''));
}
function buildList() {
panel.innerHTML = '';
for (var k = 0; k < sel.options.length; k++) {
(function (idx) {
var o = sel.options[idx];
if (o.hidden) { return; }
var item = document.createElement('button');
item.type = 'button';
item.className = 'sel-item';
item.setAttribute('role', 'option');
var on = idx === sel.selectedIndex;
if (on) { item.classList.add('on'); item.setAttribute('aria-selected', 'true'); }
var label = document.createElement('span');
label.className = 'sel-item-txt';
label.textContent = o.textContent;
item.appendChild(label);
if (on) {
var ck = document.createElement('span');
ck.className = 'sel-item-ck';
ck.setAttribute('aria-hidden', 'true');
item.appendChild(ck);
}
item.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
pick(idx);
});
panel.appendChild(item);
})(k);
}
}
function pick(idx) {
if (idx < 0 || idx >= sel.options.length) { return; }
var changed = sel.selectedIndex !== idx;
sel.selectedIndex = idx;
syncBtn();
close();
// 触发原生 change,兼容那些 onchange="this.form.submit()" 的下拉
if (changed) {
var ev = document.createEvent('HTMLEvents');
ev.initEvent('change', true, false);
sel.dispatchEvent(ev);
}
}
function open() {
if (btn.disabled) { return; }
buildList();
panel.hidden = false;
btn.setAttribute('aria-expanded', 'true');
btn.classList.add('is-open');
position();
// 打开后键盘可直接上下选
active = sel.selectedIndex;
highlight(active);
document.addEventListener('mousedown', outside, true);
document.addEventListener('keydown', onKey, true);
window.addEventListener('scroll', position, true);
window.addEventListener('resize', position);
}
function close() {
if (panel.hidden) { return; }
panel.hidden = true;
btn.setAttribute('aria-expanded', 'false');
btn.classList.remove('is-open');
document.removeEventListener('mousedown', outside, true);
document.removeEventListener('keydown', onKey, true);
window.removeEventListener('scroll', position, true);
window.removeEventListener('resize', position);
}
function outside(e) {
if (!panel.contains(e.target) && !btn.contains(e.target)) { close(); }
}
/* 定位:fixed,挂在 body 上,不服从任何祖先的 overflow。
* 优先向下展开;下方空间不足且上方更宽裕时向上翻。 */
function position() {
if (panel.hidden) { return; }
var r = btn.getBoundingClientRect();
var vw = window.innerWidth, vh = window.innerHeight;
var pw = Math.max(panel.offsetWidth, r.width);
var ph = panel.offsetHeight;
var margin = 8;
var left = r.left;
if (left + pw + margin > vw) { left = Math.max(margin, vw - pw - margin); }
var below = vh - r.bottom - margin;
var above = r.top - margin;
var openUp = (ph > below) && (above > below);
var top = openUp ? (r.top - ph - 4) : (r.bottom + 4);
if (top < margin) { top = margin; }
if (top + ph > vh - margin) { top = Math.max(margin, vh - ph - margin); }
panel.style.left = Math.round(left) + 'px';
panel.style.top = Math.round(top) + 'px';
panel.style.minWidth = Math.round(r.width) + 'px';
panel.classList.toggle('is-up', openUp);
}
function highlight(idx) {
var items = panel.querySelectorAll('.sel-item');
for (var k = 0; k < items.length; k++) {
items[k].classList.toggle('is-active', k === idx);
}
if (idx >= 0 && items[idx]) {
var it = items[idx];
var t = it.offsetTop, b = t + it.offsetHeight;
if (t < panel.scrollTop) { panel.scrollTop = t; }
else if (b > panel.scrollTop + panel.clientHeight) {
panel.scrollTop = b - panel.clientHeight;
}
}
}
function onKey(e) {
var items = panel.querySelectorAll('.sel-item');
if (e.key === 'Escape') { e.preventDefault(); close(); btn.focus(); return; }
if (e.key === 'ArrowDown') {
e.preventDefault();
active = Math.min(items.length - 1, active + 1); highlight(active);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
active = Math.max(0, active - 1); highlight(active);
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (active >= 0) { pick(active); }
} else if (e.key === 'Home') {
e.preventDefault(); active = 0; highlight(active);
} else if (e.key === 'End') {
e.preventDefault(); active = items.length - 1; highlight(active);
}
}
btn.addEventListener('click', function (e) {
e.preventDefault(); e.stopPropagation();
if (panel.hidden) { open(); } else { close(); }
});
// 原生 select 若在别处被程序改值,按钮文字要跟着变
sel.addEventListener('change', syncBtn);
syncBtn();
})(sels[i]);
}
}
/* ---------- 顶栏软件下拉菜单(右上角三横线) ---------- /* ---------- 顶栏软件下拉菜单(右上角三横线) ----------
* 极简:点了就展开一个小浮窗,点外面 / Esc 就关。 * 极简:点了就展开一个小浮窗,点外面 / Esc 就关。
* 不做遮罩、不锁背景滚动 —— 那套是侧边栏的做法,下拉菜单不需要。 */ * 不做遮罩、不锁背景滚动 —— 那套是侧边栏的做法,下拉菜单不需要。 */
@@ -1196,6 +1411,7 @@
bindColorPicker(); bindColorPicker();
bindIconPicker(); bindIconPicker();
bindIconUpload(); bindIconUpload();
bindSelect();
bindSoftwarePanel(); bindSoftwarePanel();
bindMailTest(); bindMailTest();
bindInlineValidate(); // ★ 必须在 bindNoDoubleSubmit 之前 bindInlineValidate(); // ★ 必须在 bindNoDoubleSubmit 之前
+105
View File
@@ -906,6 +906,111 @@ select:hover{
} }
.num-wrap input[type=number]{padding-right:44px} .num-wrap input[type=number]{padding-right:44px}
/* ---------- 自定义下拉(替换原生 select 的展开列表)----------
* 用户 2026-11-04:「下拉框拉下来之后怎么全是灰色的?不好看,重新设计。」
*
* 🔴 原生 <select> 的展开列表由浏览器绘制,CSS 改不动(只能改 option 底色),
* 各平台样式还不统一。所以必须自绘。
* 但**不重写 HTML**:原生 select 保留参与提交,JS 在旁边生成按钮+浮层。
* 这样全站 16 个 select 自动获得新样式,也不会破坏 onchange 内联逻辑。
*/
/* 原生 select:视觉隐藏但仍参与表单提交与事件(不能 display:none) */
.sel-native{
position:absolute !important;
width:1px !important; height:1px !important;
margin:0 !important; padding:0 !important; border:0 !important;
opacity:0 !important; pointer-events:none !important;
clip:rect(0 0 0 0) !important;
}
/* 外层:inline-flex 让它跟输入框一样参与行内布局 */
.sel{position:relative; display:inline-flex; vertical-align:middle; max-width:100%}
/* 触发按钮:外观与普通输入框完全一致(同高同圆角同边框) */
.sel-btn{
display:inline-flex; align-items:center; gap:10px;
height:var(--ctl-h); padding:0 12px;
border:var(--ctl-bd); border-radius:var(--ctl-r);
background:var(--card); color:var(--fg);
font-family:inherit; font-size:14.5px; line-height:1;
cursor:pointer; text-align:left;
box-shadow:var(--ctl-shadow);
transition:border-color .12s, box-shadow .12s;
max-width:100%; min-width:0;
}
.sel-btn:hover{border-color:#c3c9d1}
.sel-btn:focus-visible{outline:none; border-color:var(--brand); box-shadow:var(--ctl-shadow-focus)}
.sel-btn.is-open{border-color:var(--brand); box-shadow:var(--ctl-shadow-focus)}
.sel-txt{
flex:1; min-width:0;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
/* 未选择任何值时(如「全部状态」)文字用次要色,弱化存在感 */
.sel-btn.is-placeholder .sel-txt{color:var(--muted)}
/* 自绘箭头 */
.sel-caret{
width:11px; height:7px; flex:none;
background:currentColor; opacity:.55;
-webkit-mask:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='11' height='7' viewBox='0 0 11 7'%3E%3Cpath d='M1 1.2 5.5 5.8 10 1.2' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center/contain no-repeat;
mask:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='11' height='7' viewBox='0 0 11 7'%3E%3Cpath d='M1 1.2 5.5 5.8 10 1.2' fill='none' stroke='black' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center/contain no-repeat;
transition:transform .16s;
}
.sel-btn.is-open .sel-caret{transform:rotate(180deg); opacity:.85}
/* 浮层:fixed 定位,不被任何 overflow 容器裁切。
* 用白底 + 细边框 + 柔和阴影,而不是浏览器默认的灰块。 */
.sel-panel{
position:fixed; z-index:9998;
max-height:min(320px, calc(100vh - 32px));
overflow-y:auto; overscroll-behavior:contain;
padding:5px;
background:var(--card);
border:1px solid var(--line-strong);
border-radius:11px;
box-shadow:0 10px 30px rgba(16,24,40,.16), 0 2px 6px rgba(16,24,40,.06);
animation:selIn .12s ease-out;
}
@keyframes selIn{ from{opacity:0; transform:translateY(-4px)} to{opacity:1; transform:none} }
.sel-panel.is-up{animation-name:selInUp}
@keyframes selInUp{ from{opacity:0; transform:translateY(4px)} to{opacity:1; transform:none} }
/* 列表项:圆角、行高一致、hover 有底色、选中项有对勾 */
.sel-item{
display:flex; align-items:center; gap:8px; width:100%;
padding:0 10px; height:34px;
border:0; border-radius:7px;
background:transparent; color:var(--fg);
font-family:inherit; font-size:14px; line-height:1;
cursor:pointer; text-align:left; white-space:nowrap;
transition:background .1s, color .1s;
-webkit-appearance:none; appearance:none;
}
.sel-item-txt{flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis}
/* hover / 键盘高亮:中性浅灰,**不要用 --brand-soft**。
* 🔴 踩过:最初选中项用 var(--brand-soft),但那个变量会被软件主题色覆盖 ——
* 同步日历是砖红色,--brand-soft 就成了 #ffe8e9(粉红),
* 下拉里一片粉,很难看。下拉是**工具性控件**,不该跟着软件换色,
* 统一用中性色才耐看。 */
.sel-item:hover, .sel-item.is-active{background:#f1f3f6; color:var(--fg)}
/* 选中项:浅灰蓝底 + 深色文字 + 右侧对勾(不依赖主题色) */
.sel-item.on{background:#eef1f5; color:var(--fg); font-weight:600}
.sel-item.on:hover, .sel-item.on.is-active{background:#e6eaf0; color:var(--fg)}
.sel-item-ck{
width:13px; height:10px; flex:none;
background:currentColor;
-webkit-mask:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='13' height='10' viewBox='0 0 13 10'%3E%3Cpath d='M1.5 5.2 4.8 8.4 11.5 1.6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center/contain no-repeat;
mask:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='13' height='10' viewBox='0 0 13 10'%3E%3Cpath d='M1.5 5.2 4.8 8.4 11.5 1.6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center/contain no-repeat;
}
/* 窄屏:让下拉至少占满可用宽度,别挤成一小坨 */
@media (max-width:640px){
.sel{flex:1 1 auto; min-width:0}
.sel-btn{width:100%}
}
/* ---------- 提交页容器 */ /* ---------- 提交页容器 */
* 🔴 原来只有 max-width:640px,**没有居中** —— 在 880px 的内容区里, * 🔴 原来只有 max-width:640px,**没有居中** —— 在 880px 的内容区里,
* 表单贴左边、右侧空出 240px 白,看起来像"页面缺了一块"。 * 表单贴左边、右侧空出 240px 白,看起来像"页面缺了一块"。
+21 -5
View File
@@ -150,7 +150,12 @@ function fb_admin_taxonomy_action($pdo, $act, $post) {
$slug = fb_sw_slugify($name, 'sw', $next); $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(); /* 排序:优先用表单里填的值(新建表单现在也有这一项了),
* 没填才算「排到最后」。用 isset 判断而不是空判断 ——
* 用户明确填 0 也是合法值(排最前面),不能被当成"没填"。 */
$sort = isset($post['sort_order']) && $post['sort_order'] !== ''
? (int)$post['sort_order']
: (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM software')->fetchColumn();
$color = fb_color_norm(isset($post['color']) ? $post['color'] : ''); $color = fb_color_norm(isset($post['color']) ? $post['color'] : '');
$icon = fb_sw_icon_norm(isset($post['icon']) ? $post['icon'] : ''); $icon = fb_sw_icon_norm(isset($post['icon']) ? $post['icon'] : '');
$dl = fb_sw_url_norm(isset($post['download_url']) ? $post['download_url'] : ''); $dl = fb_sw_url_norm(isset($post['download_url']) ? $post['download_url'] : '');
@@ -591,11 +596,22 @@ function fb_admin_taxonomy_page($pdo, $editSw, $editCat) {
echo ' </div>'; echo ' </div>';
echo ' </div>'; echo ' </div>';
echo ' </div>'; echo ' </div>';
/* 排序字段:新建和编辑**都要有**。
* 之前只在 if ($esw) 分支里渲染,导致「添加软件」时根本没有这一项,
* 用户以为排序被删了。新建时给个默认值 = 当前最大排序+1(排到最后)。
*
* ⚠️ 与「下载地址」不同:排序是**真实会被用到**的字段
* (前台软件列表、顶栏三横线菜单都按它排),不能删。 */
$sortVal = 0;
if ($esw) {
$sortVal = (int)$esw['sort_order'];
} else {
$sortVal = (int)fb_db()->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM software')->fetchColumn();
}
echo ' <label class="field field-narrow"><span>排序<i class="hint">数字小的排前面,同级按名称排</i></span>'
. '<input type="number" name="sort_order" min="0" max="999" value="' . $sortVal . '"></label>';
if ($esw) { if ($esw) {
/* 排序是 1-2 位的小整数,占满整行很难看 —— 收窄成定宽。
* 用 .field-narrow 限制宽度,而不是让它跟着 .field 铺满。 */
echo ' <label class="field field-narrow"><span>排序<i class="hint">数字小的排前面,同级按名称排</i></span>'
. '<input type="number" name="sort_order" min="0" max="999" value="' . (int)$esw['sort_order'] . '"></label>';
echo ' <button class="btn primary" name="act" value="sw_edit" type="submit">保存</button> ' echo ' <button class="btn primary" name="act" value="sw_edit" type="submit">保存</button> '
. '<a class="btn" href="admin.php?tab=taxonomy">取消</a>'; . '<a class="btn" href="admin.php?tab=taxonomy">取消</a>';
} else { } else {
+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', 47); } if (!defined('FB_ASSET_V')) { define('FB_ASSET_V', 49); }
} }
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------