Files
FamilyBook/assets/js/cs_picker.js
T
laoyang f1b09a2f41 v93: 首页日历本下拉能拉了 / 日程浮层只留编辑删除 / 重复日程三档 / Markdown 标题四级阶梯补齐
用户两条:
① 「新建日程,日历本是拉不下来的,是空的,好像只有一个"工作"什么的」
② 「点日程后的详情跟原来的弹框几乎一模一样,这个详情就没有意义了」
   ⇒ 只显示「编辑」「删除」;重复日程的删除/编辑要有三档
   (当前 / 从现在到以后 / 全部)

【① 根因】`_add_modal.php` 被日程页与首页共用,HTML 一样,但自绘下拉的整套 JS
只内联在 `templates/calendar/index.php` 底部 ⇒ 首页那份没有交互,点了菜单不展开;
而按钮上是服务端渲染的默认本("工作"),所以看着"只有一个工作"。
修法:抽 `assets/js/cs_picker.js`(全站 defer、按 `[data-cs-picker]` 初始化、
`data-cs-ready` 幂等)。不扫所有 `.cs` —— 观影表单的影院下拉有自己的内联绑定,
两边都绑会"点一次开两次"。

【②】浮层自定义日程不再显示「详情」(派生条目仍只有真链接「详情」);
删除/编辑三档措辞按用户原话;`occ` 为空时只给「删除全部」,绝不让空串触发整条删除。
编辑三档的入口在 `fb_entry_json()` 把 `occ` 拼进 edit 地址(`?d=`)——
不带就只有"修改全部"一档。

【顺带修掉 v85 没做完的一半】全量套件在 v61 A9b 报红,查下去不是本轮改坏的:
CSS 只把 `##`(h4)调到 17px,`###`(h5)留在 15.5px(正文 15px,只差 0.5px)、
`####`(h6)留在 14.5px(比正文还小),而那段注释自己写的就是"三级都要调大"。
阶梯重排 h3/h4/h5/h6 = 22/20/18/17,四级两两不同号。
更值得记的是四条判据全部只盯 h4(v61 A9b、v85 B2/B2b 前身、静态断言、线上 A4),
而 h4 恰好是唯一修好的那一级 ⇒ 各加"每一级/最矮的一级"判据,并用
「只把 h6 打回 14.5px ⇒ A9b 仍绿、只有 A9b2 变红」证明新判据有独立价值。

【工具链】`_b61_falsify.py` 的 main_all 改成每个 case 起独立子进程 ——
原先同一进程连跑 10 遍探针,Playwright 的临时 profile 建/删会在同一轮里累计,
撞沙箱批量删除钩子被 SIGTERM(症状:输出戛然而止、退出码 1、一条 FAIL 都没有)。

验证:v93 正向 28/0 · v93 反向 3 处注入各自被抓 · v61 34/0 · v61 反向 9/9 ·
v85 md_heading 12/0 · v85 静态反向 8/0 · 全量流水线通过并已部署。
2026-10-25 14:52:43 +08:00

164 lines
7.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================================
通用「色点下拉」(`.cs`)—— 全站一份
----------------------------------------------------------------------------
起源 v44(用户 2026-10-12 第 1 条):「新建日程,放在哪本,改成日历本后面加冒号。
现在这句话和日历本下拉框还有前面的颜色,仍然不在一行,我建议把颜色放在下拉框里面,
就跟电影的影院选择一样」。当时那段交互是**内联在 templates/calendar/index.php**
里的,只服务日程页那一个 `#calPicker`。
🔴🔴 v93(用户 2026-10-25):
「如果新建日程,日历本是拉不下来的,是空的,好像只有一个"工作"什么的。」
真根因:首页「今天的日程」卡片右上角的「新建日程」,用的是**与日程页同一份**
模板块(`templates/calendar/_add_modal.php`),HTML 一模一样 ⇒ 日历本那颗
按钮和菜单都在页面上;可**交互全在日程页的内联脚本里**,首页没有那一段
⇒ 按钮点了没反应、菜单永远 `hidden`。用户看到的就是"拉不下来";
而按钮上那个默认值(「工作」)是服务端渲染出来的,所以"只有一个工作"。
⇒ 把那段交互抽成这一份,**按标记初始化**(`[data-cs-picker]`):
不认具体 id、不认哪个页面 —— 谁 require 了模板块谁就能用。
契约(模板只需给容器加 `data-cs-picker`,其余全按类名找):
<div class="cs" data-cs-picker>
<button class="cs-btn"><span class="cs-dot"><span class="cs-text">…
<div class="cs-menu" hidden><div class="cs-list">…</div></div>
<select name="…"> ← 真正的提交字段(单一真值来源)
</div>
⚠️ **只认 `[data-cs-picker]`,不扫所有 `.cs`**:观影表单里的影院下拉
(`templates/movies/form.php` 的 `#csBtn`)有它自己的一套内联脚本,
两边都绑 = 点一次开两次、关两次(`add_modal.js` 头部记着同一个坑)。
⚠️ 幂等闸门 `data-cs-ready`:AJAX 换进来的表单(浮层里点「编辑」)要能被
重复初始化,但同一个容器绝不能被绑两遍。
⚠️ 列表项**从 `<select>` 重建**:单一真值来源是那个 select。
模板里已经输出的 `.cs-item` 只是"没有 JS 时的样子",不被当作数据源。
============================================================================ */
(function () {
'use strict';
var MARK = 'data-cs-picker';
var READY = 'data-cs-ready';
function initOne(box) {
if (!box || box.getAttribute(READY) === '1') { return false; }
var btn = box.querySelector('.cs-btn');
var menu = box.querySelector('.cs-menu');
var list = box.querySelector('.cs-list');
var sel = box.querySelector('select');
/* `.cs-dot` 在按钮里和列表项里都有 —— `querySelector` 按文档序取第一个,
而按钮一定排在菜单前面,所以取到的就是按钮里那颗。 */
var dot = btn ? btn.querySelector('.cs-dot') : null;
var text = btn ? btn.querySelector('.cs-text') : null;
if (!btn || !menu || !list || !sel) { return false; }
/* ---- 从 select 重建列表项 -------------------------------------------- */
function build() {
list.innerHTML = '';
Array.prototype.forEach.call(sel.options, function (o) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'cs-item' + (o.disabled ? ' is-disabled' : '');
b.setAttribute('role', 'option');
if (o.disabled) {
b.disabled = true;
b.title = o.getAttribute('title') || '';
}
b.setAttribute('data-id', o.value);
b.setAttribute('data-color', o.getAttribute('data-color') || '');
b.setAttribute('data-text', o.textContent || '');
var d = document.createElement('span');
d.className = 'cs-dot';
d.style.background = o.getAttribute('data-color') || '';
var s = document.createElement('span');
s.textContent = o.textContent || '';
b.appendChild(d);
b.appendChild(s);
list.appendChild(b);
});
}
/* ---- 按钮外观跟选中项走 ---------------------------------------------- */
function paint() {
var o = sel.options[sel.selectedIndex];
if (!o) { return; }
if (text) { text.textContent = o.textContent || ''; }
var c = o.getAttribute('data-color') || '';
if (dot) {
if (c) { dot.hidden = false; dot.style.background = c; } else { dot.hidden = true; }
}
}
function closeMenu() {
menu.hidden = true;
btn.setAttribute('aria-expanded', 'false');
box.setAttribute('data-open', '0');
}
function openMenu() {
menu.hidden = false;
btn.setAttribute('aria-expanded', 'true');
box.setAttribute('data-open', '1');
}
build();
paint();
/* ⚠️ 原生 select 的 change 也可能由别处触发(无 JS 路径 / 老脚本),
这里跟着刷新外观,保证"显示的值"永远是"要提交的值"。 */
sel.addEventListener('change', paint);
btn.addEventListener('click', function (e) {
e.stopPropagation();
if (menu.hidden) { openMenu(); } else { closeMenu(); }
});
menu.addEventListener('click', function (e) {
var it = e.target.closest ? e.target.closest('.cs-item') : null;
if (!it || it.disabled) { return; }
e.stopPropagation();
sel.value = it.getAttribute('data-id');
sel.dispatchEvent(new Event('change', { bubbles: true }));
closeMenu();
});
/* 点空白处收起。用 document 监听 + 判定 inside,不额外造遮罩节点。
⚠️ 必须**捕获阶段**:菜单项的 click 会 stopPropagation(冒泡阶段),
捕获阶段先于它,这里判"点在不在容器里"才精确。 */
document.addEventListener('click', function (e) {
if (menu.hidden) { return; }
if (box.contains(e.target)) { return; }
closeMenu();
}, true);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !menu.hidden) { closeMenu(); }
});
box.setAttribute(READY, '1');
return true;
}
/**
* 初始化 `scope` 内所有还没初始化的色点下拉。给动态插入的 HTML 用
* (浮层里点「编辑」→ AJAX 换进表单 → 这里补一次)。
*
* @param {Document|Element} [scope] 默认整个文档
* @return {number} 本次真正绑了几个(0 = 都已经是就绪状态)
*/
function init(scope) {
var root = scope || document;
var list = [];
if (root.matches && root.matches('[' + MARK + ']')) { list.push(root); }
var found = root.querySelectorAll('[' + MARK + ']');
for (var i = 0; i < found.length; i++) { list.push(found[i]); }
var n = 0;
for (var j = 0; j < list.length; j++) {
if (initOne(list[j])) { n++; }
}
return n;
}
window.fbInitCsPicker = init;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { init(document); });
} else {
init(document);
}
})();