diff --git a/app/helpers.php b/app/helpers.php index ae06dd2..7e91c45 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -426,6 +426,40 @@ function days_until_birthday(?string $birthday): ?int * @param array $extra 额外要显示的行 [[标签, 值], …] * @return string 可直接放进 data-fb-entry 的 JSON 字符串(已转义) */ +/** + * 把 hex 主题色往白色方向混,得到一档浅色。 + * + * 🔴 #85(用户 2026-11-05):「参考一下那个反馈系统里面的下拉框到底是怎么做的… + * 它也有主题颜色,我们这个也有主题颜色,就按照一样的做法去做。」 + * + * FeedLite 那边用三档浅色区分下拉项的三态: + * tint-1(混 91% 白)→ hover + * tint-2(混 80% 白)→ 选中项(比 hover 深一档,一眼分开) + * tint-3(混 72% 白)→ 选中项 hover + * + * ⚠️ 在 PHP 里算好再输出,**不用 CSS 的 `color-mix()`** —— + * FeedLite 实测过:`color-mix(in srgb, var(--brand) 9%, #fff)` + * 在 Chrome 里解析成透明(规则匹配上了但不生效)。 + * ⚠️ `$ratio` 是"往白里混多少",不是"保留多少": + * 0.91 = 91% 白 + 9% 原色(很浅),0.0 = 原色本身。 + */ +function fb_color_tint(string $hex, float $ratio): string +{ + $hex = trim($hex); + if (preg_match('/^#?([0-9a-fA-F]{6})$/', $hex, $m) !== 1) { + return ''; // 不是 6 位 hex(比如 rgba()/色名)就放弃,调用方退回默认 + } + $h = $m[1]; + $r = (int)hexdec(substr($h, 0, 2)); + $g = (int)hexdec(substr($h, 2, 2)); + $b = (int)hexdec(substr($h, 4, 2)); + $ratio = max(0.0, min(1.0, $ratio)); + return sprintf('#%02x%02x%02x', + (int)round($r + (255 - $r) * $ratio), + (int)round($g + (255 - $g) * $ratio), + (int)round($b + (255 - $b) * $ratio)); +} + function fb_entry_json(array $ev, string $detail = '', string $edit = '', array $extra = []): string { $date = (string)($ev['date'] ?? ''); diff --git a/assets/css/app.css b/assets/css/app.css index 7dad6b6..4863800 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -7339,3 +7339,133 @@ label.is-req, text-decoration: none; } .pv-fallback-dl:hover { background: #eee; } + +/* ══════════════════════════════════════════════════════════════════════ + #85 自绘下拉框(替换原生 select 的展开列表) + ══════════════════════════════════════════════════════════════════════ + 🔴 用户 2026-11-05:「网页版的下拉框也太丑了。你参考一下那个反馈系统 + (FeedLite)里面的下拉框到底是怎么做的?按它的做法做,它也有主题颜色, + 我们这个也有主题颜色,就按照一样的做法去做。」 + + **为什么必须自绘**:原生 `` 收起时能改样式,但**展开列表由浏览器绘制, + CSS 完全改不动**(只能改 option 底色),各平台样式还不统一。 + + 但**不重写 HTML**:原生 select 留在 DOM 里参与表单提交,我们只在旁边 + 生成一个按钮 + 浮层。好处: + · 全站所有 select 自动获得新样式,不用逐个改模板; + · `onchange="this.form.submit()"` 这类内联逻辑照样工作(我们派发原生 change); + · 无 JS 时原生 select 仍可用(只是朴素),不会白屏。 + + ⚠️ 浮层 `position: fixed` 挂 body —— 面板/浮层里大量 `overflow:hidden`, + 放 select 旁边会被裁掉(FeedLite 踩过这个坑)。 + ⚠️ AJAX 换进来的内容(任务面板)要**再次调用** `fbBindSelects()`, + 否则新 select 没有增强。已在 task_panel.paint / entry_popup.loadPanel 里接上。 + ══════════════════════════════════════════════════════════════════════ */ +function fbBindSelects(root) { + var scope = root || document; + var sels = scope.querySelectorAll('select'); + for (var i = 0; i < sels.length; i++) { + (function (sel) { + /* 幂等:已经增强过的跳过(AJAX 重画时会重复调用) */ + if (sel.getAttribute('data-sel-enhanced') === '1') { return; } + /* 明确排除的(比如需要在原位置显示多行列表的) */ + if (sel.hasAttribute('data-no-enhance')) { return; } + /* 多选 / 列表框不动 —— 那些本来就有自己的多行展示方式 */ + if (sel.multiple || (parseInt(sel.getAttribute('size'), 10) || 0) > 1) { return; } + /* 隐藏域也不要(有些地方用它传值) */ + if (sel.style.display === 'none' || sel.hidden) { return; } + + sel.setAttribute('data-sel-enhanced', '1'); + 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); + + 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 = ''; + 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'); + /* 先量宽度再定位:panel 刚显示时 offsetWidth 才有值 */ + 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 top = it.offsetTop, bot = top + it.offsetHeight; + if (top < panel.scrollTop) { panel.scrollTop = top; } + else if (bot > panel.scrollTop + panel.clientHeight) { + panel.scrollTop = bot - 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]); + } +} + +/* 对外暴露:AJAX 换进来的内容要再调一次 */ +window.fbBindSelects = fbBindSelects; + + + + /* #85:首屏自动增强所有 select */ + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { fbBindSelects(document); }); + } else { + fbBindSelects(document); + } + })(); diff --git a/assets/js/entry_popup.js b/assets/js/entry_popup.js index 2886aed..9e78d64 100644 --- a/assets/js/entry_popup.js +++ b/assets/js/entry_popup.js @@ -242,6 +242,10 @@ 让它先跑完;mountAntd* 有 data-antd-done 幂等标记,重复调用无害。 */ if (typeof window.fbBootAntd === 'function') { try { window.fbBootAntd(); } catch (e) { /* 挂不上就留在原生下拉,不阻断面板 */ } + /* #85:AJAX 换进来的 select 也要增强成自绘下拉 */ + if (typeof window.fbBindSelects === 'function') { + try { window.fbBindSelects(document); } catch (e2) { /* 忽略 */ } + } } /* 🔴 #78(用户 2026-11-05):「选人的时候不能按列表,因为人太多了。 你参考一下观影模式里面选人的方式。」 diff --git a/assets/js/task_panel.js b/assets/js/task_panel.js index 8ae2b7f..3f59cbf 100644 --- a/assets/js/task_panel.js +++ b/assets/js/task_panel.js @@ -91,6 +91,10 @@ ⚠️ 必须放在 `innerHTML` 之后 —— 要挂的节点得先真的在 DOM 里。 */ if (typeof window.fbBootAntd === 'function') { try { window.fbBootAntd(); } catch (err) { /* 挂不上就留在原生下拉,不阻断面板 */ } + /* #85:AJAX 换进来的 select 也要增强成自绘下拉 */ + if (typeof window.fbBindSelects === 'function') { + try { window.fbBindSelects(document); } catch (e2) { /* 忽略 */ } + } } /* #78:重画后选人组件是新节点 ⇒ 要重新接管(自带 data-pp-init 幂等标记)。 不补这一步的话,做过任何动作之后「更换负责人 / + 添加人」就点不动了。 */ diff --git a/templates/layout.php b/templates/layout.php index 618cb57..89499ae 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -134,9 +134,18 @@ if ($_needAddr): ?> 下面所有用到品牌色的地方(按钮、链接、页头高亮…)自动跟着变。 ⚠️ 只覆盖这三个变量,**不碰** --paper / --ink 这些纸墨底色 —— 模块色是用来"分区"的,把整页背景也换了就不是家书了。 */ ?> + + style="--brand: ; --brand-2: ; --brand-ink: ; + --brand-tint-1: ; --brand-tint-2: ; --brand-tint-3: ;">