Files
FamilyBook/assets/js/app.js
T
laoyang 7138314dc7 v24 第 15 批:活动 / 旅程两级语义(术语 · 派生时间城市 · 表单三项 · 题图可点)
用户原话四条,逐条落地:

1. 「旅程模块,按钮是记一次活动,而不是记一趟旅程。旅程是活动里面的概念。」
   → 顶层一律改称**活动**:按钮「记一次活动」、页标题「活动」、`N 次` / `N 段旅程`;
     活动详情三组改叫「计划的旅程 / 走过的旅程 / 没成行的」;
     节点表单「这段旅程叫什么」「阶段」;flash 文案 17 处同步。
     (导航条仍叫「旅程」——用户就是这么称呼这个模块,列表页加副标题说清两级关系。)

2. 「活动不需要开始和结束时间,是根据活动中的旅程,最早的旅程开始时间,和最晚的
     旅程结束时间,计算出来的。目的地也不需要…去的城市,也是根据旅程,得到的。」
   → 活动**不再存**这三样,全部**读时算出来**:
     · Trip::derived()      最早开始 / 最晚结束 / 城市去重
     · Trip::cities()       按最早出现时间排(先后顺序本身是信息,不是字母序)
     · Trip::attachDerived() 列表页批量补(避免 N+1)
     · Trip::activitySpan()  没有旅程时说「还没有旅程」,不用「日期待补」
     create/update 不再写 events 的起止与地点(老值保留不动,页面不读)。
     列表排序/搜索、首页「最近去了」改走子查询。

3. 「题图那里,没有办法上传图片,点击哪里都没反应。」——**真 bug**
   「点图 = 开文件框」那句 JS 只写在 people/form.php 的内页脚本里(头像专用);
   第 14 批加的旅程题图与图书封面照抄了同一套 DOM 却没有对应的 JS,
   于是图块有 cursor:pointer 与 hover 高亮(看着能点),点下去毫无反应。
   → 做成声明式通用绑定:图上写 data-pick-input="#fileInputId",
     app.js 统一接管(一个页面多个也各绑各的),并删掉旧的那份专用实现。
     三处(头像 / 题图 / 图书封面)全挂上。

4. 「添加活动的页面,顺序是这样的:添加标题,上传并裁剪题图,然后一段简介,就可以了。」
   → 主区就这三项,顺序照原话;时间/城市不做输入项(只留一句说明它们从哪来,
     不说清用户会满页找"怎么没地方填时间")。
     备注 / 公开 / 同行的人收进「更多设置」折叠区——功能一个没丢。

顺带修掉两处会炸的实现问题:
· ORDER BY 引用聚合别名(first_at)在 MariaDB/MySQL 报 1247,且只在运行时炸、
  php -l 看不出 → 改成 ORDER BY MIN(starts_at),否则整个旅程模块 Fatal。
· e2e 里"详情页显示了派生时间"原本写成整页查找,下面每段旅程自己也渲染日期,
  于是把派生值塞成 null 也照样绿(借光假绿)→ 判据收进页头那一段。

验证:e2e 845/0(+15 条本批断言)· 升级 134/0 · 桌面几何 428/0 · 移动几何 688/0 ·
交互 92/0 · 题图真机点击 4/0 · 读书窄屏 15/0 · 家谱 34/0 + 10/0 + 反向 3/3 ·
观影按钮 60/0 + 反向 3/3 · 本批反向验证 4/4(题图绑定抽掉→红、派生值不挂→红、
城市退回读 location→红、时间字段加回表单→红)· 部署静态断言新增两组(含"活动不许
再写起止时间/目的地"与"ORDER BY 不许引用聚合别名"的反退化防线)。
部署后服务器自检 19/19,五个关键文件 md5 与线上逐一核对一致。
2026-10-05 10:58:17 +08:00

1246 lines
56 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 那套同一思路:不能让没用到裁剪的页面白拉)。
(function () {
var inputs = Array.prototype.slice.call(
document.querySelectorAll('input[type="file"][data-crop-ratio]'));
if (!inputs.length) { return; }
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 = ''; }
});
}
for (var i = 0; i < inputs.length; i++) {
(function (input) {
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);
});
})(inputs[i]);
}
})();
/**
* 通用「点预览图 = 打开文件选择框」。
*
* ⚠️ 为什么必须做成通用的(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';
}
);
// 头像选好后即时预览(仅用于**没走裁剪器**的文件框,避免和裁剪预览打架)
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); }
function blocks() {
return roster.querySelectorAll('.group-block');
}
/** 从事件目标往上找那张可拖的卡片(点在卡片里的链接/表单上时也算这张卡) */
function cardFrom(node) {
var t = node;
while (t && t !== roster) {
if (t.classList && t.classList.contains('person')) { return t; }
t = t.parentNode;
}
return null;
}
/**
* 这次按下该不该算"拖动"?
*
* ⚠️ 按钮 / 输入框 / 下拉 / <summary> 一律不算 —— 在那儿按下是"要用它",
* 不是"要拖这张卡"。唯一例外是 `.drag-handle`(它是专门用来抓的)。
* ⚠️ 大链接 `.person-main` **算**:整行都能拖才顺手(这也是原来的手感),
* 靠 MIN_MOVE 门槛区分"点进去看详情"和"拖着走",并且拖过之后要掐掉那次 click。
*/
function isControl(node) {
var t = node;
while (t && t !== roster) {
if (t.nodeType !== 1) { t = t.parentNode; continue; }
// ⚠️ 判定顺序不能乱:先认"专门用来抓的"和"整行那个大链接",
// 再认"点了就有别的作用的控件"。
if (t.classList && t.classList.contains('drag-handle')) { return false; }
// `.person-main` 虽然是 <a>(点它会进详情页),但整行都能拖才顺手 ——
// 靠 MIN_MOVE 区分"点"与"拖",拖过之后掐掉那次 click。
if (t.classList && t.classList.contains('person-main')) { return false; }
if (t.getAttribute && t.getAttribute('data-no-drag') !== null) { return true; }
var tag = t.tagName;
if (tag === 'BUTTON' || tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA'
|| tag === 'SUMMARY' || tag === 'LABEL' || tag === 'A') {
return true;
}
t = t.parentNode;
}
return false;
}
/** 每栏人数与"空栏"标记跟着 DOM 走 —— 不然拖完之后标题上的「N 人」还是旧的 */
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); }
b.classList.toggle('is-empty', n === 0);
// 🔴 empty 的栏被 filter.js 标了 hidden(筛完没人剩就整栏收起)。
// 拖了一张卡进来之后必须**把 hidden 也清掉** —— 只去掉 is-empty 没用:
// 作者样式里 .group-block 没有写 display,于是浏览器默认的
// [hidden]{display:none} 照样生效,那一栏连同刚放进去的人一起看不见
// (症状就是"拖到后面的分组里,松手什么都没有")。
b.hidden = (n === 0);
});
}
/** 右下角那个小提示(成功/失败都只活两秒,不打断操作) */
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); }
});
cols.push({ g: parseInt(b.getAttribute('data-group'), 10) || 0, 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;
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; }
// 触摸时整行都能拖会让页面没法上下滚 —— 触摸只认那个 ⠿ 手柄
var fromHandle = !!(e.target.closest && e.target.closest('.drag-handle'));
if (e.pointerType !== 'mouse' && !fromHandle) { return; }
if (!fromHandle && isControl(e.target)) { return; }
pending = { card: card, id: e.pointerId, type: e.pointerType };
startPt = { x: e.clientX, y: e.clientY };
lastPt = { x: e.clientX, y: e.clientY };
/* 🔴 空栏必须在**按下的这一刻**就露出来,不能等"真的开始拖"才露。
露出来会让页面变高、下面的分组整体下移 ——
如果等到拖动开始才露,用户瞄准的那个位置在手指一动之后就跑了
(症状:想拖到「未分类」,松手却落进了别的栏,或者干脆没动静)。
按下时就露,布局在**瞄准之前**已经稳定,之后全程不变。
⚠️ 只点一下不拖的情况:松手时会收回去(见下面的 pointerup);
而"点一下"多半是点这一行看详情,页面本来就要跳走,看不到这一下。 */
roster.classList.add('is-dragging');
if (fromHandle) { 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) {
pending = null;
roster.classList.remove('is-dragging'); // 只是点了一下:把露出来的空栏收回去
}
});
document.addEventListener('pointercancel', function () {
if (dragging) { endDrag(); }
else if (pending) {
pending = null;
roster.classList.remove('is-dragging');
}
});
// 拖完掐掉那一次 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;
}
});
})();
})();
// ==================== Semi Design DatePicker(v24 第 9 批 #9) ====================
// 用户点名「所有日期选择换 https://semi.design 的 DatePicker,样式与系统统一」。
// 这里是**渐进增强**的升级层:templates/_datetime.php 照旧渲染原生「日期+时/分/秒」
// 四字段(表单契约与服务端 read_dt_input() 不动),Semi 的资源(合计约 4 MB)只在
// 页面上真出现 .dt-input 时才懒加载;加载成功 → 藏原生、挂 DatePicker、值回写原字段;
// 加载失败 → 原生控件原样可用,页面不会坏。
(function () {
var boxes = Array.prototype.slice.call(document.querySelectorAll('.dt-input[data-dt]'));
var hasMonth = !!document.querySelector('input[type="month"]');
if (!boxes.length && !hasMonth) { return; }
var meta = document.querySelector('meta[name="fb-semi"]');
if (!meta) { 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; }
if (typeof window.SemiUI === 'undefined' || !window.SemiUI.DatePicker) { return; }
if (!window.ReactDOM || !window.ReactDOM.createRoot) { return; }
var host = document.createElement('span');
host.className = 'dt-semi dt-semi-range';
// ⚠️ 插到**第一个原生控件之前**,不能插到 group 的最前面。
// 因为 group 就是那个 `.field`,`<label>` 也是它的子节点:
// 用 group.firstChild 会把控件塞到 label **上面**(第一版就是这么写的),
// 用户看到的现象正是"开始/结束时间这几个字跑到日期控件下面去了"。
group.insertBefore(host, a);
var fmt = 'yyyy-MM-dd HH:mm' + (secs ? ':ss' : '');
function onRange(dates, strs) {
var arr = Array.isArray(strs) ? strs : [strs];
writeDT(a, arr[0] || '');
writeDT(b, arr[1] || '');
}
// 同上:先 unmount 再 createRoot,否则 defaultValue 改了也不生效
var rroot = null;
function mountRange() {
if (rroot) { rroot.unmount(); rroot = null; }
host.innerHTML = '';
var q0 = parseDT(readDT(a)), q1 = parseDT(readDT(b));
rroot = ReactDOM.createRoot(host);
rroot.render(React.createElement(SemiUI.DatePicker, {
type: 'dateTimeRange',
format: fmt,
showClear: true,
defaultValue: (q0 || q1) ? [q0 || q1, q1 || q0] : undefined,
placeholder: ['开始时间', '结束时间'],
position: window.matchMedia('(max-width: 640px)').matches ? 'bottom' : 'bottomLeft',
onChange: onRange
}));
}
try {
mountRange();
} catch (e) {
if (window.console) { console.warn('Semi 范围选择器挂载失败,留在原生控件:', e && e.message); }
host.parentNode.removeChild(host);
return;
}
group.__fbRemount = mountRange;
a.style.display = 'none';
b.style.display = 'none';
// ⚠️ 范围里的两端同样要摘 required(隐藏的 required 会让整个表单提交不了)
guardRequired(a);
guardRequired(b);
group.setAttribute('data-semi-range-done', '1');
group.classList.add('is-semi-range');
}
function upgrade(box) {
if (box.getAttribute('data-semi-done')) { return; }
// 范围对的成员由 upgradeRange 统一处理(一个控件管两端,用户第 7 条)
if (box.classList.contains('dt-part')) { return; }
if (typeof window.SemiUI === 'undefined' || !window.SemiUI.DatePicker) { return; }
if (!window.ReactDOM || !window.ReactDOM.createRoot) { return; }
var cfg = {};
try { cfg = JSON.parse(box.getAttribute('data-dt') || '{}'); } catch (e) { /* 空配置按默认走 */ }
var host = box.querySelector('.dt-semi');
var native = box.querySelector('.dt-native');
if (!host || !native) { return; }
var fmt = 'yyyy-MM-dd HH:mm' + (cfg.seconds === false ? '' : ':ss');
var init = parseDT(readDT(box));
// Semi 的 onChange → 写回原生四字段(POST 契约),并派 change 事件给可能的监听者
function writeBack(date, str) { writeDT(box, str); }
// ⚠️ 重挂必须**先 unmount 再 createRoot**,不能对同一个 root 再 render:
// DatePicker 是非受控组件(我们用 defaultValue),React 18 复用旧 root 时
// 新传的 defaultValue **不会**改变已显示的值 —— 于是 fbSetDT 改了原生字段、
// Semi 还显示旧值("点了 15:00 控件里还写 09:00"的真实成因)。
var root = null;
function mount() {
if (root) { root.unmount(); root = null; }
host.innerHTML = '';
root = ReactDOM.createRoot(host);
root.render(React.createElement(SemiUI.DatePicker, {
type: 'dateTime',
format: fmt,
showClear: true,
defaultValue: parseDT(readDT(box)),
placeholder: '选择日期时间',
position: window.matchMedia('(max-width: 640px)').matches ? 'bottom' : 'bottomLeft',
onChange: writeBack
}));
}
try {
mount();
} catch (e) {
if (window.console) { console.warn('Semi DatePicker 挂载失败,留在原生控件:', e && e.message); }
return;
}
// 页内 JS 可能会**程序化**改这个字段(如日程页「点日历空白处」预填开始时间)——
// 原生值改了 Semi 却还显示旧值,两边就不一致了。所以把重挂函数挂到元素上,
// 供 window.fbSetDT() 调用(见文件末尾)。
box.__fbRemount = mount;
box.classList.add('is-semi');
native.style.display = 'none';
if (init) { box.classList.add('has-value'); }
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]');
if (!req) { return; }
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'); });
var inp = bad[0].querySelector('.dt-semi 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 });
}
/**
* 对外接口:程序化设置某个日期时间字段,并**同步 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;
};
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);
// 原生写完了 —— 现在把 Semi 一侧也刷新成同一个值
var group = box.closest ? box.closest('.dt-range') : null;
if (group && group.getAttribute('data-semi-range-done') && group.__fbRemount) {
group.__fbRemount();
} else if (box.__fbRemount) {
box.__fbRemount();
}
return true;
};
// 错开首屏:idle 时再拉 4 MB 资源,别挡首屏渲染
if ('requestIdleCallback' in window) { requestIdleCallback(boot, { timeout: 3000 }); }
else { setTimeout(boot, 500); }
})();