Files
FamilyBook/assets/js/app.js
T

3084 lines
157 KiB
JavaScript
Raw Normal View History

/* 家书 · 前端小助手(无框架,无构建步骤) */
(function () {
'use strict';
// ===== 时间框:点输入框任意一处都弹出选择器 =====
// 原生行为只在点右侧那个小日历图标时才弹(而且各浏览器还不一致),
// 用户会以为框只能读、或者找不到那个图标在哪。
// showPicker() 是标准做法:在 click 里调它,等于把整块输入区都当成了那个图标。
//
// 为什么能跟浏览器自带的那次弹出共存:若本次点击已经弹过,再调一次是空操作
// (规范里「show the picker」对已经显示的选择器直接返回),不会闪两下。
// 不支持的浏览器(老 Safari 等)静默跳过 —— 退化成原生行为,不会比现在更差。
(function () {
var pickers = document.querySelectorAll('input[type="date"], input[type="datetime-local"]');
function hook(el) {
el.addEventListener('click', function () {
if (typeof el.showPicker !== 'function') { return; }
try {
el.showPicker();
} catch (err) {
// NotAllowedError:不是用户手势触发的(或被跨域 iframe 挡住)。
// 忽略即可 —— 原生那套点击行为还在,用户仍有办法选时间。
}
});
}
for (var i = 0; i < pickers.length; i++) { hook(pickers[i]); }
})();
// ===== 图片裁剪(v24 第 11 批 #5:改用 Cropper.js)=====
//
// 用户原话:「所有上传图片带有裁剪功能的地方,都需要重做,现在仍然不好用。
// 用 Cropper.js 这个库,弹框不要有任何滚动条,图片就无论多大,
// 就缩放到这个框内,也不要缩放条,鼠标滚轮滚动后,缩放大小,
// 裁剪框,要比外面的弹框小,让所有的裁剪触点都露出来。」
//
// 逐条落地:
// · **弹框不许有滚动条** → 面板 overflow:hidden;图片用 `img { max-width:100% }`
// 交给 Cropper 的 viewMode:1 自动缩放进容器,而不是让容器去滚动。
// · **不要缩放条** → 去掉原来那条 <input type="range">,改滚轮:
// Cropper 的 wheelZoomRatio 直接支持(默认 0.1,这里调小一点更精细)。
// · **裁剪框比弹框小、触点全露出** → viewMode:1(框只能在画布内)
// + 面板四周留出 padding(CSS 的 .crop-panel),保证四角四个手柄永远在框内可见。
// · **不同形状** → 比例仍由服务端 data-crop-ratio 决定(头像 1:1、题图 16:9…),
// 前端不再自己定数字。可拖动的方向由 aspectRatio 允许的轴向自动决定。
//
// ⚠️ 提交契约**完全不变**:确认时仍写回 `avatar_crop` = 归一化 "x,y,w,h"(0..1)。
// Cropper.getData() 给的是**原图像素**,所以要除以 naturalWidth/naturalHeight ——
// 服务端(app/Upload.php)继续按归一化矩形裁,绕过前端也存不进比例不对的图。
// ⚠️ Cropper.js 是按需加载的:页面上真有 data-crop-ratio 的文件框才去拉这个库
// (与 Semi 那套同一思路:不能让没用到裁剪的页面白拉)。
//
// 🔴🔴 v50(用户 2026-10-14 第 1 条)——**根因级修法:绑定可重复、可增量**。
// 用户原话:「旅程的详情页面,当我点击编辑的时候,里面的图片没有办法改变。
// 实际上当我把图片拖进去,或者选中一个图片的时候,应该弹出来一个让我
// 裁剪的那个框。但是现在却没有那个裁剪的框。」
//
// 真根因:这段以前是**一次性**的 ——
// `var inputs = querySelectorAll('[data-crop-ratio]'); if (!inputs.length) return;`
// 在**脚本加载那一刻**扫一遍,扫到就绑、扫不到(或后来才出现的)**永远不绑**。
// 而旅程详情页的「编辑旅程」是 **AJAX 取片段、innerHTML 塞进弹框**的
// (见 templates/trips/show.php 的 loadInto):
// 详情页首屏**没有**任何 `[data-crop-ratio]`(题图框在片段里)⇒ 这里当场
// `return` ⇒ 弹框里的题图框**从来没有 change 监听** ⇒
// 拖入/选中图片什么都不发生(`bindZone` 那条拖动逻辑会派发 change,
// 但**没人听**)⇒ 「图片改不了」。
//
// ⇒ 拆成两部分:
// ① `bindOne(input)`:给**单个**文件框绑 change(幂等,带 `data-crop-bound` 标记);
// ② `scan(scope)`:扫一遍并绑没绑过的;首屏调一次 + MutationObserver 补挂。
// ⚠️ 与「拖动上传」那段(bindInput/scan/MutationObserver)**同一手法** ——
// 它早就解决了同样的问题("动态插进来的文件框也要接得住"),
// 裁剪这段当年漏了这层。别再用"首屏一次性"的写法。
// ⚠️ 库(Cropper.js)的按需加载时机也要跟着改:**首次真要用时才拉**
// (不能在"扫到 0 个框"时就 return 掉 —— 那样动态来的框再也不会拉库)。
(function () {
var meta = document.querySelector('meta[name="fb-cropper"]');
if (!meta) { return; } // 页面没登记资源地址(不该发生,见 layout.php)
var conf;
try { conf = JSON.parse(meta.getAttribute('content')); } catch (e) { return; }
if (!conf || !conf.css || !conf.js) { return; }
var loading = null;
function loadCss(href) {
return new Promise(function (res) {
var l = document.createElement('link');
l.rel = 'stylesheet'; l.href = href;
l.onload = function () { res(true); };
l.onerror = function () { res(false); };
document.head.appendChild(l);
});
}
/* ⚠️ 与 Semi 段那个 `loadJs` 用**同一个全局登记表** —— 两处都加载 React
(Semi 无条件、antd 有条件),不共享去重就会执行两次 ⇒ React #321。
详见 Semi 段 `loadJs` 上方的完整说明。 */
function loadJs(src) {
var reg = window.__fbJsLoaded;
if (!reg) { reg = window.__fbJsLoaded = {}; }
if (reg[src]) { return reg[src]; }
reg[src] = new Promise(function (res, rej) {
var el = document.createElement('script');
el.src = src;
el.onload = function () { res(); };
el.onerror = function () { rej(new Error('加载失败: ' + src)); };
document.head.appendChild(el);
});
return reg[src];
}
function ensureCropper() {
if (window.Cropper) { return Promise.resolve(); }
if (loading) { return loading; }
loading = loadCss(conf.css).then(function () { return loadJs(conf.js); });
return loading;
}
/**
* 解析 data-crop-ratio;拿不到就退回 1(正方形)。
*
* ⚠️ 必须支持 **"16:9" 这种带冒号的写法**:`parseFloat('16:9')` 会得到 **16**
* (它遇到 ':' 就停),于是"题图"槽位会裁出一个 16:1 的细长条 ——
* 页面不报错、图也存下去了,只有肉眼看才发现不对。
* (旧自研版正因为同一处栽过,交互测试里专门留了一条断言守它。)
*/
function parseRatioAttr(v) {
var t = String(v == null ? '' : v).trim();
if (t === '') { return 1; }
var m = t.match(/^(\d+(?:\.\d+)?)\s*[:/x×]\s*(\d+(?:\.\d+)?)$/);
if (m) {
var a = parseFloat(m[1]), b = parseFloat(m[2]);
if (isFinite(a) && isFinite(b) && a > 0 && b > 0) { return a / b; }
}
var n = parseFloat(t);
return (isFinite(n) && n > 0) ? n : 1;
}
// ---- 弹框骨架(全页一个,复用)----
var overlay = null, panel = null, imgEl = null, hintEl = null, cropper = null;
var cur = null; // 当前编辑的 file input
var objUrl = '';
function build() {
overlay = document.createElement('div');
overlay.className = 'crop-overlay';
overlay.innerHTML =
'<div class="crop-panel" role="dialog" aria-modal="true" aria-label="裁剪图片">' +
'<div class="crop-head"><b>裁剪图片</b><span class="crop-shape"></span>' +
'<button type="button" class="crop-x" aria-label="关闭">✕</button></div>' +
// ⚠️ 这里**不再有** stage / 缩放条 / 自画框:全部交给 Cropper.js。
'<div class="crop-canvas"><img class="crop-img" alt=""></div>' +
'<p class="crop-tip"></p>' +
'<div class="crop-actions">' +
'<button type="button" class="btn btn-ghost crop-cancel">取消</button>' +
'<button type="button" class="btn crop-ok">就用这块</button>' +
'</div>' +
'</div>';
document.body.appendChild(overlay);
panel = overlay.querySelector('.crop-panel');
imgEl = overlay.querySelector('.crop-img');
hintEl = overlay.querySelector('.crop-tip');
overlay.querySelector('.crop-x').addEventListener('click', function () { close(true); });
overlay.querySelector('.crop-cancel').addEventListener('click', function () { close(true); });
overlay.addEventListener('click', function (e) { if (e.target === overlay) { close(true); } });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && overlay.classList.contains('is-open')) { close(true); }
});
overlay.querySelector('.crop-ok').addEventListener('click', confirm);
}
/**
* 关闭浮层。
*
* @param {boolean} cancelled true = 用户**取消**(点 ✕ / 取消 / 遮罩 / Esc)。
*
* ⚠️ 取消时必须把 file input 清空:否则用户以为"没选图",实际上表单里
* 仍挂着那张**没裁过的原图**,一提交就传上去了(旧自研版有这一步,
* 换成 Cropper.js 时被我漏掉,靠交互测试的"取消后文件框被清空"抓回来)。
* 确认(confirm)走的是另一条路 —— 那时文件正是用户要传的,不能清。
*/
function close(cancelled) {
if (cropper) { cropper.destroy(); cropper = null; }
if (overlay) { overlay.classList.remove('is-open'); }
if (objUrl) { URL.revokeObjectURL(objUrl); objUrl = ''; }
if (cancelled && cur) {
try { cur.value = ''; } catch (e) { /* 某些浏览器对 file.value 只读,忽略 */ }
// 顺带把已经写进去的裁剪矩形也清掉,避免"上次的框"跟着下一次提交
var f0 = cur.form || document;
var h0 = f0.querySelector('input[type="hidden"][name$="_crop"]');
if (h0) { h0.value = ''; }
}
cur = null;
}
/**
* 对外暴露当前「图片显示尺寸 + 裁剪框」,**只给测试用**。
*
* ⚠️ 存在的理由:验证"滚轮真的缩放了"不能只看"有没有 wheel 事件"——
* 那证明不了库真的动了图片。有了这个探针,测试可以断言
* "滚轮前后 getBoundingClientRect 的宽度确实变了"。
*/
window.__cropData = function () {
if (!imgEl) { return null; }
var d = cropper ? cropper.getData(true) : null;
var im = cropper ? cropper.getImageData() : null;
// ⚠️ 别用 imgEl.getBoundingClientRect() 量"显示尺寸":Cropper 把图片
// 放在 .cropper-canvas 里用 transform 缩放,img 自身的 rect 常常是 0。
// 真正代表"当前显示多大"的是 getImageData() 的 width/height(容器坐标)。
return {imgW: im ? Math.round(im.width) : 0,
imgH: im ? Math.round(im.height) : 0,
natW: im ? im.naturalWidth : 0,
crop: d ? {x: d.x, y: d.y, w: d.width, h: d.height} : null};
};
function confirm() {
if (!cropper || !cur) { close(); return; }
var d = cropper.getData(true); // true = 取整
var img = cropper.getImageData();
var nw = img.naturalWidth || 1;
var nh = img.naturalHeight || 1;
// 归一化:服务端要的就是"相对原图的 0..1"
function r3(v) { return Math.round(v * 1000) / 1000; }
var x = r3(d.x / nw), y = r3(d.y / nh), w = r3(d.width / nw), h = r3(d.height / nh);
var box = cur.closest ? cur.closest('.avatar-slot, .field, form') : null;
var hidden = box ? box.querySelector('input[type="hidden"][name$="_crop"]') : null;
if (!hidden) {
// 兜底:找同一张表单里任何 *_crop 隐藏字段
var f = cur.form || document;
hidden = f.querySelector('input[type="hidden"][name$="_crop"]');
}
if (hidden) { hidden.value = [x, y, w, h].join(','); }
// 选完给个即时反馈:预览图要显示**框里那块**,而不是原图。
//
// ⚠️ 这里曾经只写了 `pv.src = objUrl`(原图)—— 于是"上车"的是整张原图,
// 用户看到预览没有任何变化,会以为没裁上。用户当初的原话是
// 「确认后把框里那块画成预览(所见即所得)」,这是必须保住的行为。
// 做法:用 canvas 按 Cropper 给的**原图像素矩形**重新画一遍。
var prevSel = cur.getAttribute('data-crop-preview');
if (prevSel) {
var pv = document.querySelector(prevSel);
if (pv) {
try {
var cw = Math.max(1, Math.round(d.width));
var ch = Math.max(1, Math.round(d.height));
var cv = document.createElement('canvas');
cv.width = cw; cv.height = ch;
cv.getContext('2d').drawImage(
imgEl,
Math.round(d.x), Math.round(d.y), cw, ch, // 源:框里那块(原图像素)
0, 0, cw, ch); // 目标:整张预览
pv.src = cv.toDataURL('image/png');
} catch (e) {
// 画布被污染 / 内存不足等极端情况 → 退回原图,别让预览空着
if (objUrl) { pv.src = objUrl; }
}
}
}
close();
}
function open(input, file) {
cur = input;
if (!overlay) { build(); }
var ratio = parseRatioAttr(input.getAttribute('data-crop-ratio'));
var label = input.getAttribute('data-crop-label') || '图片';
var hint = input.getAttribute('data-crop-hint') || '';
overlay.querySelector('.crop-shape').textContent =
label + (ratio === 1 ? '(正方形)' : '(' + ratio.toFixed(2) + ':1)');
hintEl.textContent = hint !== '' ? hint
: '拖动图片挪位置,滚轮缩放;虚线框里就是最终保存的样子。';
if (objUrl) { URL.revokeObjectURL(objUrl); }
objUrl = URL.createObjectURL(file);
imgEl.src = objUrl;
overlay.classList.add('is-open');
ensureCropper().then(function () {
if (cropper) { cropper.destroy(); cropper = null; }
cropper = new window.Cropper(imgEl, {
// ★ 比例由槽位决定(头像 1:1、题图 16:9…),Cropper 会据此锁定缩放方向。
// ⚠️ 少了它,框能自由拉伸,"题图"就会被裁成任意比例。
aspectRatio: ratio,
// ① 框不能超出画布,且图片自动缩放铺进容器(**不产生滚动条**)
viewMode: 1,
// ② 图片可以比"容器"小或大,但始终被约束在画布内
dragMode: 'move',
// ③ 滚轮缩放(取代原来那根滑动条)
zoomOnWheel: true,
wheelZoomRatio: 0.06,
// ④ 不许把图片缩到看不见 / 放到糊
minContainerWidth: 200, minContainerHeight: 200,
minCropBoxWidth: 32, minCropBoxHeight: 32,
// ⑤ 触点要**全露出来**:框四周始终留出可抓的边(见 CSS .cropper-* 手柄)
autoCrop: true, autoCropArea: 0.86, center: true,
responsive: true, restore: false,
background: false,
guides: true, highlight: false, movable: true, rotatable: false,
scalable: false, zoomable: true,
toggleDragModeOnDblclick: false,
});
// 只给测试用:把实例露出去,便于用库 API 确定性地改变框尺寸
// (无头浏览器里 5×5 的触点很难稳定命中,靠鼠标拖会变成"测不稳")。
window.__cropperInst = cropper;
}).catch(function (err) {
// 拉不到库 → 退回"不裁剪":清空隐藏字段(服务端会**居中裁切**),
// 并明确告诉用户,而不是默默地不裁。
if (window.console) { console.warn('Cropper.js 未加载,改为中心裁切:', err && err.message); }
hintEl.textContent = '裁剪组件没加载出来 —— 保存时会自动居中裁切。';
var f = input.form || document;
var hid = f.querySelector('input[type="hidden"][name$="_crop"]');
if (hid) { hid.value = ''; }
});
}
/**
* 给**一个**文件框绑裁剪(幂等:已绑过的打标记跳过)。
* 只有带 `data-crop-ratio` 的框才需要裁剪 —— 其它 file input 不碰。
*/
function bindOne(input) {
if (!input || input.tagName !== 'INPUT' || input.type !== 'file') { return; }
if (!input.hasAttribute('data-crop-ratio')) { return; }
if (input.getAttribute('data-crop-bound') === '1') { return; } // 已绑,跳过
input.setAttribute('data-crop-bound', '1');
input.addEventListener('change', function () {
var f = input.files && input.files[0];
if (!f) { return; }
if (!/^image\//.test(f.type)) {
alert('请选择图片文件。');
input.value = '';
return;
}
open(input, f);
});
}
/** 扫一遍(可限定 scope)并绑上所有还没绑的 `[data-crop-ratio]` 框 */
function scan(scope) {
Array.prototype.forEach.call(
(scope || document).querySelectorAll('input[type="file"][data-crop-ratio]'),
bindOne);
}
scan(); // 首屏
/* 🔴 v50:**动态插进来的框也要接得住**(旅程「编辑旅程」是 AJAX 片段)。
⚠️ 与上面「拖动上传」那段用同一招:MutationObserver 补挂。
子树监听:片段是塞进某个容器里的,框在子树深处。 */
if (window.MutationObserver) {
new MutationObserver(function (muts) {
muts.forEach(function (m) {
Array.prototype.forEach.call(m.addedNodes || [], function (n) {
if (!n.querySelectorAll) { return; }
if (n.tagName === 'INPUT' && n.type === 'file') { bindOne(n); }
scan(n);
});
});
}).observe(document.body, { childList: true, subtree: true });
}
})();
/**
* 通用「点预览图 = 打开文件选择框」。
*
* ⚠️ 为什么必须做成通用的(2026-10-05 第 15 批 用户第 3 条实测踩到):
* 原来这句 `prev.addEventListener('click', () => file.click())` 只写在
* **templates/people/form.php 的内页脚本**里(那是"头像"专用的)。
* 后来加的「旅程题图」「图书封面」照抄了同一套 DOM(.cover-picker +
* #cover-preview + 隐藏的 file input),却**没有对应的 JS** ——
* 于是图块看着能点(CSS 给了 cursor:pointer 与 hover 高亮),
* 点下去什么都不会发生,用户以为功能坏了(原话:"点击哪里都没反应")。
*
* 做法:谁想"点图选文件",就在图上写 `data-pick-input="#文件框id"`,
* 这里统一接管(一个页面有多个也各绑各的)。声明式,模板里不再各写一段 JS。
*/
Array.prototype.forEach.call(
document.querySelectorAll('[data-pick-input]'),
function (pv) {
var sel = pv.getAttribute('data-pick-input');
var file = sel ? document.querySelector(sel) : null;
if (!file) { return; }
// ⚠️ 用 <label for> 包住图片也能触发文件框,但那样 hover/光标语义会变、
// 且部分浏览器在 label 里点 <img> 不会激活控件(要额外 CSS 才能稳)。
// 直接 click() 最省事,也与原来头像那一套保持一致。
pv.addEventListener('click', function () { file.click(); });
pv.style.cursor = 'pointer';
}
);
/**
* 拖动上传(v24 第 19 批 用户第 1 条)。
*
* 用户原话:「为所有的图片视频附件上传处,提供拖动上传的功能,就是允许用户,
* 通过把文件拖动到那里进行上传。」
*
* 做法:**给页面上每一个 `<input type="file">` 自动挂上**拖动接收 —— 不改模板,
* 于是全站的附件上传点(旅程照片、观影海报/票根/现场照片、资源、任务、日程附件、
* 图书封面与文件、名册头像与通讯录导入…)一次性都有了拖动能力。
* 新出现的文件框也接得住:MutationObserver 补挂(时间控件那套用过同一招)。
*
* 🔴 两个必须踩准的点:
* ① **必须真的把 drop 的文件写进 input.files**,不能只"显示个名字"。
* 写回去要用 DataTransfer:`dt.items.add(file)` 再 `input.files = dt.files`。
* 不这么做的话,用户以为拖上去了,一提交却发现什么都没上传。
* ② 写回之后要**手动派发一次 change** —— 页面上挂的预览 / 裁剪器都监听 change,
* 不派发的话拖进来的图不会弹裁剪框、也不会更新预览("拖了没反应")。
* 注意:`input.files = …` 本身**不会**触发 change(不是用户交互),必须手动补。
*
* ⚠️ 只接拖动落点,不改 accept 语义:accept 是给选择框用的提示,浏览器**不**拿它
* 过滤 drop(拖什么都进得来)。服务端本来就会再判一次 MIME,这里不重复造规则。
* ⚠️ dragover 里必须 preventDefault,否则浏览器不认这里是"可放下"的(默认行为是拒收)。
* ⚠️ 用计数器算 dragenter/dragleave:拖过子元素时也会触发 leave,
* 只按"进了/出了"切换高亮会让高亮疯狂闪。
*/
(function () {
/**
* 给一个"落点元素"挂拖动接收。
*
* @param {Element} zone 用户实际往上拖东西的那个元素(可见的那块)
* @param {Element} input 目标 <input type="file">
*
* ⚠️ 为什么落点与 input 要分开:题图/头像那类上传,`<input>` 是 **sr-only**
* (看不见),用户能看见、能点的是旁边那张预览图。只把拖动挂在 input 上,
* 用户往图上拖是**没有任何反应**的 —— 而"拖到那块图上"才是最自然的动作。
* 所以:有 `data-pick-input` 指向这个 input 的图,就把那图也当落点。
*/
function bindZone(zone, input) {
if (!zone || !input || zone.getAttribute('data-drop-bound') === '1') { return; }
zone.setAttribute('data-drop-bound', '1');
zone.className += (zone.className ? ' ' : '') + 'dropzone';
var depth = 0; // dragenter/dragleave 计数器(见上面的说明)
function over(state) {
if (state) { zone.classList.add('is-over'); } else { zone.classList.remove('is-over'); }
}
zone.addEventListener('dragenter', function (e) {
if (!hasFiles(e)) { return; }
e.preventDefault();
depth++;
over(true);
});
zone.addEventListener('dragover', function (e) {
if (!hasFiles(e)) { return; }
// 🔴 少了这句浏览器就不允许放下(默认是拒收),后头的 drop 根本不会来
e.preventDefault();
if (e.dataTransfer) { e.dataTransfer.dropEffect = 'copy'; }
over(true);
});
zone.addEventListener('dragleave', function (e) {
if (!hasFiles(e)) { return; }
depth--;
if (depth <= 0) { depth = 0; over(false); }
});
zone.addEventListener('drop', function (e) {
if (!hasFiles(e)) { return; }
e.preventDefault();
depth = 0;
over(false);
var files = Array.prototype.slice.call(e.dataTransfer.files || []);
if (!files.length) { return; }
// ⚠️ 目标是**暂存式**拖放区(第 20 批 第 4 条)→ 文件直接**即刻上传**,
// 一个接一个排队;用户可以接着再拖,最后一次"保存"统一认领。
// 普通文件框(没有 data-drop-stage)仍走第 19 批那套"写回 input.files"。
// 🔴 找落点必须**就近**:一个表单里可能有好几个(电影的 海报/票根/合影 三块),
// `form.querySelector('[data-drop-stage]')` 只会拿到**第一个** ——
// 那样往"票根"里拖会跑到"海报"上(用户看到的是"拖到这儿,结果上到那儿")。
// 所以先找**同一个 .field 里**的那个,找不到才退回表单里的第一个。
var stageBox = null;
var ownField = input.closest ? input.closest('.field') : '';
if (ownField) { stageBox = ownField.querySelector('[data-drop-stage]'); }
// v24 第 21 批 第 7 条:新结构里文件框是 **sr-only、且与框互为兄弟**
// (不再包在 .field 里 —— 片段自己画框,调用方只 require 一行)。
// 所以再认一次"显式指向":框上写了 data-sd-for="<input的id>"。
if (!stageBox && input.id) {
stageBox = document.querySelector('[data-drop-stage][data-sd-for="' + input.id + '"]');
}
// 最后才退回"表单里第一个"(老结构;多上传区的表单**必须**能就近命中,
// 否则往「票根」拖会跑到「海报」上 —— 第 20 批踩过,注释见上)
if (!stageBox && input.form) { stageBox = input.form.querySelector('[data-drop-stage]'); }
if (stageBox) {
queueDrop(stageBox, input, files);
return;
}
// 单选的只取第一个(多选也塞多个会与 accept 语义打架:
// 题图/头像那种单选框塞两个,浏览器提交的仍是第一个,但用户会以为两个都传了)
var multi = input.hasAttribute('multiple');
var put = multi ? files : files.slice(0, 1);
try {
var dt = new DataTransfer();
put.forEach(function (f) { dt.items.add(f); });
input.files = dt.files;
} catch (err) {
// 老浏览器不支持给 input.files 赋值 → 至少把光标引到框上,不让用户白拖
if (window.console) { console.warn('拖动上传:这个浏览器不支持把文件写进控件', err); }
input.focus();
return;
}
// 🔴 手动派发 change:预览与裁剪器都听它(input.files 赋值不会自己触发)
input.dispatchEvent(new Event('change', { bubbles: true }));
});
}
/* ============================================================
暂存式"拖一个就上传"(v24 第 20 批 用户第 4 条)
------------------------------------------------------------
用户原话:「所有上传图片视频和附件的模块,都允许多次拖动添加文件上传,
拖动一个进去,就开始上传,还可以继续拖动,继续上传,
最后统一保存即可。」
做法(三条链路,缺一不可):
① 拖进来 → **立刻** POST 到 upload_stage.php(`?r=upload/stage` 由路由转过去),
服务端落进暂存目录并回一个 `stoken`;
② 把 stoken 塞进隐藏字段(名字由落点给,见 `data-stage-field`)——
表单提交时这些令牌就跟着走了,业务路由在 attach_save_files 里**认领**;
③ 立刻画一张缩略图(指向 `media.php?stage=<stoken>`),
让用户看得见"这一张已经上去了"。
⚠️ 三个必须踩准的点(都是"看着能跑、实际会丢"的坑):
· **队列要串行**:用户一次拖 5 个/连着拖几次时,并发 POST 会让"完成顺序"
与"显示顺序"不一致,用户看到的缩略图顺序跳来跳去。这里用一个 FIFO 队列,
一张传完再传下一张(上传是本地网络,串行也够快)。
· **失败必须显示出来**:不能静默丢("我拖了三张,怎么只上去一张")。
失败的那张在缩略图上标红并写清原因。
· **保存前要拦住还在上传的**:用户拖完立刻点"保存"时,最后一张可能还在传。
提交时若队列没空,`preventDefault` 并提示等一会儿 —— 否则
"拖了但没保存上"是必然发生的(概率还不低)。
============================================================ */
/**
* 从 `<input type=file>` 里**摘掉某一个已上传成功的文件**(工单 #15 第 2 修)。
*
* 为什么需要它:暂存框把一个文件传到服务器后,会拿 `stoken` 塞进隐藏字段,
* 提交时服务端靠令牌认领。但文件同时也还在 `input.files` 里,
* 表单提交会带上 `$_FILES` ⇒ 服务端**两条路径各存一次** ⇒ 库里两行。
*
* 所以"传完这张"就要把它从 `input.files` 摘掉 —— 且**只摘这一张**。
*
* 实现:`input.files` 是只读 FileList,不能直接删。用 `DataTransfer`
* 重新装一遍剩下那些,再整体赋回去(这是浏览器里唯一通用的办法)。
* 比较用 name+size+lastModified(与 `bindStagePick` 的去重指纹同一口径)。
*/
function dropFromInput(input, target) {
if (!input || !input.files || !target) { return; }
var fp = function (x) {
return (x.name || '') + '|' + (x.size || 0) + '|' + (x.lastModified || 0);
};
var want = fp(target);
var keep = [];
var removed = false;
Array.prototype.slice.call(input.files).forEach(function (x) {
// 只摘**一张**:同名同大小同时间的重复选择里,后面那些继续留着
if (!removed && fp(x) === want) { removed = true; return; }
keep.push(x);
});
if (!removed) { return; }
try {
var dt = new DataTransfer();
keep.forEach(function (x) { dt.items.add(x); });
input.files = dt.files;
} catch (e) {
// 极老的浏览器没有 DataTransfer 构造器 —— 不影响主流程(大不了多存一行),
// 所以**不抛**,只留个痕迹方便排查。
try { input.setAttribute('data-sd-keep', '1'); } catch (e2) { /* noop */ }
}
}
function queueDrop(box, input, files) {
var form = input.form;
if (!form) { return false; }
// ⚠️ v24 第 21 批 第 7 条:清单与提示行现在在框**外面**(同一个 `.upfield` 容器里)——
// 清单放框内的话,加了五张图那个虚线框就变成"大框套着五张图"。
// 所以先在容器里找,找不到才退回框自身(`.upfield` 不在时至少别静默失效:
// 没有清单 = 上传成功却什么都不显示,用户会以为没上去)。
var host = (box.closest && box.closest('.upfield')) || box.parentNode || box;
var list = host.querySelector('[data-stage-list]') || box.querySelector('[data-stage-list]');
var tip = host.querySelector('[data-stage-tip]') || box.querySelector('[data-stage-tip]');
// 没清单就**别往下走**(见文件末尾 return 处的说明)——
// 否则文件传上去了、令牌也塞了,但用户一个字都看不见。
if (!list) { return false; }
// 队列状态挂在 box 上:**跨多次拖放**共享(用户拖一次、再拖一次,仍要串行)
var st = box.__stage || (box.__stage = { pending: 0, chain: Promise.resolve() });
// 提交守卫:还有没传完的就别让它提交(见上面第 ③ 点)。
// ⚠️ 只挂一次(用 __stageGuard 记着)—— 反复拖会让 form 上叠出十几个监听。
if (!st.guard) {
st.guard = true;
form.addEventListener('submit', function (ev) {
if (st.pending > 0) {
ev.preventDefault();
if (tip) { tip.textContent = '还有 ' + st.pending + ' 个文件在上传,稍等一下再保存。'; }
}
});
}
files.forEach(function (f) {
// 每张一个卡片:立刻出现"上传中",成功换成缩略图,失败标红
var cell = document.createElement('div');
cell.className = 'stage-cell is-uploading';
var nm = document.createElement('span');
nm.className = 'stage-name';
nm.textContent = f.name;
var meta = document.createElement('span');
meta.className = 'stage-meta';
meta.textContent = '上传中…';
cell.appendChild(nm);
cell.appendChild(meta);
if (list) {
list.appendChild(cell);
// 🔴 必须把它**显示出来**:模板里 `.stage-list` 初始带 `hidden`
// (没人上传时不留一条空边框)。不 un-hide 的话卡片确实建出来了、
// 但整条清单是 display:none —— 用户看到的是"拖了没反应",
// 而且缩略图也点不到(真机探针实测:卡片在 DOM 里、尺寸 0×0)。
list.hidden = false;
// 提示行同理(模板也给了 hidden)—— 它要说"已上传,可以继续拖"这类话,
// 藏着等于一个字都不说。
if (tip) { tip.hidden = false; }
}
st.pending++;
// 🔴 串行:把这次上传接到链尾。**不能 Promise.all** ——
// 并发时"谁先传完"不定,缩略图的顺序会跳(见上面第 ① 点)。
st.chain = st.chain.then(function () {
var fd = new FormData();
fd.append('file', f);
fd.append('_csrf', csrfToken(form));
// 🔴 工单 #15:`data-stage-slot` 渲染在**框**(`.upbox`)上,不在 input 上。
// 原来这里读 `input.getAttribute(...)` ⇒ **恒为空串**,`slot` 从来没传上去过。
// 对资源附件(photo)看不出差别,但海报/票根(2:3、按比例重裁)会拿到错的槽位。
// 两边都读:框上有就先用框的,兜底再读 input(兼容将来把属性挪过去)。
var slot = box.getAttribute('data-stage-slot')
|| input.getAttribute('data-stage-slot') || '';
if (slot) { fd.append('slot', slot); }
return fetch(stageUrl, { method: 'POST', body: fd, credentials: 'same-origin' })
.then(function (r) {
// ⚠️ 服务端哪怕回 419/500,也要**先试 json**:它总归是 JSON 契约;
// 解析不出来才按"不是 JSON"报(比如整页 HTML 的错误页)。
return r.json().catch(function () {
return { ok: false, error: '服务器返回的不是 JSON(可能登录已过期)。' };
});
})
.then(function (d) {
if (d && d.ok) {
cell.className = 'stage-cell';
cell.innerHTML = '';
if (d.is_image) {
var im = document.createElement('img');
im.src = d.url;
im.alt = '';
cell.appendChild(im);
} else {
var ic = document.createElement('span');
ic.className = 'stage-ico';
ic.textContent = d.kind || '文件';
cell.appendChild(ic);
}
var n2 = document.createElement('span');
n2.className = 'stage-name';
n2.textContent = d.name || f.name;
cell.appendChild(n2);
var m2 = document.createElement('span');
m2.className = 'stage-meta';
m2.textContent = d.size_word || '';
cell.appendChild(m2);
// 令牌进隐藏字段 → 随表单提交,服务端在 attach_save_files 里认领它
var hid = document.createElement('input');
hid.type = 'hidden';
// ⚠️ 字段名由落点决定,**不能写死**:
// · 通用附件是 `stage_token[]` —— 🔴 v24 第 21 批 第 4 条:
// 它原来叫 `stage[]`,与行程表单里那个 `stage`(plan/actual)
// 同名 → PHP 把 `$_POST['stage']` 合并成**数组** →
// 路由 `(string)` 强转成 "Array" → 静默退回 'plan'
// (症状:拖过图的"实际行程"一保存就变回计划)。
// · 海报/票根是 `stage_poster` / `stage_ticket`(各自单槽位、要按比例重裁),
// 写死通用名会让海报存成 1:1、页面上变形。
// 两条都有详细说明在 templates/_stage_drop.php 的头部。
hid.name = box.getAttribute('data-stage-field') || 'stage_token[]';
hid.value = d.stoken;
cell.appendChild(hid);
// 🔴🔴 工单 #15 第 2 修:**已经进暂存区的文件,必须从 input.files 里摘掉**。
//
// 这条是实测出来的 —— 我原来在这里写了「两边都认领也不会重复挂」,
// 那句话是**错的**。服务端有两条独立的入库路径:
// · `attach_save_files()`:先 `StagedUpload::claim(stage_token[])`,
// 再遍历 `$_FILES[$field]` 逐个 `Attachment::save()`
// · `movies/create|update`:先认领 `stage_token[]`,再认领 `$_FILES['photo']`
// 两条都追加到同一张 attachments 表 ⇒ **同一张图存两行**。
//
// 实测证据(`_tooling/_res_attach_db.py` 查库):
// 点选 1 个文件 → attachments 里 2 行,position 10 / 20,文件名相同;
// 观影「第35批」套件同时红 3 条:2 张→4 行、编辑 +1→6 行。
//
// ⚠️ 为什么可以放心摘:文件**已经在服务器暂存区**(拿了 stoken),
// 提交时靠这个令牌认领。`$_FILES` 只是同一份内容的**第二条冗余通路**。
// 摘掉它不丢文件,只是不让它被存第二遍。
// (之前不敢摘是怕"点选的文件反而没存上"—— 那是**没有实测**的猜测。)
//
// ⚠️ 必须**只摘这一张**,不能整个清空:多选时后面的还排在上传链里,
// 整个清空会让"还没传完的那几张"连表单都带不上(它们的令牌还没生成)。
dropFromInput(input, f);
// 撤回这张(令牌与卡片一起去掉;服务端的暂存由 TTL 清)
var x = document.createElement('button');
x.type = 'button';
x.className = 'stage-x';
x.textContent = '×';
x.title = '撤掉这张';
x.addEventListener('click', function () {
if (hid.parentNode) { hid.parentNode.removeChild(hid); }
if (cell.parentNode) { cell.parentNode.removeChild(cell); }
// 一张都不剩了就把清单藏回去(别留一条空边框 —— 与初始状态一致)
if (list && !list.querySelector('.stage-cell')) { list.hidden = true; }
if (tip) { tip.textContent = '已撤掉,保存时不会带上它。'; }
});
cell.appendChild(x);
if (tip) { tip.textContent = '已上传,可以继续拖;点「保存」时才真正记到这条上。'; }
} else {
// 🔴 失败必须**看得见**(写在卡片上,不弹 alert):静默丢是
// "我拖了三张怎么只上去一张"的根源。
cell.className = 'stage-cell is-failed';
meta.textContent = (d && d.error) ? d.error : '上传失败';
if (tip) { tip.textContent = '有文件没传上去,见红色那张。'; }
}
})
.catch(function (err) {
cell.className = 'stage-cell is-failed';
meta.textContent = '网络错误:' + (err && err.message ? err.message : '重试一下');
if (tip) { tip.textContent = '有文件没传上去,见红色那张。'; }
})
.then(function () { st.pending--; }); // 无论成败都要减,否则守卫会把表单永久锁住
});
});
return true;
}
/**
* 「**点选**文件后也要有反应」—— 工单 #15。
*
* 用户原话:「资源模块新建资源时,有一个弹窗。点击'上传附件'选择文件后,
* 页面没有任何变化。虽然文件实际上已经上传成功(保存也能正常保存),
* 但界面并没有显示已上传,导致无法确认附件是否真正上传成功。」
*
* 🔴 根因:**暂存框只接了拖放,没接 change**。
* 这个片段(`_stage_drop.php`)的框有三个入口:
* · 点框 → `bindBox` → `input.click()` (能打开选择框 ✓)
* · 拖文件进来 → `bindZone` 的 drop → `queueDrop()` (有缩略图 ✓)
* · **点选文件** → 触发 input 的 `change` 事件 (**没人听** ✗)
* `bindFileEcho()` 里那句 `if (box.hasAttribute('data-drop-stage')) { return; }`
* 是**故意**跳过暂存框的(理由写在它上面:暂存框有自己的清单 UI,别抢)。
* 问题在于:暂存框自己的清单 UI **只在 queueDrop 里画**,而 queueDrop
* 只被 drop 事件调用 ⇒ 点选这条路,文件进了 `input.files`、
* 也随表单提交成功了(所以用户说"保存也能正常保存"),
* 但界面上**一个像素都没变**。
*
* ⚠️ 为什么不是"也给暂存框挂 bindFileEcho":那只会把框里那句话换成文件名,
* 而用户真正需要知道的是"**上传成功没有**"—— 状态、失败原因、能否撤掉。
* 这些只有 queueDrop 的卡片能给。所以正确修法是**把文件送进 queueDrop**,
* 让点选和拖放走**同一条**链路(behavior parity,而不是各写一套 UI)。
*
* ⚠️⚠️ 关于 `input.files` 与 `$_FILES`(这条我一开始**判断错了,已实测纠正**):
* 我原来在这里写的是"直接调 queueDrop 不能清空 input.files,
* 因为服务端还会看 `$_FILES`,清空会让点选的文件反而没存上;
* 而两边都认领也不会重复挂"。
*
* 后半句是**错的**,而且代价很实在:
* 服务端有两条**独立**的入库路径(`attach_save_files()` 里先 claim 令牌、
* 再遍历 `$_FILES` 逐个 save;`movies/create` 里同样两段),
* 两边都追加到同一张 attachments 表 ⇒ **同一张图存两行**。
* 查库实测:点选 1 个文件 → 库里 2 行;观影第 35 批套件同时红 3 条。
*
* ⇒ 正确做法是 **传完就把它从 `input.files` 摘掉**(见 `dropFromInput`),
* 只留暂存令牌这一条路径。文件已经在服务器暂存区,令牌负责认领,不会丢。
* ("清空会丢"是我当时的猜测,没有实测支撑 —— 所以现在改成有实测的写法。)
*
* ⚠️ 要**去重**:`change` 事件在"同一个文件选两次"时也会触发(清空再选),
* 重复调用 queueDrop 会让同一份文件传两遍、出现两张卡片。
* 这里用 `data-sd-picked` 记上一次的指纹(name+size+lastModified)。
*/
function bindStagePick(box, input) {
if (!box || !input || box.getAttribute('data-sd-pick') === '1') { return; }
// 只给**暂存型**框挂(非暂存框由 bindFileEcho 负责换文案,别抢)。
if (!box.hasAttribute('data-drop-stage')) { return; }
box.setAttribute('data-sd-pick', '1');
input.addEventListener('change', function () {
var picked = input.files ? Array.prototype.slice.call(input.files) : [];
if (!picked.length) { return; }
var fp = picked.map(function (f) {
return (f.name || '') + '|' + (f.size || 0) + '|' + (f.lastModified || 0);
}).join(',');
if (fp === box.getAttribute('data-sd-picked')) { return; }
box.setAttribute('data-sd-picked', fp);
queueDrop(box, input, picked);
});
}
/** 取当前表单里的 CSRF(上传接口要带;与 csrf_field() 同一个值) */
function csrfToken(form) {
var el = form.querySelector('input[name="_csrf"]');
return el ? el.value : '';
}
/** 暂存上传地址(从 meta 读,页面上没有就退回约定路由) */
var stageUrl = (function () {
var m = document.querySelector('meta[name="fb-stage-url"]');
return (m && m.getAttribute('content')) || 'index.php?r=upload/stage';
})();
/** 这次拖动里有没有真文件(拖选中的文字/链接也会触发 drag 事件,那些不该管) */
function hasFiles(e) {
var t = e.dataTransfer;
if (!t) { return false; }
if (t.types) {
for (var i = 0; i < t.types.length; i++) {
if (t.types[i] === 'Files') { return true; }
}
}
return false;
}
/**
* 一个 input:既给它自己挂落点,也给"指向它的那张预览图 / 那个上传框"挂(见 bindZone)。
*
* ⚠️ v24 第 21 批 第 7 条:给「点击或拖动进行上传」那个框补**点击**。
* 用户原话:「点击这个框实现点击上传按钮的功能」——
* 框是 <div role=button>,它**不会**自己打开文件选择框(那是 <input>/<label> 的能力),
* 所以必须显式 `input.click()` 转发过去。
* ⚠️ 键盘同样要能操作(Tab 到框 + 回车/空格):只做鼠标的话,
* 键盘用户就完全用不了上传了。弹层/选人组件的既有做法也是这一套。
* ⚠️ 拖动由 bindZone 负责(它同时给了高亮);这里只管"点"。
*/
function bindBox(box, input) {
if (!box || box.getAttribute('data-sd-click') === '1') { return; }
box.setAttribute('data-sd-click', '1');
box.addEventListener('click', function (ev) {
// ⚠️ 点暂存清单里的「撤掉这张」时不要又弹一次文件选择框
// (那个 × 按钮在框**外面**,但保险起见还是挡一道 —— 结构将来可能挪)。
if (ev.target && ev.target.closest && ev.target.closest('button')) { return; }
input.click();
});
box.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter' || ev.key === ' ' || ev.key === 'Spacebar') {
ev.preventDefault();
input.click();
}
});
}
/**
* 「选中文件后要有反应」—— v30 用户第 1 条。
*
* 用户原话:「导入日程,选中文件后,那个地方没有任何反应,虽然能导入,
* 但是体验不好,选中文件后,那个文件输入框,得有点反应才对。」
*
* 根因:**非暂存**的上传框(导入型:ics / 通讯录)在选中文件后**什么都不显示** ——
* 暂存型框靠 queueDrop() 画缩略图卡片,而导入型只是把 File 写进 `<input>`,
* 框里那句"点击或拖动进行上传"从头到尾不变,用户完全看不出"到底选上没有"。
* ⇒ 这里给所有"指向某个 file input 的框"补一条 change 监听:
* 选到文件就把框里的话换成**文件名**(+ 可点"重选"),选不到(取消)就还原。
*
* ⚠️ 只认 `data-sd-for` 指向的那些框(片段自己声明的),不碰别的上传区 ——
* 暂存型框有自己的清单 UI,这里插一脚会和它打架。
* ⚠️ 用**原始**文案做还原基准(`data-sd-msg`):用户点了重选又取消时,
* 框里那句话必须回到原文,不能变成空字符串。
*/
function bindFileEcho(box, input) {
if (!box || !input || box.getAttribute('data-sd-echo') === '1') { return; }
// ⚠️ **只给非暂存框**挂(暂存型框由 queueDrop 画缩略图卡片,别去抢它的 UI)。
// 暂存型框带 data-drop-stage;导入型不带(见 _stage_drop.php 头部的 v30 说明)。
if (box.hasAttribute('data-drop-stage')) { return; }
box.setAttribute('data-sd-echo', '1');
// 记下原始文案(片段渲染的那句「点击或拖动进行上传」或 $sdMsg)
if (!box.hasAttribute('data-sd-msg')) {
var msgEl = box.querySelector('.upbox-msg');
box.setAttribute('data-sd-msg', msgEl ? msgEl.textContent : '');
}
var msgEl2 = box.querySelector('.upbox-msg');
var icoEl = box.querySelector('.upbox-ico');
function paint() {
var f = input.files && input.files[0];
var list = input.files ? Array.prototype.slice.call(input.files) : [];
if (!list.length) {
// 没选(或用户取消了)→ 还原成原始提示
box.classList.remove('is-picked');
if (msgEl2) { msgEl2.textContent = box.getAttribute('data-sd-msg'); }
if (icoEl) { icoEl.textContent = '+'; }
return;
}
box.classList.add('is-picked');
if (icoEl) { icoEl.textContent = '✓'; }
if (msgEl2) {
// 单选直接给文件名;多选给"第一个 + 等 N 个"
msgEl2.textContent = list.length === 1
? ('已选择:' + (f.name || '文件') + '(点这里可重选)')
: ('已选择 ' + list.length + ' 个文件:' + (f.name || '') + ' 等(点这里可重选)');
}
}
input.addEventListener('change', paint);
// 拖进来的文件也会派发 change(见 bindZone),所以这一条同时覆盖"点选"和"拖入"
paint();
}
/** 找"属于这个 input 的上传框/预览图",逐个挂上 */
function bindInput(input) {
bindZone(input, input);
var sel = input.id ? ('[data-pick-input="#' + input.id + '"]') : '';
if (sel) {
Array.prototype.forEach.call(document.querySelectorAll(sel), function (pv) {
bindZone(pv, input);
bindBox(pv, input);
});
}
// 新结构:框自己声明 data-sd-for 指向这个 input。
// 🔴 v30 用户第 1 条:选择器**不能**带上 `[data-drop-stage]` 这一层 ——
// "导入型"上传框(ics / 通讯录)故意**不带** data-drop-stage(它要走
// "写回 input.files、随表单提交、PHP 读完即弃"的老路,见 _stage_drop.php 头部)。
// 带上它就会漏掉这些框 → 框既点不开文件选择框、也接不住拖动。
if (input.id) {
Array.prototype.forEach.call(
document.querySelectorAll('[data-sd-for="' + input.id + '"]'),
function (bx) { bindZone(bx, input); bindBox(bx, input); bindFileEcho(bx, input);
bindStagePick(bx, input); });
}
}
function scan(scope) {
Array.prototype.forEach.call(
(scope || document).querySelectorAll('input[type="file"]'), bindInput);
}
scan();
// 动态加进来的文件框(工作经历那种"再来一段"、日历弹框里的附件…)也要接得住
if (window.MutationObserver) {
new MutationObserver(function (muts) {
muts.forEach(function (m) {
Array.prototype.forEach.call(m.addedNodes || [], function (n) {
if (!n.querySelectorAll) { return; }
if (n.tagName === 'INPUT' && n.type === 'file') { bindInput(n); }
scan(n);
});
});
}).observe(document.body, { childList: true, subtree: true });
}
})();
// 头像选好后即时预览(仅用于**没走裁剪器**的文件框,避免和裁剪预览打架)
var fileInput = document.getElementById('avatar');
if (fileInput && !fileInput.hasAttribute('data-crop-ratio') && window.FileReader) {
fileInput.addEventListener('change', function () {
var f = this.files && this.files[0];
if (!f) { return; }
var img = document.getElementById('avatar-preview');
if (!img) { return; }
var reader = new FileReader();
reader.onload = function (ev) { img.src = ev.target.result; };
reader.readAsDataURL(f);
});
}
// 下拉菜单:点外面 / 按 Esc 就收起来(用 <details> 实现,没这段也能用)
Array.prototype.forEach.call(document.querySelectorAll('details.menu'), function (d) {
document.addEventListener('click', function (e) {
if (d.open && !d.contains(e.target)) { d.open = false; }
});
document.addEventListener('keydown', function (e) {
if (d.open && e.key === 'Escape') { d.open = false; }
});
});
// 头像加载失败时换回内置剪影 —— 保证任何时候都不会出现破图
Array.prototype.forEach.call(document.querySelectorAll('img'), function (img) {
img.addEventListener('error', function () {
if (img.dataset.fallbackDone) { return; }
img.dataset.fallbackDone = '1';
img.src = '/assets/avatar-default.svg';
});
});
// ===== 三个生日:选择"以哪个为准" =====
// 阳历与农历其实是同一个人的同一天,不能各填各的(那样就自相矛盾了)。
// 表单上让用户明确指定"我这次填的是哪一个",服务端据此反推另一个。
(function () {
var sel = document.getElementById('bdSource');
if (!sel) { return; }
var solarBox = document.getElementById('bdSolarWrap');
var lunarBox = document.getElementById('bdLunarWrap');
function sync() {
var isLunar = sel.value === 'lunar';
if (solarBox) { solarBox.classList.toggle('is-primary', !isLunar); }
if (lunarBox) { lunarBox.classList.toggle('is-primary', isLunar); }
}
sel.addEventListener('change', sync);
sync();
})();
// ===== 身份证号 → 即时算出「身份证生日」 =====
// 让用户当场看到"系统从证件里读出来的是哪一天",
// 而不是存下去之后才发现跟自己的生日差了一个月。
(function () {
var idc = document.getElementById('id_card');
var out = document.getElementById('idCardBirthday');
if (!idc || !out) { return; }
function pad(n) { return (n < 10 ? '0' : '') + n; }
function calc() {
var s = (idc.value || '').toUpperCase().replace(/\s/g, '');
var y, m, d;
if (/^\d{17}[\dX]$/.test(s)) {
y = +s.substr(6, 4); m = +s.substr(10, 2); d = +s.substr(12, 2);
} else if (/^\d{15}$/.test(s)) {
y = 1900 + +s.substr(6, 2); m = +s.substr(8, 2); d = +s.substr(10, 2);
} else {
out.textContent = '';
return;
}
if (m < 1 || m > 12 || d < 1 || d > 31) { out.textContent = ''; return; }
out.textContent = y + '-' + pad(m) + '-' + pad(d);
}
idc.addEventListener('input', calc);
calc();
})();
// ===== 补充联系方式:「再加一个」克隆出下一行 =====
// 平行数组命名(ct_<kind>_label[] / ct_<kind>_value[])→ 克隆行就是合法的追加,
// 服务端按下标 zip,不需要任何计数器字段。
(function () {
document.addEventListener('click', function (e) {
var btn = e.target.closest ? e.target.closest('.ct-add') : null;
if (!btn) { return; }
var block = btn.closest('.ct-block');
if (!block) { return; }
var rows = block.querySelectorAll('[data-ct-row]');
if (!rows.length) { return; }
var clone = rows[rows.length - 1].cloneNode(true);
Array.prototype.forEach.call(clone.querySelectorAll('input'), function (i) { i.value = ''; });
rows[rows.length - 1].parentNode.insertBefore(clone, btn);
var first = clone.querySelector('input');
if (first) { first.focus(); }
});
})();
// ===== 名册:拖动卡片改顺序 / 拖到别的一栏改分组 =====
//
// 落库的是**整版布局**(每栏的有序 id 列表),一次提交写完 ——
// 逐条提交会在中途留下自相矛盾的中间态(两个人暂时同号),刷新一下就变样。
//
// 🔴🔴 这一段**不用 HTML5 拖放**(dragstart/dragover/drop),改用 Pointer Events。
// 换掉的原因(用户 2026-10-04 第 6 条:「chrome 下只有第一组可以拖动」):
// · HTML5 DnD 的拖放是浏览器在"协商"的:dragstart 之后必须由我们在
// dragover 里持续 preventDefault 才允许放下,而一旦拖动过程中**改了 DOM**
// (本功能本来就要把卡片挪来挪去),Chrome 会中途撤销这次拖放 ——
// 表现就是"第一栏能拖,后面的栏拖着拖着就断了"。
// · 它**没有自动滚动**:名册长了以后下面的分组在视口外,根本够不着。
// · 手机上 HTML5 DnD 一律不生效(这条以前是靠「⋯」表单兜的)。
// Pointer Events 三件事全都自己说了算:落点、自动滚动、触摸。
//
// 没有这段 JS 也完全能用:行上的「分组」按钮(<details> 里的表单)照样能改分组。
(function () {
var roster = document.querySelector('.roster.is-droppable');
if (!roster || !window.FormData || !window.fetch) { return; }
var url = roster.getAttribute('data-reorder');
var csrf = roster.getAttribute('data-csrf') || '';
var MIN_MOVE = 5; // 超过这个位移才算"在拖",否则只是点了一下
var EDGE = 72; // 指针离视口上下边缘多少像素开始自动滚动
var EDGE_SPD = 18; // 自动滚动速度(像素/帧)
var pending = null; // 按下但还没到门槛
var dragging = null; // 真正在拖的那张卡
var suppressClick = false; // 拖完要掐掉紧接着的那次 click(否则会跳进详情页)
var startPt = null;
var lastPt = null;
var rafId = 0;
var noteEl = null;
function each(list, fn) { Array.prototype.forEach.call(list, fn); }
/* 🔴 v28 用户第 5 条:名册从「分栏」改成**平铺**(一个人可挂多个标签,
分栏会让他在多栏里重复出现)。拖动因此只剩"排序"这一件事。
⇒ 把整个 `.roster` 当成**唯一一栏**返回:下游的 placeAt / save / refreshCounts
全都按"栏"来写,只要这里返回一个元素,它们不用改就能继续工作
(比重写三条逻辑安全得多,也不会漏掉其中一条)。
⚠️ 返回的是 `[roster]` 而不是 `.people-grid`:placeAt 里会 `block.querySelector('.people-grid')`,
所以仍要有一个带 `.people-grid` 的容器;模板里 roster 直接包含 .people-grid。 */
function blocks() {
var flat = roster.querySelector('.people-grid');
return flat ? [roster] : [];
}
/** 从事件目标往上找那张可拖的卡片(点在卡片里的链接/表单上时也算这张卡) */
function cardFrom(node) {
var t = node;
while (t && t !== roster) {
if (t.classList && t.classList.contains('person')) { return t; }
t = t.parentNode;
}
return null;
}
/**
* v24 第 25 批 用户第 5 条:拖拽的起手判据只剩一条 —— **必须按在 ⠿ 手柄上**。
*
* 原来的 `isControl()`(判断"这一点是不是按钮/输入框/大链接")**已删除**:
* 那个函数存在的前提是"整行都能拖",而用户明确要求改成
* 「点击列表中的人,是直接查看这个人的信息,拖动的时候才是调整数序」。
* 整行不再参与拖动之后,"哪些控件不该触发拖拽"这个问题就不存在了 ——
* 留着一个没人调用的函数只会让下一个人以为还有第二条拖拽路径。
*/
/** 每栏人数与"空栏"标记跟着 DOM 走 —— 不然拖完之后标题上的「N 人」还是旧的。
* ⚠️ v28:平铺之后只剩"一栏"(就是 .roster 本身),它**没有 .group-n 也没有
* data-group**,所以下面这些查询都会拿到 null —— 全部做了存在性判断,
* 并且**绝不能**再把 `b.hidden` 设成"没人就隐藏":
* 那会把整张名册(roster 自己)藏起来。平铺下没有"空栏"这个概念。 */
function refreshCounts() {
each(blocks(), function (b) {
var n = b.querySelectorAll('.people-grid .person').length;
var nEl = b.querySelector('.group-n .n');
if (nEl) { nEl.textContent = String(n); }
});
}
/** 右下角那个小提示(成功/失败都只活两秒,不打断操作) */
function note(text, ok) {
if (!noteEl) {
noteEl = document.createElement('div');
noteEl.className = 'roster-note';
document.body.appendChild(noteEl);
}
noteEl.textContent = text;
noteEl.className = 'roster-note is-show' + (ok ? '' : ' is-err');
window.clearTimeout(note._t);
note._t = window.setTimeout(function () {
noteEl.className = 'roster-note';
}, 2200);
}
function save() {
var cols = [];
each(blocks(), function (b) {
var ids = [];
each(b.querySelectorAll('.people-grid .person'), function (c) {
var pid = parseInt(c.getAttribute('data-person'), 10);
if (pid > 0) { ids.push(pid); }
});
/* ⚠️ v28:平铺之后**不报 `g`** —— 见 PersonRoster::applyLayout 的注释:
报了组号(哪怕是 0)就会覆盖 group_id,拖一次就把所有人的旧分组清空,
而那是"旧分组→同名标签"迁移的数据源、也是 vCard 的读点。
不报 `g` = 只更新顺序,这正是"拖动只改先后"的新语义。 */
cols.push({ ids: ids });
});
var body = new URLSearchParams();
body.append('_csrf', csrf);
body.append('layout', JSON.stringify(cols));
window.fetch(url, {
method: 'POST',
credentials: 'same-origin',
// 让服务端知道这是 AJAX:它就回 JSON 而不是 302(不然 flash 会攒到下次刷新才冒出来)
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: body,
}).then(function (r) {
return r.ok ? r.json() : null;
}).then(function (j) {
if (j && j.ok) {
note('已保存', true);
} else {
note('没保存上,刷新页面再试一次', false);
}
}).catch(function () {
note('没保存上,刷新页面再试一次', false);
});
}
/** 按当前指针位置把拖着的卡插到正确的栏、正确的位置 */
function placeAt(pt) {
// ⚠️ 落点必须用 elementFromPoint 现算:拖动的卡自己就在指针底下,
// 而它在 CSS 里是 pointer-events:none(见 .person.is-dragged),
// 所以拿到的永远是它**下面**那个元素 —— 正是落点。
var under = document.elementFromPoint(pt.x, pt.y);
if (!under) { return; }
var block = under.closest ? under.closest('.group-block') : null;
if (!block || !roster.contains(block)) { return; }
var grid = block.querySelector('.people-grid');
if (!grid) { return; }
/* 落点判定(用户 2026-10-03 第 4 条:"好像只能往后调整,不能往前调整")。
名册是**竖向列表**,所以:指针在某行的**上半部分** → 插到它前面;
下半部分 → 插到它后面。
⚠️ 候选里要**排除 dragging 自己**,否则它自己会变成参照物、
两个方向都会算成"不动"(这正是"往前拖无效"的根因)。 */
var kids = [];
each(grid.querySelectorAll('.person'), function (c) {
if (c !== dragging) { kids.push(c); }
});
var target = null; // 落点参照物
var before = true; // 插到它的前面还是后面
for (var i = 0; i < kids.length; i++) {
var r = kids[i].getBoundingClientRect();
if (pt.y < r.top + r.height / 2) {
target = kids[i];
before = true;
break;
}
target = kids[i];
before = false;
}
if (!target) {
if (grid.lastChild !== dragging) { grid.appendChild(dragging); }
} else if (before) {
if (target.previousSibling !== dragging) { grid.insertBefore(dragging, target); }
} else {
if (target.nextSibling !== dragging) { grid.insertBefore(dragging, target.nextSibling); }
}
}
/**
* 自动滚动:指针靠近视口上下边缘时带着页面走。
* 名册长到一千人时,下面的分组在视口外面 —— 没有它根本拖不过去。
*/
function autoScroll() {
rafId = 0;
if (!dragging || !lastPt) { return; }
var dy = 0;
if (lastPt.y < EDGE) { dy = -Math.round(EDGE_SPD * (1 - lastPt.y / EDGE)); }
else if (lastPt.y > window.innerHeight - EDGE) {
dy = Math.round(EDGE_SPD * (1 - (window.innerHeight - lastPt.y) / EDGE));
}
if (dy !== 0) {
window.scrollBy(0, dy);
placeAt(lastPt);
}
rafId = window.requestAnimationFrame(autoScroll);
}
function beginDrag(card, pt) {
dragging = card;
// ⚠️ v24 第 25 批:`is-dragging` 从 pointerdown 挪到了这里(见 pointerdown 的说明)。
// 只有从 ⠿ 手柄起拖,所以"提前露空栏"不再必要 —— 真开始拖时再露。
roster.classList.add('is-dragging');
card.classList.add('is-dragged');
placeAt(pt);
if (!rafId) { rafId = window.requestAnimationFrame(autoScroll); }
}
function endDrag() {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
if (!dragging) { pending = null; return; }
dragging.classList.remove('is-dragged');
roster.classList.remove('is-dragging');
dragging = null;
pending = null;
// ⚠️ 紧接着的那次 click 要掐掉:指针从卡片上的链接按下、拖走了又松开,
// 浏览器会当成"点了那个链接"→ 直接跳进详情页,拖了半天等于白拖。
suppressClick = true;
window.setTimeout(function () { suppressClick = false; }, 0);
refreshCounts();
save();
}
roster.addEventListener('pointerdown', function (e) {
if (e.button !== 0 && e.pointerType === 'mouse') { return; }
var card = cardFrom(e.target);
if (!card) { return; }
/* 🔴🔴 v24 第 25 批 用户第 5 条:**只有从 ⠿ 手柄上按下才算拖动**。
*
* 用户原话:「现在名册的操作还是很不好,应该是点击列表中的人,是直接查看这个人的信息,
* 拖动的时候才是调整数序,现在是混乱的。」
*
* 改之前的行为(三处叠加,所以"混乱"):
* ① 鼠标在整行任何非控件处按下都算"待拖";
* ② 而且**按下那一刻**就给 .roster 加 `is-dragging`(本来是为了提前露出空栏当落点);
* ③ 于是轻轻点一下卡片,空栏先"冒出来"、页面高度跳一下,视觉上就是"我点了一下,
* 页面自己动了一格";抬起时又收回去 —— 用户根本分不清自己是在点还是在拖。
* ⇒ 现在:**整行都不再触发拖拽**;只有那枚 ⠿(.drag-handle,专门用来抓的)
* 按下才进入待拖状态。点击整行 = 沿 .person-main 链接进详情页(原样保留)。
*
* ⚠️ 鼠标与触摸**同一判据**(不再按 pointerType 分叉):
* 以前触摸只认手柄、鼠标认整行 —— 两种设备手感不同,用户换台机器就"又变了"。
* 统一成"都只认手柄"之后,语义只有一条:**拖这个 ⠿**。
* ⚠️ `is-dragging` 也挪到 beginDrag() 里加(不再在 pointerdown 加):
* 既然只有手柄能起拖,就没有"提前露空栏"的必要了 ——
* 手柄本身很小,用户按下它就是明确要拖,那一刻再露空栏不影响瞄准。 */
var fromHandle = !!(e.target.closest && e.target.closest('.drag-handle'));
if (!fromHandle) { return; }
pending = { card: card, id: e.pointerId, type: e.pointerType };
startPt = { x: e.clientX, y: e.clientY };
lastPt = { x: e.clientX, y: e.clientY };
e.preventDefault(); // 别顺手选中文字
});
document.addEventListener('pointermove', function (e) {
if (pending && !dragging && e.pointerId === pending.id) {
if (Math.abs(e.clientX - startPt.x) < MIN_MOVE
&& Math.abs(e.clientY - startPt.y) < MIN_MOVE) {
return;
}
beginDrag(pending.card, { x: e.clientX, y: e.clientY });
}
if (!dragging) { return; }
lastPt = { x: e.clientX, y: e.clientY };
placeAt(lastPt);
});
document.addEventListener('pointerup', function (e) {
if (dragging) { endDrag(); }
else if (pending && e.pointerId === pending.id) {
// 在手柄上按下、但位移没到门槛(= 没真拖)→ 清掉待拖态,什么都不做。
// ⚠️ v24 第 25 批:这里**不再**需要 `is-dragging` 的收回 ——
// 那个类现在只在 beginDrag() 里加(见 pointerdown 的说明)。
pending = null;
}
});
document.addEventListener('pointercancel', function () {
if (dragging) { endDrag(); }
else if (pending) { pending = null; }
});
// 拖完掐掉那一次 click(捕获阶段就拦,别等它冒泡到链接上)
document.addEventListener('click', function (e) {
if (!suppressClick) { return; }
e.preventDefault();
e.stopPropagation();
}, true);
})();
// ===== 地址:省 → 市 → 区 三级联动(用户 2026-10-04 第 4 条)=====
//
// 数据有 3429 条,一次性塞进页面会让表单页重一百多 KB,所以按级去取:
// 换了省就把「市」换成这个省下面的市、并把「区」清空;换了市同理。
//
// ⚠️ 这是**纯增强**:服务端已经把当前这一条链(省→市→区)的选项渲染好了,
// 没有这段 JS 时三个下拉照样能选、照样能提交,只是换了省不会自动带出新的市。
(function () {
var wrap = document.querySelector('[data-addr]');
if (!wrap) { return; }
var src = wrap.getAttribute('data-src');
var selProvince = wrap.querySelector('[data-addr-lvl="1"]');
var selCity = wrap.querySelector('[data-addr-lvl="2"]');
var selDistrict = wrap.querySelector('[data-addr-lvl="3"]');
if (!selProvince || !src) { return; }
var cache = {}; // 同一个父级取过一次就不再问(省下面那几个市来回切很常见)
function fill(sel, items, keep) {
sel.innerHTML = '';
var none = document.createElement('option');
none.value = '';
none.textContent = '(不填)';
sel.appendChild(none);
Array.prototype.forEach.call(items, function (it) {
var o = document.createElement('option');
o.value = it.name;
o.setAttribute('data-code', it.code);
o.textContent = it.name;
if (keep && keep === it.name) { o.selected = true; }
sel.appendChild(o);
});
sel.setAttribute('data-code', '');
}
function codeOf(sel) {
var o = sel.selectedOptions && sel.selectedOptions[0];
return (o && o.getAttribute('data-code')) || sel.getAttribute('data-code') || '';
}
function load(parent, done) {
var key = parent || '_root';
if (cache[key]) { done(cache[key]); return; }
if (!window.fetch) { done([]); return; }
window.fetch(src + (parent ? '&parent=' + encodeURIComponent(parent) : ''), {
credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
}).then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) {
var items = (j && j.items) || [];
cache[key] = items;
done(items);
}).catch(function () { done([]); });
}
selProvince.addEventListener('change', function () {
var keep = selCity.value;
load(codeOf(selProvince), function (items) {
// ⚠️ 换了省之后**原来看中的市多半不在这个省里** ——
// 但也不一律清空:省没变只是重取了一次时,保留用户选过的那个。
var still = false;
Array.prototype.forEach.call(items, function (it) { if (it.name === keep) { still = true; } });
fill(selCity, items, still ? keep : '');
fill(selDistrict, [], '');
});
});
if (selCity) {
selCity.addEventListener('change', function () {
load(codeOf(selCity), function (items) {
fill(selDistrict, items, selDistrict.value);
});
});
}
})();
// ===== 名册行的「分组」按钮:选中即提交 =====
// ⚠️ v24 第 10 批 #6 起,**这段委托搬进了 templates/people/index.php 的页内脚本**。
// 原因:分组表单不再是"每卡一份"的内联 <form>,而是全页**唯一一份**共享浮层
// (1184 人的名册原来有 2369 个 form / 27234 个 input,密码管理器逐个扫描
// 会把页面拖死 —— 用户报的"和 Bitwarden 冲突"就是这个)。
// 表单是页内动态填值的,提交委托跟着它一起走,读起来才是一件事。
// 这里留一条注释而不是删干净,是为了下一个人不要又把委托加回来。
// ===== 点页面其它地方 / 按 Esc 就收起下拉菜单 =====
// 用户 2026-10-03 第 3 条:「菜单栏点击后面,应该自动关闭,不应该一直保持打开」。
// 原生 <details> 只在再点一次 summary(或按 Esc)时收起 —— 点别处一律不管,
// 于是菜单会"粘"在屏幕上,挡住下面的内容。
//
// 覆盖全站的 details.menu(观影/任务的设置菜单、成员卡片的调整分组…),
// 不给每个页面各写一份。用事件委托:以后新加的菜单自动生效。
(function () {
var openOne = null;
function closeAll(except) {
var forEach = Array.prototype.forEach;
forEach.call(document.querySelectorAll('details.menu[open]'), function (d) {
if (d !== except) { d.open = false; }
});
if (except) { openOne = except; }
}
// toggle 不冒泡,所以用捕获阶段监听,能拿到所有菜单的开合
document.addEventListener('toggle', function (e) {
var d = e.target;
if (!d || d.tagName !== 'DETAILS' || !d.classList.contains('menu')) { return; }
if (d.open) {
closeAll(d); // 同时只留一个开着
} else if (openOne === d) {
openOne = null;
}
}, true);
document.addEventListener('click', function (e) {
if (!openOne) { return; }
// 点在菜单内部(含 summary)→ 不动,交给原生行为
if (openOne.contains(e.target)) { return; }
openOne.open = false;
openOne = null;
});
// Esc 虽然原生也会收,但这里统一收所有(原生只收获得焦点的那一个)
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { closeAll(null); openOne = null; }
});
// 键盘 Tab 走到菜单外时也收起,避免"焦点走了菜单还开着"
document.addEventListener('focusin', function (e) {
if (openOne && !openOne.contains(e.target)) {
openOne.open = false;
openOne = null;
}
});
})();
})();
// ==================== 日期时间控件:绑事件 + 工作经历的年月选择 ====================
/* 🔴🔴 v37:这一段的职责**大幅缩小**了。
原来它是"Semi DatePicker 渐进增强层":藏掉原生控件、挂上 Semi、把值写回原字段。
现在日期/时间控件(.dt-input)**完全自建**(年/月/日 + 时/分 原生下拉,
见 templates/_datetime.php 与 upgradeRange / upgrade / bindParts)
—— 因为它们要的交互("选完年月要能回来选日")浏览器自带的弹层给不了,
Semi 的弹层同样给不了(v35 实测:它的时间面板根本是空的)。
⚠️ 仍然**必须**跑起来,因为还有一件事离不开这套加载器:
**工作经历的 `<input type="month">`**(Firefox 压根不支持原生 month,
Chrome 的弹层也是同一个"选完年月卡住"的毛病)—— 它继续用 Semi 的
月选择器(`type: 'month'`,只需点月份格,没有"切回来"的问题)。
⚠️ 连带的好处:没有 month 控件的页面**不再下载那 4 MB** Semi 资源。
以前只要页面上有任何 .dt-input 就会去拉;现在只按 hasMonth 判断。 */
(function () {
var boxes = Array.prototype.slice.call(document.querySelectorAll('.dt-input[data-dt]'));
var hasMonth = !!document.querySelector('input[type="month"]');
/* 🔴🔴 v38(用户 2026-10-10):**Ant Design 的 RangePicker**。
用户原话:「我终于找到了一个我比较认可的日期选择插件
https://ant-design.antgroup.com/components/date-picker-cn 现在观影这个模块
进行测试吧,测试那个有开始、结束时间的。等这个模块测试完没问题了,
再更新到其他的模块。」
⇒ 页面出现 `[data-dt-antd]`(= 观影的起止那对)时才拉 antd 那三个文件,
**独立于** Semi 那套(Semi 只管"工作经历的年月")。
⚠️ 两套必须能**共存**:它们都吃同一个全局 React/ReactDOM(同版本,不会冲突),
但 antd/semi 各自的资源只在需要时才下。
⚠️ 顺序:**先**把原生下拉绑好(bindParts)、**再**升级成 antd ——
这样即使 antd 拉不动,用户手上的控件也是完整可用的。 */
var antdHosts = document.querySelectorAll('[data-dt-antd]');
/* 🔴 v40(用户 2026-10-10):「高级搜索里面的日期范围,也使用我们最新的控件。」
⇒ 高级搜索的日期范围是**纯日期**(GET 参数 `date_from` / `date_to`,
没有"时:分"),与观影表单那对"时刻"不是一个东西 ⇒ 单独一条标记
`[data-antd-daterange]`,挂的是**不带 showTime** 的 RangePicker。
⚠️ 两条路共用下面同一套"按需加载 + 中文 locale"的 ensure(),
只是挂载函数不同 —— 别复制一份加载逻辑出来(那必然有一天两边不同步)。 */
var antdDateRanges = document.querySelectorAll('[data-antd-daterange]');
/* 🔴 v42(用户第 8 条):单个**纯日期**控件(纪念日的「日期」)——
`[data-antd-date]`。它挂的是不带 showTime 的单 DatePicker。 */
var antdDates = document.querySelectorAll('[data-antd-date]');
/* 🔴 v44(用户第 5 条):单个**带时刻**的控件(任务「截止时间」)——
`[data-dt-antd-single]`。挂的是带 showTime 的单 DatePicker。 */
var antdSingles = document.querySelectorAll('[data-dt-antd-single]');
if (antdHosts.length || antdDateRanges.length || antdDates.length || antdSingles.length) {
bootAntd(antdHosts, antdDateRanges, antdDates, antdSingles);
}
/* 🔴🔴 v42(用户 2026-10-11 第 2 条)——**关键时序修复**:
下面 `if (!hasMonth) { … return; }` 这条"没有 month 控件就提前退出"的短路
(v37 为省 4 MB Semi 资源而加),会让**本 IIFE 尾部**的那几个
`window.fbXxx = …` 赋值**永远不执行**。
而详情页的行程弹框正是靠 `window.fbBootAntd` / `window.fbUpgradeRange`
把 AJAX 塞进来的那对起止控件升级成 AntD 的 —— 于是:
「添加行程,开始和结束时间,并没有使用我们在观影模式的控件」(用户第 2 条)。
⚠️ 这个缺陷从 v40 引入 `fbBootAntd` 起就存在,只是当时**只有观影详情页**用,
而那页首屏就带 `[data-dt-antd]`(不满足 `!hasMonth`…… 实为首屏有值、
直接走了另一条分支),所以一直没暴露;行程这条路径才把它踩出来。
⇒ 修法:**把对外接口的赋值提到所有 `return` 之前**(函数声明会提升,
所以这里能安全引用下面定义的 `upgradeRange` / `bootAntd` / `writeDT`)。
尾部那几处保留(幂等),不改动其他调用点的语义。 */
window.fbUpgradeRange = function () {
Array.prototype.forEach.call(document.querySelectorAll('.dt-range'), upgradeRange);
return true;
};
window.fbBootAntd = function () {
bootAntd(document.querySelectorAll('[data-dt-antd]'),
document.querySelectorAll('[data-antd-daterange]'),
document.querySelectorAll('[data-antd-date]'),
document.querySelectorAll('[data-dt-antd-single]'));
return true;
};
/* 🔴🔴 v48(用户 2026-10-14 第 2 条):「点了日历某一格 → 新建弹窗里应带入
那个日期/时间…之前这个功能是有的,后来我们改了时间控件之后就没有了。」
**真根因就在这里**:`window.fbSetDT` 原来**只**定义在本 IIFE 的**尾部**
(在 `if (!hasMonth) { … return; }` 与 Semi 加载分支的几个 `return` **之后**)。
日程页首屏没有 `input[type=month]`(没有工作经历那种"年月"控件)⇒ `!hasMonth`
成立 ⇒ **直接 return** ⇒ 尾部的 `window.fbSetDT` **永远不执行**。
于是 templates/calendar/index.php 的 `setStart()` 里
`typeof window.fbSetDT === 'function'` 为假 → 走"兜底"分支只写 `dt-date.value`,
而页面上的年/月/日三个下拉**没有跟着变**;弹框一显示,MutationObserver 触发
`bindParts()` → `sync()` 又把三个下拉的**旧值**写回 `dt-date`
⇒ 用户点的 10-10 被盖回服务端默认的今天。(真浏览器实测:
`SET date=2026-10-10` 紧跟着 `SET date=2026-10-14`。)
⚠️ 与 v42 修 `fbBootAntd`/`fbUpgradeRange` 是**同一个家族**:对外接口写在
提前 `return` 之后 ⇒ 静默失效。这次补上 `fbSetDT`。 */
window.fbSetDT = function (name, str) {
if (!name) { return false; }
var box = null;
Array.prototype.forEach.call(document.querySelectorAll('.dt-input[data-dt]'), function (b) {
if (box) { return; }
var cfg = {};
try { cfg = JSON.parse(b.getAttribute('data-dt') || '{}'); } catch (e) {}
if (cfg.name === name) { box = b; }
});
if (!box) { return false; }
writeDT(box, str);
/* 🔴 v37:隐藏的 *_date 写完还不算完 —— 页面上真正给用户看的是
「年 / 月 / 日」三个下拉,必须让它们**跟着走到同一个值**,
否则会出现"程序设了 15:00,下拉里还写着 09:00"这种最难解释的不一致。
⚠️ v48:控件还没 bindParts 时 `__fbSyncParts` 是 undefined —— 没关系,
下拉会在 bindParts 初始化时以 dt-date 为准对齐(见 bindParts 的 v48 说明)。 */
if (box.__fbSyncParts) { box.__fbSyncParts(); }
// 原生写完了 —— 现在把 antd / Semi 一侧也刷新成同一个值
var group = box.closest ? box.closest('.dt-range') : null;
/* 🔴 v48:**不再要求 `data-semi-range-done`**。
原来靠它分辨"这对是否已升级成范围控件",但日程页的「结束」默认是隐藏的 ⇒
`upgradeRange()` 因"两端都得可见"而 **early-return**、永远不打那个标记 ⇒
即便 `mountAntd` 已经挂好 antd 并把 `group.__fbRemount` 接好了,
这里也会因为缺那个标记而**退到 `box.__fbRemount`**(`dt-part` 成员被
`upgrade()` 跳过 ⇒ 那是 undefined)⇒ **可见控件永远不重绘**。
⇒ 直接"有 group 的重绘就用 group 的,否则用 box 的"。两者若都在,
先用 group(范围控件才是用户看得见的那个),再用 box(对齐下拉)。
⚠️ 顺序上 `bootAntd` 比 `upgradeRange` **先**跑(见 boot()),
所以 `mountAntd` 设的 `group.__fbRemount` 会**覆盖** `upgradeRange` 设的那版 ——
这正是我们要的:有 antd 就用 antd 的重绘。 */
if (group && group.__fbRemount) { group.__fbRemount(); }
if (box.__fbRemount) { box.__fbRemount(); }
return true;
};
/* ⚠️ 只要还有 month 控件就得拉 Semi;纯 .dt-input 页面(绝大多数)不用拉。 */
if (!hasMonth) {
// 没有 month 控件:**只做绑定**,不下载任何外部资源。
// 这一步不能省 —— 三个日期下拉写回隐藏 *_date 全靠它。
Array.prototype.forEach.call(document.querySelectorAll('.dt-range'), upgradeRange);
boxes.forEach(upgrade);
watchNewControls();
return;
}
var meta = document.querySelector('meta[name="fb-semi"]');
if (!meta) {
Array.prototype.forEach.call(document.querySelectorAll('.dt-range'), upgradeRange);
boxes.forEach(upgrade);
watchNewControls();
return;
}
var conf;
try { conf = JSON.parse(meta.getAttribute('content')); } catch (e) { return; }
if (!conf || !conf.css || !conf.react || !conf.dom || !conf.ui) { return; }
function loadCss(href) {
return new Promise(function (res) {
var l = document.createElement('link');
l.rel = 'stylesheet';
l.href = href;
l.onload = function () { res(true); };
l.onerror = function () { res(false); };
document.head.appendChild(l);
});
}
/**
* 加载一个 JS 文件。
*
* 🔴🔴 2026-11-05 修 React #321(Invalid hook call):
*
* 症状:首页点任务 → 浮层点「编辑」→ 控制台的 `AntD 日期控件已挂上 1 处`
* 紧接着抛 `Minified React error #321`,日期控件崩掉。
*
* 根因:**React 被加载了两遍**。
* · Semi 段的 `boot()` 用 `requestIdleCallback` 延迟跑,
* 里面**无条件** `loadJs(conf.react)` + `loadJs(conf.dom)`;
* · antd 段的 `ensure()` 只在 `!window.React` 时才加载 —— 看着更"谨慎",
* 但两边谁先谁后取决于 idle 回调的时机。
* ⇒ antd 先加载、Semi 的 idle 后到 ⇒ Semi **又插了一遍**同一个
* `react.min.js` ⇒ 那段 UMD **执行第二次** ⇒ `window.React` 被覆盖。
* 而 antd 已经在内部捕获了**第一次**的 React 引用 ⇒ 两套 React 并存
* ⇒ antd 的 hook(useContext)在"第二套"的 dispatcher 上调用 ⇒ #321。
*
* 堆栈里 `VM303 react.min.js` / `VM320 react-dom.min.js` 这种 **VM 前缀**
* 就是"同一个文件被 eval 执行了不止一次"的铁证。
*
* ⇒ 修法:按 `src` **全局去重**,同一个地址永远只执行一次。
* 缓存在 `window.__fbJsLoaded`(跨 IIFE 共享 —— 本文件里有两个独立的
* `loadJs` 定义,Semi 一个、Semi 外层一个,各自闭包内的变量互相看不见)。
*
* ⚠️ 不能只给 Semi 那边加判断(`if (!window.React)`):那种写法依赖"现有全局
* 是否已经有 React",而 UMD 执行完才赋值 —— 两个请求几乎同时发出时,
* 两边都还看不到 `window.React`,照样会各插一个。**按 URL 去重才是确定的。**
*/
function loadJs(src) {
var reg = window.__fbJsLoaded;
if (!reg) { reg = window.__fbJsLoaded = {}; }
if (reg[src]) { return reg[src]; }
reg[src] = new Promise(function (res, rej) {
var s = document.createElement('script');
s.src = src;
s.onload = function () { res(); };
s.onerror = function () { rej(new Error('加载失败: ' + src)); };
document.head.appendChild(s);
});
return reg[src];
}
function pad(n) { return (n < 10 ? '0' : '') + n; }
function parseDT(str) {
// 'YYYY-MM-DD [HH:mm[:ss]]' → 本地时区 Date | undefined
var m = (str || '').trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?$/);
if (!m) { return undefined; }
var d = new Date(+m[1], +m[2] - 1, +m[3], +(m[4] || 0), +(m[5] || 0), +(m[6] || 0));
return isNaN(d.getTime()) ? undefined : d;
}
function part(native, sel) {
var n = native.querySelector(sel);
return n ? String(n.value || '') : '';
}
/**
* 把 Semi 给的字符串写回某个 .dt-input 的原生四字段(POST 契约)。
* str 形如 '2026-10-18 07:46:13' / '2026-10-18 07:46' / ''(清空)。
* ⚠️ 提成公用函数:单点控件与范围两端都走它,规则只有一处。
*/
function writeDT(box, str) {
var m = (str || '').trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$/);
function set(sel, v) {
var n = box.querySelector(sel);
if (!n || n.value === v) { return; }
n.value = v;
n.dispatchEvent(new Event('change', { bubbles: true }));
}
if (m) {
set('.dt-date', m[1] + '-' + pad(+m[2]) + '-' + pad(+m[3]));
set('.dt-hour', pad(+m[4]));
set('.dt-minute', pad(+m[5]));
set('.dt-second', pad(+(m[6] || 0)));
} else {
set('.dt-date', '');
set('.dt-hour', '00');
set('.dt-minute', '00');
set('.dt-second', '00');
}
}
/** 读某个 .dt-input 当前的原生值 → 'YYYY-MM-DD HH:mm:ss' */
function readDT(box) {
var native = box.querySelector('.dt-native');
if (!native) { return ''; }
return part(native, '.dt-date') + ' ' +
(part(native, '.dt-hour') || '00') + ':' +
(part(native, '.dt-minute') || '00') + ':' +
(part(native, '.dt-second') || '00');
}
/**
* 起止范围:把一对 .dt-input[data-dt.range=start|end] 合并成**一个**
* Semi `dateTimeRange` 选择器(用户第 7 条:「应该用这个日期插件的时间范围选择方式,
* 而不是将这个范围拆成两个,这样不好看,也多一步操作」)。
*
* 契约不变:两个原生控件都留着(藏在隐藏容器里),Semi 选完把两端分别写回它们,
* 所以服务端的 read_dt_input('starts_at') / ('ends_at') 与既有的 e2e 全部照旧。
*/
function upgradeRange(group) {
if (group.getAttribute('data-semi-range-done')) { return; }
var boxes = Array.prototype.slice.call(group.querySelectorAll('.dt-input[data-dt]'));
if (boxes.length !== 2) { return; }
// ⚠️ **两端都得可见**才升级成范围控件。
// 日程页的「结束」默认是隐藏的(要点「+ 添加结束时间」才出现,见 _event_fields.php)——
// 那时用户表达的意图是"就一个时刻",硬给一个范围控件反而多一个空着的输入框。
// 用户在页面里点了那个开关之后,页内 JS 会再调一次 window.fbUpgradeRange(),
// 这时两端都可见了,才真正合并成范围。
var visible = boxes.every(function (bx) { return bx.offsetParent !== null; });
if (!visible) { return; }
// 按 data-dt.range 认起止;没标就按 DOM 顺序(起在前)
var a = null, b = null, secs = true;
boxes.forEach(function (bx) {
var cfg = {};
try { cfg = JSON.parse(bx.getAttribute('data-dt') || '{}'); } catch (e) {}
if (cfg.seconds === false) { secs = false; }
if (cfg.range === 'end') { b = bx; } else if (cfg.range === 'start') { a = bx; }
else if (a === null) { a = bx; } else { b = bx; }
});
if (!a || !b) { return; }
/* 🔴🔴 v37:**最彻底的简化 —— 不再"升级"任何东西、不再隐藏任何东西**。
控件从服务端出来就(应当)是它**最终该有的样子**(见 templates/_datetime.php):
· 日期 = 年 / 月 / 日 三个原生下拉;
· 时间 = 时 / 分 两个原生下拉。
这里只做两件事:① 把三个日期下拉绑成"改哪个算哪个"(写回隐藏的 *_date);
② 给范围组排版(两端一行、窄屏自动换行)。
用户原话(2026-10-09 第 4 次反馈):
「…选日期的这个控件非常非常不好用。就是如果我把年和月那个地方拉下来,
我选完年和月之后,它并不会回到让我选日的那个界面,它还是不动的,
所以这时候就没有办法选日。」
—— 那是**浏览器自带日历弹层**的行为(我们碰不到它的 DOM,也就没法治);
三个原生下拉是我们能完全控制的形态,选完即生效、不需要"确定"。
「另外一个问题是,现在开始和结束时间分在了两行…如果能放得下的话,
就应该放一排。」→ 见下面 layoutRange() + CSS 的 .dt-cols。 */
layoutRange(group);
bindParts(a);
bindParts(b);
/* 供 fbSetDT / fbUpgradeRange 的既有调用点使用。
🔴 v48:不再是空壳 —— 两端各自的**下拉**要跟着原生值走。
⚠️ 与 `mountAntd`(antd 那套)的分工:本函数**先**跑(boot 里先 upgradeRange),
`mountAntd` **后**跑并把 `group.__fbRemount` **覆盖**成"重读原生值 + 重绘 antd"。
所以:
· 有 antd 的页面 → 最终是 mountAntd 那版(下面的空壳被覆盖);
· 没有 antd 的页面 → 就是这一版,把两个端点的下拉对齐到原生值。
🔴🔴 v51(用户 2026-10-15)——**这里原来是"降级覆盖",害了日程页的带入时间**。
用户原话:「在周视图和日视图的某一个地方点击,它会把当前的时间带进来,
但是**只有第一次**带进来的时间是对的……当我关闭了它,再次点击的时候,
无论我点什么地方,它带进来的还是 8 点钟。」
**真根因(真机插桩实测,不是猜的)**:
日程页首屏那对起止字段是**藏在弹框里**的(`#calAddCard` 默认 `hidden`)
⇒ boot 时 `upgradeRange()` 因为"两端不可见"**直接 return**(见上面的守卫)
⇒ 那一刻 **`mountAntd` 是唯一给 `group.__fbRemount` 赋值的人**
(它那一版会"重读原生值 + `renderPicker()` 重绘 antd")。
而**一打开弹框**(元素第一次可见),页内 `openNew()` 会调
`window.fbUpgradeRange()` 补升级 ⇒ `upgradeRange()` 这次跑到底,
在**它的尾部**把 `group.__fbRemount` **重新赋成了下面这一版** ——
只会对齐两个原生下拉、**根本不碰 antd**。
⇒ 此后每次 `fbSetDT`(点日历带入时间)都走到这一版:
隐藏字段与原生下拉都更新了,**可见的 antd 控件永远停在第一次的值**。
这正是"第一次对、后面都不动"。
⇒ 修法:**别覆盖已经更强的重绘实现**。
· 已存在 `__fbRemount`(= mountAntd 那版)时:把"对齐原生下拉"**串在它前面**,
而不是把它顶掉;
· 不存在时:才用下面这版(纯原生页面的兜底)。
⚠️ 顺序:先对齐下拉再重绘 antd —— mountAntd 那版会从原生值重读,
先让原生侧一致,重绘出来的才对。
⚠️ 幂等:`upgradeRange` 若因任何原因再跑一次,也不会把自己套进自己
(用 `__fbRangeSync` 标记,套过的就不重复套)。 */
var syncPartsOnce = function () {
if (a.__fbSyncParts) { a.__fbSyncParts(); }
if (b.__fbSyncParts) { b.__fbSyncParts(); }
};
if (typeof group.__fbRemount === 'function' && !group.__fbRangeSync) {
/* 已有更强的实现(mountAntd)→ 只把"对齐下拉"挂到它前面 */
var stronger = group.__fbRemount;
group.__fbRangeSync = true;
group.__fbRemount = function () {
syncPartsOnce();
return stronger();
};
} else if (!group.__fbRemount) {
group.__fbRemount = syncPartsOnce;
}
guardRequired(a);
guardRequired(b);
group.setAttribute('data-semi-range-done', '1');
group.classList.add('is-semi-range');
group.classList.add('is-native-range');
}
/* ============================================================================
v37 的共用件(单点与范围两端都走它们)
============================================================================ */
/**
* 把「年 / 月 / 日」三个下拉绑成"改哪个算哪个",并写回隐藏的 `*_date`。
*
* ⚠️ `*_date` 仍然是**真正的表单字段**(只是 type=hidden)——
* 「提交契约一字不改」是这样在**结构上**保证的,而不是靠前后端约定。
* ⚠️ 改年 / 月会重建"日"那一列:2 月在闰年 29 天、4 月 30 天。
* 原来选的那一天若在新月份里不存在(31 号 → 2 月),**清空**而不是
* 静默挪到别的一天 —— 用户没选过的日子不能替他选。
*/
function bindParts(box) {
var native = box.querySelector('.dt-native');
if (!native) { return; }
var date = native.querySelector('input.dt-date');
var y = native.querySelector('select.dt-year');
var mo = native.querySelector('select.dt-month');
var d = native.querySelector('select.dt-day');
if (!date || !y || !mo || !d) { return; } // 不是"下拉版"(native 模式)→ 无事可做
if (box.__fbBound) { return; }
box.__fbBound = true;
function daysIn(yy, mm) {
return (yy && mm) ? new Date(yy, mm, 0).getDate() : 31; // new Date(y, m, 0) = 上月末
}
function rebuildDays(keep) {
var yy = parseInt(y.value, 10), mm = parseInt(mo.value, 10);
var n = daysIn(yy, mm);
var cur = keep ? d.value : '';
d.innerHTML = '<option value="">日</option>';
for (var i = 1; i <= n; i++) {
var o = document.createElement('option');
o.value = String(i);
o.textContent = i + '日';
d.appendChild(o);
}
d.value = (cur && parseInt(cur, 10) <= n) ? cur : '';
}
function sync() {
var v = (y.value && mo.value && d.value)
? y.value + '-' + pad(+mo.value) + '-' + pad(+d.value)
: '';
if (date.value !== v) {
date.value = v;
date.dispatchEvent(new Event('change', { bubbles: true }));
}
box.classList.toggle('has-value', v !== '');
}
y.addEventListener('change', function () { rebuildDays(true); sync(); });
mo.addEventListener('change', function () { rebuildDays(true); sync(); });
d.addEventListener('change', sync);
/* 🔴🔴 v40 修一个**数据丢失级**的真 bug(v37 引入,影响**所有** `.dt-range` 页面):
这里原来是 `rebuildDays(false)` —— `keep=false` 会**把"日"下拉清空**
(它是"重建这一列"的写法,本意用于"换了年月要重新铺天数")。
紧接着的 `sync()` 看到 `y/mo` 有值、`d` 是空的 ⇒ 算出 `v=''`
⇒ **把隐藏的 `*_date` 写成了空串**。
后果:任何带着已存时间的编辑表单,只要 JS 跑起来(正常情况都会),
日期就在用户**还没动任何东西**时被抹掉了 —— 用户提交后库里 `starts_at`
变成 NULL。实测(`_dbg_submit.py`):打开编辑弹框后
`starts_at_date` 读出来是 `''`(而年/月下拉与 antd 显示都还是正确的
`2026 / 2026-10-05`),点保存 → 库里的 `starts_at` 从
`2026-10-05 19:30:00` 变成 **NULL**。
⚠️ 为什么以前没被发现:e2e 是**服务端**驱动的(直接 POST 字段),
不经过 JS;而真机探针当时只验"控件可见可点"与"改值能写回",
**没验"什么都不动时值还在不在"**。
⇒ 补一条回归断言(见 `_fb_b40_movie.py`:打开编辑弹框时
`starts_at_date` 必须非空、提交后库里时间不丢)。
⇒ 首屏应当 `rebuildDays(true)`:把**服务端已经选好**的那一天保留下来。
(`true` 的语义是"能保留就保留":那天在新月份里不存在时仍然清空 ——
31 号 → 2 月那种,见上面 `d.value` 那行的判断。) */
/* 供 fbSetDT / writeDT 程序化改值之后让三个下拉跟上。
⚠️⚠️ v48:**赋值必须在下面调用之前** —— 这是赋值语句(不提升),
原来它写在 `rebuildDays(true); sync();` **之后**,会导致
"点日历带入的日期被初始化盖掉"(详见下面 `__fbSyncParts` 的说明)。 */
box.__fbSyncParts = function () {
var m = (date.value || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!m) { y.value = ''; mo.value = ''; rebuildDays(false); d.value = ''; return; }
y.value = String(+m[1]);
mo.value = String(+m[2]);
rebuildDays(false);
d.value = String(+m[3]);
};
/* 🔴🔴 v48(用户 2026-10-14 第 2 条):初始化改成**以 `date` 为准**。
原先是 `rebuildDays(true); sync();` —— `sync()` 读的是**三个下拉**、
再写回 `date`。这在"服务端渲染 → 用户不动"时没问题(三者本就一致),
但**点日历带入时间**时就成了 bug:
① 点击 → `setStart()` → `fbSetDT` 已经把 `date` 改成 10-10、三个下拉还是
服务端的 10-14(那一刻还没 bindParts,`__fbSyncParts` 尚不存在);
② 弹框显示 → MutationObserver 触发 → 这里的 bindParts 初始化跑
`sync()` → 用**旧下拉**的 10-14 **盖回 `date`** ⇒ 用户点的日期丢了。
真浏览器实测(`_fb_v48_dbg3.py`)的调用栈:
set date=2026-10-10 ← setStart / openNew
set date=2026-10-14 ← sync() ← bindParts
⇒ 初始化必须以**权威字段 `date`** 为准去对齐下拉(`__fbSyncParts()`),
而不是反过来。`date` 是真正的表单字段,它就是唯一真相。
⚠️ 这同时**保住了 v40 的修复**(不把已存日期抹成空):`__fbSyncParts`
在 `date` 有值时会把下拉逐档对齐(含 `rebuildDays` 重铺天数),
`sync()` 随后算出的值与 `date` 相同 ⇒ 不触发、不丢。 */
box.__fbSyncParts();
sync();
}
/**
* 范围组排版:两端各占一列,**宽屏一行、窄屏换行**。
*
* ⚠️ 几何全部交给 CSS(flex + flex-wrap + 每端的 min-width),
* 这里**只挂类名**。为什么不在这里算宽度:
* 用 JS 量宽度会在字体没加载完 / 容器动画中量错,而且窗口缩放要重算;
* `flex-wrap` 由浏览器在**每次布局时**自己算,永远是对的。
* 这也正是用户要的:「如果能放得下就放一排;手机上很窄就挤到下一行」。
*/
function layoutRange(group) {
group.classList.add('dt-cols');
}
/* ============================================================================
v38:Ant Design RangePicker 适配层(**只在观影模块试点**)
============================================================================
与自建下拉的分工:
· 自建下拉 = **唯一的值载体与兜底**。三个日期下拉照旧绑在隐藏的 `*_date` 上;
· antd 控件 = **额外**挂上去的一层 UI,选完把值写回那两个端点(走 writeDT)。
这样"antd 拉不动"时用户仍然能用自建下拉,不会出现"控件消失"。
🔴 实测出来的三条关键行为(见 _tooling/_antd_probe,15/0):
① UMD 需要全局 `React` / `ReactDOM` / `dayjs`,缺一个就挂不出来;
② 面板里的**时间列是内联可见的、还有「确定」按钮** —— 正是用户要的形态;
③ `onChange` 只在**两端都齐了**才触发,单端改动走 `onCalendarChange`。
⇒ 回写必须同时挂 `onCalendarChange`(实时)**和** `onChange`(确认),
否则"只填了开始时间就提交"会丢值。
🔴🔴 v39(用户 2026-10-10):「时间控件…还有下面的确定按钮,现在显示的都是英文,
应该给它设置成中文。」
· 英文的**根因**:普通 `antd.min.js` 产物**不含** locale 包(`antd.locales` 是
undefined),所以面板只能用它内置的英文默认值 ⇒ 改载 **antd-with-locales**;
· 另外 dayjs 核心包只有 en,星期/月份是 dayjs 出的 ⇒ 必须**另加载**
`dayjs-zh-cn.js` 并调 `dayjs.locale('zh-cn')`;
· 最后用 `<ConfigProvider locale={zh_CN}>` 包住控件 —— antd 读的是**上下文里的**
locale,不包的话即使资源在、面板照样是英文(但**日历文字**会变中文,容易误判)。
⚠️ 三个条件缺一不可;这也是为什么单独写了 `_antd_probe/zh.html` 把它钉死(9/0)。
⚠️ 这个适配层**不认识的页面不受影响**:没有 `[data-dt-antd]` 就直接返回。
============================================================================ */
function bootAntd(hosts, dateRanges, dates, singles) {
hosts = hosts || [];
dateRanges = dateRanges || [];
dates = dates || [];
singles = singles || [];
if (!hosts.length && !dateRanges.length && !dates.length && !singles.length) { return; }
var meta = document.querySelector('meta[name="fb-antd"]');
if (!meta) { return; }
var conf;
try { conf = JSON.parse(meta.getAttribute('content')); } catch (e) { return; }
if (!conf || !conf.css || !conf.antd || !conf.dayjs) { return; }
var loading = null;
function ensure() {
if (window.antd && window.dayjs && window.React && window.ReactDOM) {
return Promise.resolve();
}
if (loading) { return loading; }
loadCss(conf.css);
// ⚠️ 顺序不能换:dayjs 是 antd 的依赖,antd 又从全局取 React/ReactDOM。
// React/ReactDOM 若已被 Semi 那套加载过,loadJs 会再下一次 —— 但它们是
// 幂等赋值(同一个 UMD),不会出事;先查一下能省掉那次下载。
var chain = Promise.resolve();
if (!window.React) { chain = chain.then(function () { return loadJs(conf.react || ''); }); }
if (!window.ReactDOM) { chain = chain.then(function () { return loadJs(conf.dom || ''); }); }
loading = chain
.then(function () { return window.dayjs ? null : loadJs(conf.dayjs); })
// 🔴 v39:dayjs 的中文 locale 是**单独一个文件**(核心包只有 en)。
// 它必须在 dayjs 之后、antd 之前加载 —— 加载完是挂一个
// `window.dayjs_locale_zh_cn` 函数(见文件头的说明)。
.then(function () {
if (!conf.dayjsZh || window.dayjs_locale_zh_cn) { return null; }
return loadJs(conf.dayjsZh).catch(function () { return null; });
})
.then(function () { return window.antd ? null : loadJs(conf.antd); });
return loading;
}
var done = 0;
ensure().then(function () {
applyDayjsZh();
Array.prototype.forEach.call(hosts, function (box) {
if (mountAntd(box)) { done++; }
});
Array.prototype.forEach.call(dateRanges, function (el) {
if (mountAntdDateRange(el)) { done++; }
});
Array.prototype.forEach.call(dates, function (el) {
if (mountAntdDate(el)) { done++; }
});
Array.prototype.forEach.call(singles, function (box) {
if (mountAntdSingleDT(box)) { done++; }
});
if (window.console && done) { console.info('AntD 日期控件已挂上 ' + done + ' 处'); }
}).catch(function (err) {
// 拉不动就留在自建下拉上 —— 渐进增强的兜底,不弹任何打扰
if (window.console) { console.warn('AntD DatePicker 未启用:', err && err.message); }
});
}
/**
* 把一对**纯日期**输入(`<input type=date name=date_from/date_to>`)升级成
* Ant Design 的 RangePicker(**不带时间列**)。
*
* 🔴 v40(用户 2026-10-10):「高级搜索里面的日期范围,也使用我们最新的控件。」
* ⚠️ 与 `mountAntd()`(观影那对"时刻")的分工:
* · 那个操作的是 `.dt-input[data-dt]`(隐藏 `*_date` + `*_hour/_minute` 契约);
* · 这个直接操作两个 `input[type=date]`(GET 表单参数),**写成 `YYYY-MM-DD`**。
* 两边的"值载体"完全不同 ⇒ 共享加载/中文逻辑,但**回写各自实现**。
* ⚠️ 原生输入**不删**:用 `class="dt-antd-hidden"` 视觉收起(仍是表单字段、仍可聚焦)。
* 这样 antd 拉不动时,用户看到的就是原来那两个原生日期框 —— 不倒退。
*/
function mountAntdDateRange(el) {
if (!el || el.getAttribute('data-antd-done')) { return false; }
var fm = el.querySelector('input[name$="date_from"], input[name="date_from"]');
var to = el.querySelector('input[name$="date_to"], input[name="date_to"]');
// 兼容调用方自定义字段名:按 DOM 顺序取前两个 date 输入
if (!fm || !to) {
var all = el.querySelectorAll('input[type="date"]');
if (all.length < 2) { return false; }
fm = all[0]; to = all[1];
}
var host = document.createElement('span');
host.className = 'dt-antd dt-antd-dateonly';
el.insertBefore(host, el.firstChild);
function toDayjs(str) {
var m = (str || '').trim().match(/^(\d{4})-(\d{2})-(\d{2})$/);
return m ? window.dayjs(m[1] + '-' + m[2] + '-' + m[3], 'YYYY-MM-DD') : null;
}
var last = ['', ''];
function writeBack(strs) {
var s0 = (strs && strs[0]) || '', s1 = (strs && strs[1]) || '';
if (s0 === last[0] && s1 === last[1]) { return; }
last = [s0, s1];
// 直接写原生字段(GET 提交的就是它们)
fm.value = s0; to.value = s1;
fm.dispatchEvent(new Event('change', { bubbles: true }));
to.dispatchEvent(new Event('change', { bubbles: true }));
}
var v0 = (fm.value || '').trim(), v1 = (to.value || '').trim();
try {
var picker = React.createElement(antd.DatePicker.RangePicker, {
format: 'YYYY-MM-DD',
allowClear: true,
placeholder: ['开始日期', '结束日期'],
defaultValue: (v0 || v1) ? [toDayjs(v0 || v1), toDayjs(v1 || v0)] : undefined,
// 与观影那对同一条实测结论:单端改动走 onCalendarChange,两端齐了才 on
onCalendarChange: function (vals, strs) { writeBack(strs); },
onChange: function (vals, strs) { writeBack(strs); }
});
var loc = (window.antd.locales && window.antd.locales.zh_CN) || undefined;
if (loc) { picker = React.createElement(antd.ConfigProvider, { locale: loc }, picker); }
ReactDOM.createRoot(host).render(picker);
} catch (e) {
if (window.console) { console.warn('antd 日期范围挂载失败,留在原生输入:', e && e.message); }
host.parentNode.removeChild(host);
return false;
}
el.classList.add('is-antd');
el.setAttribute('data-antd-done', '1');
return true;
}
/* 🔴 v42(用户 2026-10-11 第 8 条):「新建纪念日,月日那里…也用我们最新的时间控件,
后面的时分就不需要了,只需要日期。」
⇒ 单个**纯日期**的 AntD DatePicker(不是 RangePicker)。
值载体是一个 `<input type="date" name="X">`(写成 `YYYY-MM-DD`),
由调用方从它再拆出 month/day(纪念日的存储契约是 month + day)。 */
function mountAntdDate(el) {
if (!el || el.getAttribute('data-antd-done')) { return false; }
var native = el.querySelector('input[type="date"]');
if (!native) { return false; }
var host = document.createElement('span');
host.className = 'dt-antd dt-antd-dateonly';
el.insertBefore(host, el.firstChild);
function toDayjs(str) {
var m = (str || '').trim().match(/^(\d{4})-(\d{2})-(\d{2})$/);
return m ? window.dayjs(m[1] + '-' + m[2] + '-' + m[3], 'YYYY-MM-DD') : null;
}
var last = '';
function writeBack(str) {
var s = str || '';
if (s === last) { return; }
last = s;
native.value = s;
native.dispatchEvent(new Event('change', { bubbles: true }));
}
var v0 = (native.value || '').trim();
try {
var picker = React.createElement(antd.DatePicker, {
format: 'YYYY-MM-DD',
allowClear: true,
placeholder: '选择日期',
defaultValue: toDayjs(v0) || undefined,
onChange: function (val, str) { writeBack(str); }
});
var loc = (window.antd.locales && window.antd.locales.zh_CN) || undefined;
if (loc) { picker = React.createElement(antd.ConfigProvider, { locale: loc }, picker); }
ReactDOM.createRoot(host).render(picker);
} catch (e) {
if (window.console) { console.warn('antd DatePicker 挂载失败,留在原生输入:', e && e.message); }
host.parentNode.removeChild(host);
return false;
}
el.classList.add('is-antd');
el.setAttribute('data-antd-done', '1');
return true;
}
/** 把 dayjs 切到简体中文(星期/月份/相对时间都由它出)。
* ⚠️ 拿不到就静默跳过 —— 那只会让星期显示成英文,不该拦住整个控件。 */
function applyDayjsZh() {
try {
if (window.dayjs && window.dayjs.locale) { window.dayjs.locale('zh-cn'); }
} catch (e) { /* 忽略:dayjs locale 未注册时不做任何事 */ }
}
/** 把**一对**端点升级成 antd RangePicker;成功返回 true。 */
function mountAntd(box) {
var group = box.closest ? box.closest('.dt-range') : null;
if (!group || group.getAttribute('data-antd-done')) { return false; }
var pair = Array.prototype.slice.call(group.querySelectorAll('.dt-input[data-dt]'));
if (pair.length !== 2) { return false; }
var a = group.querySelector('[data-dt-antd]');
if (!a) { return false; }
var b = null;
pair.forEach(function (p) { if (p !== a) { b = p; } });
if (!b) { return false; }
var host = document.createElement('span');
host.className = 'dt-antd';
a.parentNode.insertBefore(host, a);
function cur(el) {
var m = (readDT(el).trim() || '').match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})/);
return m ? m[1] + '-' + m[2] + '-' + m[3] + ' ' + m[4] + ':' + m[5] : '';
}
var v0 = cur(a), v1 = cur(b);
function toDayjs(str) { return str ? window.dayjs(str, 'YYYY-MM-DD HH:mm') : null; }
var last = ['', ''];
/* 回写:把 antd 给的两串写回两个端点(走既有的 writeDT → 隐藏四字段)。
⚠️ 两端都要写 —— 只写变化的那一端,另一端会被 writeDT('') 清空。 */
function writeBack(strs) {
var s0 = (strs && strs[0]) || '', s1 = (strs && strs[1]) || '';
if (s0 === last[0] && s1 === last[1]) { return; }
last = [s0, s1];
writeDT(a, s0);
writeDT(b, s1);
a.classList.toggle('has-value', !!s0);
b.classList.toggle('has-value', !!s1);
}
/* 🔴🔴 v48(用户 2026-10-14 第 2 条)——**受控 + 可重绘**。
原来这里是 `defaultValue`(非受控):只在**首次挂载**时读一次原生值,
之后怎么改原生字段(`fbSetDT`)它都不会重画。于是"点日历带入时间"会出现:
隐藏字段 = 10-10(对的),**可见的 antd 控件却还写着 10-14**(错的)。
用户看到的正是后者 ——「点了 10 号,弹窗里开始时间还是今天」。
⇒ 改成**受控**(`value` + 状态)+ 暴露 `__fbRemount()`:
· 内部改动:`onChange/onCalendarChange` → `writeBack` → 更新状态 → 重绘;
· 外部改动:`fbSetDT` 写完原生字段后调 `__fbRemount()` → 从原生值**重读**
状态 → 重绘 ⇒ 可见控件与隐藏字段永远一致。
⚠️ 状态用 `v0/v1` 两个字符串('YYYY-MM-DD HH:mm'):与 writeBack 的入参同一
口径(antd 的 `strs` 就是这个格式),不用在两条路之间换算。 */
var st0 = v0, st1 = v1;
/* 🔴🔴 v60(用户 2026-10-18):**范围控件只改一端,选完不该被赶回起点**。
现象:「前面的时间不用改,我直接点后面的结束时间;选完之后它还是跳回到
开始时间、让我重新选开始 —— 没必要。」
链路(**不是我们接线接错了** —— 一个回调都不挂的裸 RangePicker 也一样):
① 点「确定」→ rc-picker 把**活动端复位到「开始」**(那是为"下一次选择"
准备的,正常情况下面板同时也该关掉,用户看不见);
② 但这个版本关完面板又把焦点**送回开始输入框**,Trigger 是"聚焦即展开"
⇒ 面板又开了、还停在「开始」端 ⇒ 用户看到的就是"让我重选开始"。
⇒ 这里**接管 open**:面板开合由我们说了算,点「确定」就关(`onOk`)。
⚠️ 别把 `open` 的接管抄到 `mountAntdSingleDT`:单点控件(任务截止时间)
实测点确定能正常关闭 —— 没有"复位活动端"这回事,多接管一层反而挡开合。
⚠️ 反向验证(_b60_falsify 三处注入)证明:去掉 `open` 接管 / 去掉
`onOk` 里的关闭 / 让 `onOpenChange` 连张开都吞掉 —— 对应断言都会变红。 */
var openState = false;
var root = null;
function renderPicker() {
var picker = React.createElement(antd.DatePicker.RangePicker, {
showTime: { format: 'HH:mm' },
format: 'YYYY-MM-DD HH:mm',
allowClear: true,
placeholder: ['开始时间', '结束时间'],
// ⚠️ 受控:空串要给 null,否则 dayjs('') 会变成 Invalid Date → 控件显示 NaN
value: [st0 ? toDayjs(st0) : null, st1 ? toDayjs(st1) : null],
// 🔴 v60:面板开合由我们说了算(见上面那段)。
open: openState,
onOpenChange: function (o) {
openState = !!o;
renderPicker();
},
onOk: function () {
openState = false;
renderPicker();
},
// ⚠️ onCalendarChange 是**单端也算**的那个(实测:只点开始它就响);
// onChange 只在两端齐了才响。两个都挂才不会丢值。
onCalendarChange: function (vals, strs) {
writeBack(strs);
st0 = (strs && strs[0]) || ''; st1 = (strs && strs[1]) || '';
renderPicker();
},
onChange: function (vals, strs) {
writeBack(strs);
st0 = (strs && strs[0]) || ''; st1 = (strs && strs[1]) || '';
renderPicker();
}
});
/* 🔴 v39:**必须用 ConfigProvider 包住**,否则面板上的「确定 / 取消 / 此刻」
和星期格式还是英文 —— antd 读的是**上下文里的** locale,不是全局变量。
(实测过:资源挂了但没包,日历标题/日期会变中文,**按钮却仍是英文**,
最容易误判成"已经中文化了"。) */
var loc = (window.antd.locales && window.antd.locales.zh_CN) || undefined;
if (loc) {
picker = React.createElement(antd.ConfigProvider, { locale: loc }, picker);
}
if (!root) { root = ReactDOM.createRoot(host); }
root.render(picker);
}
try {
renderPicker();
} catch (e) {
if (window.console) { console.warn('antd RangePicker 挂载失败,留在自建下拉:', e && e.message); }
host.parentNode.removeChild(host);
return false;
}
/* 🔴 v48:把 `__fbRemount` 从"空壳"换成**真的重读原生值 + 重绘**。
⚠️ 原来 upgradeRange() 里写的是 `group.__fbRemount = function () {};`(占位),
于是 fbSetDT 调它等于什么都没做 —— 可见控件永远不跟着变。
⚠️ 本函数**后于** `upgradeRange()` 执行(boot 里先 upgradeRange 再 bootAntd),
所以这里会**覆盖** upgradeRange 设的那版 —— 有 antd 就用 antd 的重绘。 */
group.__fbRemount = function () {
st0 = cur(a); st1 = cur(b);
try { renderPicker(); } catch (e) { /* 重绘失败不该影响已写好的原生值 */ }
};
group.classList.add('is-antd');
group.setAttribute('data-antd-done', '1');
return true;
}
/* 🔴 v44(用户 2026-10-12 第 5 条):「截止时间也用我们最新的日历控件,
记得把现在这个控件删掉。」
⇒ 单个**带时刻**的 AntD DatePicker(`showTime`),不是 RangePicker ——
任务的「截止时间」只有一个时刻,套范围控件会多一个空端点。
操作对象是 `.dt-input[data-dt]`(隐藏 `*_date` + `*_hour/_minute` 是值载体),
与 `mountAntd()`(成对范围)同一套「选完写回原生字段」的路子,
所以**服务端契约一字不改**。
⚠️ 原生那几档下拉的"删除"由 CSS 负责(`.dt-input.is-antd > .dt-native{display:none}`),
这里只管加 `.is-antd` 类 + 挂控件。 */
function mountAntdSingleDT(box) {
if (!box || box.getAttribute('data-antd-done')) { return false; }
var native = box.querySelector('.dt-native');
if (!native) { return false; }
var host = document.createElement('span');
host.className = 'dt-antd';
box.insertBefore(host, box.firstChild);
function cur() {
var s = readDT(box).trim();
var m = s.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})/);
return m ? m[1] + '-' + m[2] + '-' + m[3] + ' ' + m[4] + ':' + m[5] : '';
}
var last = '';
function writeBack(str) {
var s = (str || '');
if (s === last) { return; }
last = s;
writeDT(box, s); // 写回隐藏四字段
if (box.__fbSyncParts) { box.__fbSyncParts(); }
box.classList.toggle('has-value', !!s);
/* 🔴 #77(用户 2026-11-05):「截止日期的时间保存有bug,时和分没保存上。」
真根因:面板保存时读 `.dt-hour` / `.dt-minute` —— 那是**镜像**,
依赖本函数的 writeDT 写回。而实测(Playwright + 真实 antd)显示
用户在弹层里选完、**不点弹层里的「确定」** 就直接点面板的保存时,
镜像还停在上一轮的值(甚至 00:00),于是时分就丢了。
⇒ 把 onChange 拿到的字符串**直接挂到 box 上**。这是"控件刚刚确认的值",
不经任何镜像、不受弹层是否关闭影响。面板保存时优先读它。
⚠️ 用 `data-fb-dt-value` 而不是自定义 JS 属性:dataset 会同步到 DOM 属性,
新节点/重渲染后也能用 `getAttribute` 拿到(虽然本控件是复用同一节点)。
⚠️ 空串也要写(= 用户清空了截止时间),所以不能 `if (s !== '')`。 */
box.setAttribute('data-fb-dt-value', s);
}
var v0 = cur();
/* 🔴 v48:与 `mountAntd` 同一处修复(用户第 2 条)——改成**受控 + 可重绘**。
原来 `defaultValue` 只在挂载时读一次;`fbSetDT` 之后可见控件不跟着变。
(这一处(任务的「截止时间」)目前没有"点击带入"的调用点,
但同一个组件模型必须一致 —— 否则下一个人接它就踩同一个坑。) */
var st = v0, root = null;
function renderPicker() {
var picker = React.createElement(antd.DatePicker, {
showTime: { format: 'HH:mm' },
format: 'YYYY-MM-DD HH:mm',
allowClear: true,
placeholder: '选择时间',
value: st ? window.dayjs(st, 'YYYY-MM-DD HH:mm') : null,
onChange: function (val, str) { writeBack(str); st = str || ''; renderPicker(); }
});
var loc = (window.antd.locales && window.antd.locales.zh_CN) || undefined;
if (loc) { picker = React.createElement(antd.ConfigProvider, { locale: loc }, picker); }
if (!root) { root = ReactDOM.createRoot(host); }
root.render(picker);
}
try {
renderPicker();
} catch (e) {
if (window.console) { console.warn('antd 单点时间控件挂载失败,留在自建下拉:', e && e.message); }
host.parentNode.removeChild(host);
return false;
}
box.__fbRemount = function () {
st = cur();
try { renderPicker(); } catch (e) { /* 重绘失败不影响已写好的原生值 */ }
};
box.classList.add('is-antd');
box.setAttribute('data-antd-done', '1');
return true;
}
function upgrade(box) {
if (box.getAttribute('data-semi-done')) { return; }
// 范围对的成员由 upgradeRange 统一处理
if (box.classList.contains('dt-part')) { return; }
var native = box.querySelector('.dt-native');
if (!native) { return; }
/* v37:单点控件**也不再"升级"** —— 服务端给的就是最终形态(年/月/日 + 时/分下拉)。
这里只负责绑定与打标记。 */
bindParts(box);
box.classList.add('is-native');
box.classList.add('has-value');
/* 🔴 v48:不再是空壳 —— 单点控件的三个下拉要跟着原生值走。
(`mountAntdSingleDT` 若挂上了 antd,会把这个函数**覆盖**成"重读 + 重绘";
没挂 antd 的页面就靠这一版把下拉对齐。见 mountAntdSingleDT。) */
box.__fbRemount = function () {
if (box.__fbSyncParts) { box.__fbSyncParts(); }
};
guardRequired(box);
box.setAttribute('data-semi-done', '1');
}
/**
* 原生控件被藏起来之后,它上面的 `required` 必须**摘掉**并改由我们自己校验。
*
* ⚠️ 这是 Chrome 的一个硬行为:`required` 落在 `display:none` 的控件上时,
* 表单提交会被拦住,但浏览器**没法把焦点移到那个元素**去提示 →
* 控制台报 "An invalid form control with name='…' is not focusable",
* 页面上**什么都不发生**(点了保存没反应,也没有红字提示)。
* 用户会以为"保存按钮坏了"。
*
* 所以:摘掉 required → 给容器打 data-required → 在 form 的 submit 上自查。
* 🔴 单点控件和**范围控件**都要走这里 —— 范围那次我漏了,
* 结果观影表单(starts_at 是必填)怎么点都提交不了。
*/
function guardRequired(box) {
var req = box.querySelector('.dt-date[required]');
/* 🔴 v37:日期改成"年/月/日 下拉 + 隐藏 *_date"之后,
**隐藏框上不能挂 `required`** —— 隐藏控件在表单校验时不可聚焦,
Chrome 会报 "An invalid form control is not focusable":点了保存
**什么都不发生、也没有红字提示**(这正是引入本守卫的原因)。
所以模板改用 `data-dt-required="1"` 声明"这个控件必填",由下面自查。 */
var wantReq = box.getAttribute('data-dt-required') === '1';
if (!req && !wantReq) { return; }
if (req) { req.removeAttribute('required'); }
box.setAttribute('data-required', '1');
var form = box.closest('form');
if (!form || form.getAttribute('data-dt-guard')) { return; }
form.setAttribute('data-dt-guard', '1');
form.addEventListener('submit', function (ev) {
// 不能用 :not([value]) 这类选择器:value 是动态写回的属性,得手动筛
var bad = [];
form.querySelectorAll('.dt-input[data-required="1"]').forEach(function (b) {
var d = b.querySelector('.dt-date');
if (d && !d.value) { bad.push(b); }
});
if (bad.length) {
ev.preventDefault();
bad.forEach(function (b) { b.classList.add('dt-missing'); });
// v37:焦点落到**年**那一档(下拉版的第一个可见控件;那才是"该补的字段")
var inp = bad[0].querySelector('select.dt-year')
|| bad[0].querySelector('.dt-date')
|| bad[0].querySelector('select, input');
if (inp) { inp.focus(); }
}
});
}
var kicked = false;
function boot() {
if (kicked) { return; }
kicked = true;
loadCss(conf.css);
loadJs(conf.react)
.then(function () { return loadJs(conf.dom); })
.then(function () { return loadJs(conf.ui); })
.then(function () {
// 先处理"起止成对"的(它们会各自挂一个范围控件并把自己藏起来),
// 再处理剩下的单点控件 —— 否则范围里的两个会先被当成两个独立控件升级掉。
Array.prototype.forEach.call(document.querySelectorAll('.dt-range'), upgradeRange);
boxes.forEach(upgrade);
upgradeAllMonths();
watchNewControls();
})
.catch(function (err) {
// 资源拉不动就留在原生控件上 —— 渐进增强的兜底,不弹任何打扰
if (window.console) { console.warn('Semi DatePicker 未启用:', err && err.message); }
});
}
// ---- 工作经历的 <input type="month">(YYYY-MM)也换 Semi DatePicker ----
// 原生 type="month" Firefox 压根不支持;同样渐进增强:原 input 藏起来当值载体,
// POST 契约(job_start[]/job_end[])不变。「再加一段」新增的行由 observer 补挂。
function parseYm(str) {
var m = (str || '').trim().match(/^(\d{4})-(\d{1,2})$/);
if (!m) { return undefined; }
var d = new Date(+m[1], +m[2] - 1, 1);
return isNaN(d.getTime()) ? undefined : d;
}
function upgradeMonth(inp) {
if (inp.getAttribute('data-semi-month')) { return; }
inp.setAttribute('data-semi-month', '1');
var host = document.createElement('span');
host.className = 'dt-semi dt-semi-month';
inp.parentNode.insertBefore(host, inp);
try {
ReactDOM.createRoot(host).render(React.createElement(SemiUI.DatePicker, {
type: 'month',
format: 'yyyy-MM',
showClear: true,
defaultValue: parseYm(inp.value),
placeholder: '选择年月',
position: window.matchMedia('(max-width: 640px)').matches ? 'bottom' : 'bottomLeft',
onChange: function (date, str) {
var v = (str || '').trim();
if (inp.value !== v) {
inp.value = v;
inp.dispatchEvent(new Event('change', { bubbles: true }));
}
}
}));
} catch (e) {
if (window.console) { console.warn('月份选择器挂载失败,留在原生控件:', e && e.message); }
return;
}
host.classList.add('is-live');
inp.style.display = 'none';
}
function upgradeAllMonths() {
Array.prototype.forEach.call(document.querySelectorAll('input[type="month"]'), upgradeMonth);
}
/**
* 动态新增的控件也要升级。
*
* 三种会"事后长出来"的:
* · 工作经历「+ 再加一段」→ 新的 input[type=month];
* · 旅程「+ 添加节点」→ 新的 .dt-range(成对起止);
* · 任何其它被 JS 插入的 .dt-input。
* ⚠️ 用一个 200ms 的去抖定时器:这些操作往往**一次插入一大片 DOM**
* (整段表单),逐个 MutationRecord 跑一遍会让升级逻辑反复扫全页。
*/
function watchNewControls() {
if (!window.MutationObserver) { return; }
var timer = null;
new MutationObserver(function () {
if (timer) { return; }
timer = setTimeout(function () {
timer = null;
// 范围先于单点(范围里的两个不能被当成两个独立控件)
Array.prototype.forEach.call(document.querySelectorAll('.dt-range'), upgradeRange);
Array.prototype.forEach.call(
document.querySelectorAll('.dt-input[data-dt]'),
function (bx) { upgrade(bx); });
upgradeAllMonths();
}, 200);
}).observe(document.body, {
childList: true, subtree: true,
// 🔴 v24 第 21 批 第 7 条:**必须也盯 hidden 属性的变化**。
// upgradeRange() 有一道"两端都得可见"的判断(对隐藏容器里的控件跳过升级)。
// 而日程的「新建」弹框**初始就是 hidden 的** —— 里面那对起止控件在弹框打开前
// 永远不可见,于是范围控件**永远挂不上**:用户打开弹框看到的是一片空白
// (CSS 又把未升级的原生控件设成 visibility:hidden),点哪儿都没反应。
// 这正是"时间范围选择器功能无法使用"的真根因。
// 加上 attributeFilter 之后:弹框一被打开(hidden 被摘掉)→ 观察器触发 →
// 重新扫一遍 → 范围控件就挂上了。**通用**:任何"藏在容器里、稍后才露出来"
// 的日期控件(弹框、折叠面板、tab)都会自动升级,不必逐个写钩子。
attributes: true, attributeFilter: ['hidden'],
});
}
/**
* 对外接口:程序化设置某个日期时间字段,并**同步 Semi 的显示**。
*
* 为什么必须有它:日程页「点日历空白处 → 预填开始时间」是页内 JS 直接写原生
* `input.dt-date.value`。Semi 是非受控组件,原生值改了它不会跟着变 ——
* 于是用户会看到"我点了 15:00,控件里还写着 09:00",而提交上去却是 15:00,
* 是最难解释的一类不一致。
*
* 用法:window.fbSetDT('starts_at', '2026-10-05 15:00:00')
* 第二个参数传 '' 表示清空。
*/
/**
* 对外接口:重新扫描并升级所有范围对(供页内 JS 在"结束时间被显示出来"之后调用)。
* 幂等:已升级过的组会被 data-semi-range-done 挡掉。
*/
window.fbUpgradeRange = function () {
Array.prototype.forEach.call(document.querySelectorAll('.dt-range'), upgradeRange);
return true;
};
/**
* 对外接口:**重新扫描并挂 antd RangePicker**(v40 新增)。
*
* 为什么要它:观影详情页的「编辑」现在弹出一个**动态插入**的弹框(AJAX 校验失败
* 时还会整块重画),里面那对起止控件是**后来才出现在 DOM 里**的 ——
* 而 `bootAntd()` 只在页面初始化时扫一次 `[data-dt-antd]`,扫不到它们
* ⇒ 用户看到一个空白的日期行(原生下拉被 CSS 收起、antd 又没挂上)。
* ⚠️ `mountAntd` 有 `data-antd-done` 幂等标记,重复调用不会挂两遍。
*/
window.fbBootAntd = function () {
bootAntd(document.querySelectorAll('[data-dt-antd]'),
document.querySelectorAll('[data-antd-daterange]'),
document.querySelectorAll('[data-antd-date]'),
document.querySelectorAll('[data-dt-antd-single]'));
return true;
};
window.fbSetDT = function (name, str) {
if (!name) { return false; }
var box = null;
Array.prototype.forEach.call(document.querySelectorAll('.dt-input[data-dt]'), function (b) {
if (box) { return; }
var cfg = {};
try { cfg = JSON.parse(b.getAttribute('data-dt') || '{}'); } catch (e) {}
if (cfg.name === name) { box = b; }
});
if (!box) { return false; }
writeDT(box, str);
/* 🔴 v37:隐藏的 *_date 写完还不算完 —— 页面上真正给用户看的是
「年 / 月 / 日」三个下拉,必须让它们**跟着走到同一个值**,
否则会出现"程序设了 15:00,下拉里还写着 09:00"这种最难解释的不一致。 */
if (box.__fbSyncParts) { box.__fbSyncParts(); }
// 原生写完了 —— 现在把 antd 一侧也刷新成同一个值(同尾部那份,见上面 v48 说明)
var group = box.closest ? box.closest('.dt-range') : null;
if (group && group.__fbRemount) { group.__fbRemount(); }
if (box.__fbRemount) { box.__fbRemount(); }
return true;
};
// 错开首屏:idle 时再拉 4 MB 资源,别挡首屏渲染
if ('requestIdleCallback' in window) { requestIdleCallback(boot, { timeout: 3000 }); }
else { setTimeout(boot, 500); }
})();
/* ============================================================
周/日视图的「当前时间红线」自己走(v24 第 20 批 用户第 6 条)
------------------------------------------------------------
用户原话:「用一个**不断**从上到下的红线,来表示当前时间在视图上的刻度和位置。」
⚠️ 服务端只给"渲染那一刻"的位置(%.4f,见 index.php)—— 用户开着页面不动,
过一小时线还停在原处就荒谬了。所以这里起一个定时器让它自己挪。
⚠️ 定时器**每分钟**走一次即可(线的精度就是分钟;每秒重排会白白耗电,
而这条线在视野里根本看不出每秒的差别)。
⚠️ 位置算法必须与 .tg-block / 服务端**同一套**:列高固定 24 小时 →
1 分钟 = 100/1440 %。三处口径一致,线才不会与日程块错位。
⚠️ 跨过午夜时线要**消失**(那一天已经不是"今天"了)—— 这里用"日期变了就重载一次"
处理,而不是把算出来的 top 硬塞到 >100%(那会让线停在格子底部不动,
看着像卡住了)。重载一次最稳,而且一天最多发生一次。
⚠️ 只在页面上真有这条线时才起定时器(月视图没有 `.tg-now`)。
============================================================ */
(function () {
'use strict';
var lines = document.querySelectorAll('[data-now-line]');
if (!lines.length) { return; }
var dayAtLoad = new Date().toDateString();
function place() {
var d = new Date();
if (d.toDateString() !== dayAtLoad) {
// 已经跨过午夜:这一列不再是"今天"了 → 让服务端重新渲染一次(见上面第 4 点)
window.location.reload();
return;
}
var pct = ((d.getHours() * 60 + d.getMinutes()) / 1440) * 100;
Array.prototype.forEach.call(lines, function (el) {
el.style.top = pct.toFixed(4) + '%';
el.setAttribute('title', '现在 ' + ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2));
});
}
// 对齐到"下一分钟整"再开始(否则第一次跳动会晚最多 60 秒,看着像没动)
var msToNextMinute = 60000 - (Date.now() % 60000);
setTimeout(function () {
place();
setInterval(place, 60000);
}, msToNextMinute);
// 从后台切回来时立刻纠一次(有些浏览器会冻结后台标签页的定时器)
document.addEventListener('visibilitychange', function () {
if (!document.hidden) { place(); }
});
})();
/* ============================================================================
首页「近期任务」:把一条已完成的任务行从卡片里摘掉(工单 #20)
----------------------------------------------------------------------------
用户原话(2026-10-28,两次强调):「首页,近期任务,如果勾选了完成,
应该刷新一下这个列表,把完成的删掉。」
🔴🔴 为什么这段放在 app.js 而不是留在 home.php 里:
"把这条标成完成"在首页有**两个入口**——
① 行首那颗复选框(脚本在 home.php 末尾)
② 点整行弹浮层 → 面板里的「状态」三段按钮(脚本是 task_panel.js)
第一版只改了 ①,于是同一个用户动作(都是"标完成")两条路径两种结果:
从复选框勾 → 行消失;从面板点「已完成」→ 行留着,徽标变成「已完成」。
用户复现的就是第二条(我先前的判据完全没测浮层,所以 20/0 全绿也是假绿,
→ pitfalls「需求横跨几个入口就每个入口都要判据」)。
⚠️ 作用域必须限定 `[data-tk-home-host]`(首页任务卡片的外层):
任务**看板**页的行也带 `data-task-row`,那里勾完成是**只改状态、不删行**
(看板是按状态分栏的,"行"就是那个任务本身,删了就没处看了)。
所以这段绝不能按 `[data-task-row]` 全局找。
⚠️ 只在"变更后确实是 done"时删(判据用服务端回的 `d.status`,不是我算出来的 next):
`tasks/status` 是幂等接口,传 done 再来一次还是 done;拿返回值判断才不会
"手快连点两下,第二下把行删了"。
@param {Element} row 要摘掉的那一行 `li[data-task-row]`
@param {string} wasStatus 改**之前**的状态('doing' / 'todo')——
决定概述卡该减「件在做」还是「件待办」。
⚠️ 不能在这里读 `data-tk-status`:调用方通常已经把它改成 done 了。
@return {boolean} 真的删了才 true(行不在首页卡片里就 false,什么也不做)
============================================================================ */
window.fbHomeTaskDrop = function (row, wasStatus) {
if (!row) { return false; }
var host = row.closest ? row.closest('[data-tk-home-host]') : null;
/* 不在首页卡片里(任务看板 / 详情页子任务列表)→ 一律不删。
⚠️ 这条判断是这段代码能被两个入口共用的**前提**,别为了"通用"去掉它。 */
if (!host || !host.contains(row)) { return false; }
var ul = row.parentNode;
if (row.parentNode) { row.parentNode.removeChild(row); }
/* 概述卡的「N 件在做 / N 件待办」同步减 1。
⚠️ 用 `[data-hbs]` 定位(模板里专门加的钩子),**不靠中文文案** ——
"件在做"改一个字就失效,而这是 JS 要读的东西。 */
if (wasStatus === 'doing' || wasStatus === 'todo') {
var a = document.querySelector('.hb-stats a.hbs[data-hbs="' + wasStatus + '"]');
var b = a ? a.querySelector('b') : null;
if (b) {
var n = Math.max(0, (parseInt(b.textContent, 10) || 0) - 1);
b.textContent = String(n);
/* 减到 0 就把整条藏起来:服务端是 `if ($nTodo > 0)` 才渲染待办那一条,
前端不藏会出现「0 件待办」——服务端永远不会画出来的状态。 */
if (n === 0) { a.hidden = true; }
}
}
/* 删到一条不剩:摘掉空 ul,把服务端渲染的空态提示露出来
—— 否则卡片变成一片空白,用户以为加载坏了。 */
if (ul && ul.querySelectorAll && ul.querySelectorAll('[data-task-row]').length === 0) {
if (ul.parentNode) { ul.parentNode.removeChild(ul); }
var empty = host.querySelector('[data-tk-empty]');
if (empty) { empty.hidden = false; }
}
return true;
};
/* ============================================================
#47(2026-11-05)行程状态动作按钮:点一下 → PATCH → 就地重绘
------------------------------------------------------------
用户要求状态由**动作**驱动,不再由编辑表单手工选。
服务端接口:PATCH /api/v1/trips/<tripId>/nodes/status {node_id, status}
⚠️ 但网页端走的是表单式站点,不是 API 客户端 —— 用一个轻量的
POST 到同源端点(下面用 fetch 打站点自己的 node_status 动作)。
============================================================ */
(function () {
function bindNodeStatus() {
var btns = document.querySelectorAll('[data-node-status-set]');
for (var i = 0; i < btns.length; i++) {
var b = btns[i];
if (b.getAttribute('data-bound') === '1') { continue; }
b.setAttribute('data-bound', '1');
b.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
var self = e.currentTarget;
if (self.getAttribute('data-busy') === '1') { return; }
self.setAttribute('data-busy', '1');
var nodeId = self.getAttribute('data-node');
var status = self.getAttribute('data-node-status-set');
var box = self.closest('[data-node-status]');
var tripId = document.body.getAttribute('data-trip-id') || '';
var fd = new FormData();
fd.append('node_id', nodeId);
fd.append('status', status);
if (tripId) { fd.append('trip_id', tripId); }
fd.append('csrf', (window.FB_CSRF || ''));
fetch('index.php?action=node_status', {
method: 'POST',
body: fd,
headers: { 'X-Requested-With': 'fetch' }
}).then(function (r) { return r.json(); }).then(function (res) {
self.removeAttribute('data-busy');
if (!res || !res.ok) {
alert((res && res.error) ? res.error : '状态没改成,请重试。');
return;
}
/* 就地刷新这一段的动作条(不整页重载,避免丢失滚动位置) */
if (box && res.html) {
box.outerHTML = res.html;
bindNodeStatus();
} else {
location.reload();
}
}).catch(function () {
self.removeAttribute('data-busy');
alert('网络出错,状态没改成。');
});
});
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bindNodeStatus);
} else {
bindNodeStatus();
}
window.fbBindNodeStatus = bindNodeStatus;
})();
/* ============================================================
#48(2026-11-05)行程动作:开始 / 完成 / 取消 / 删除
------------------------------------------------------------
用户:「计划行程右上角就不是两个按钮了,而是至少四个按钮:
一个是开始,一个是完成,一个是编辑,一个是删除。
在删除前面还应该有一个按钮,这个按钮是取消。」
⚠️ 完成 = 转成实际行程(stage: plan → actual),之后不再有状态。
⚠️ 取消 ≠ 删除:取消保留数据(stage=skipped),删除才软删。
============================================================ */
(function () {
function bindNodeActs() {
/* ---- 状态动作(开始 / 完成 / 取消)---- */
var acts = document.querySelectorAll('[data-node-act]');
for (var i = 0; i < acts.length; i++) {
var b = acts[i];
if (b.getAttribute('data-bound') === '1') { continue; }
b.setAttribute('data-bound', '1');
b.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
var self = e.currentTarget;
if (self.getAttribute('data-busy') === '1') { return; }
var act = self.getAttribute('data-node-act');
var nodeId = self.getAttribute('data-node');
/* 取消是不可逆的语义(虽然数据还在),先确认一下 */
if (act === 'cancel' && !confirm('标记为不去了?数据会保留在「未成行」里。')) {
return;
}
self.setAttribute('data-busy', '1');
var fd = new FormData();
fd.append('node_id', nodeId);
fd.append('act', act);
fd.append('csrf', (window.FB_CSRF || ''));
fetch('index.php?action=node_act', {
method: 'POST', body: fd,
headers: { 'X-Requested-With': 'fetch' }
}).then(function (r) { return r.json(); }).then(function (res) {
self.removeAttribute('data-busy');
if (!res || !res.ok) {
alert((res && res.error) ? res.error : '没成功,请重试。');
return;
}
/* 动作会改变分组(计划→实际/未成行),必须整页重载 */
location.reload();
}).catch(function () {
self.removeAttribute('data-busy');
alert('网络出错,没成功。');
});
});
}
/* ---- 删除(软删)---- */
var dels = document.querySelectorAll('[data-node-del]');
for (var k = 0; k < dels.length; k++) {
var d = dels[k];
if (d.getAttribute('data-bound') === '1') { continue; }
d.setAttribute('data-bound', '1');
d.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
var self = e.currentTarget;
if (!confirm('彻底删掉这段行程?')) { return; }
var fd = new FormData();
fd.append('node_id', self.getAttribute('data-node-del'));
fd.append('csrf', (window.FB_CSRF || ''));
fetch('index.php?action=node_del', {
method: 'POST', body: fd,
headers: { 'X-Requested-With': 'fetch' }
}).then(function (r) { return r.json(); }).then(function (res) {
if (!res || !res.ok) {
alert((res && res.error) ? res.error : '删不掉,请重试。');
return;
}
location.reload();
}).catch(function () { alert('网络出错。'); });
});
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bindNodeActs);
} else {
bindNodeActs();
}
window.fbBindNodeActs = bindNodeActs;
/* ══════════════════════════════════════════════════════════════════════
#85 自绘下拉框(把原生 select 的展开列表换成自绘浮层)
══════════════════════════════════════════════════════════════════════
🔴 用户 2026-11-05:「网页版的下拉框也太丑了。你参考一下那个反馈系统
(FeedLite)里面的下拉框到底是怎么做的?按它的做法做。」
为什么必须自绘:原生 `<select>` 收起时能改样式,但**展开列表由浏览器绘制,
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 = '<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');
/* 先量宽度再定位: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);
}
})();