Files
FamilyBook/assets/js/app.js
T
laoyang f58a128816 行程:#47~#51 打包 —— 双轨状态、动态类型、动作按钮、去提示 (#51)
#47 编辑浮层居中、类型换行、去掉手动状态选择、按钮改胶囊
#48 计划/实际双轨:完成即转实际行程,取消≠删除,记真实开始/结束时间
#50 类型点击无反应(@Builder 值传参不建立依赖)
#51 节点类型动态加载(服务端9种,原端上硬编码5种)+ 地址结构化回调

API 新增:GET /trips/<id>/node-types、PATCH /trips/<id>/nodes/action
DB 新增:trip_nodes.actual_started_at / actual_ended_at
2026-11-05 12:40:57 +08:00

2799 lines
144 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 家书 · 前端小助手(无框架,无构建步骤) */
(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);
});
}
function loadJs(src) {
return 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);
});
}
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);
});
}
function loadJs(src) {
return 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);
});
}
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);
}
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;
})();