Files
FamilyBook/assets/js/app.js
T

1245 lines
54 KiB
JavaScript
Raw Normal View History

/* 家书 · 前端小助手(无框架,无构建步骤) */
(function () {
'use strict';
// ===== 时间框:点输入框任意一处都弹出选择器 =====
// 原生行为只在点右侧那个小日历图标时才弹(而且各浏览器还不一致),
// 用户会以为框只能读、或者找不到那个图标在哪。
// showPicker() 是标准做法:在 click 里调它,等于把整块输入区都当成了那个图标。
//
// 为什么能跟浏览器自带的那次弹出共存:若本次点击已经弹过,再调一次是空操作
// (规范里「show the picker」对已经显示的选择器直接返回),不会闪两下。
// 不支持的浏览器(老 Safari 等)静默跳过 —— 退化成原生行为,不会比现在更差。
(function () {
var pickers = document.querySelectorAll('input[type="date"], input[type="datetime-local"]');
function hook(el) {
el.addEventListener('click', function () {
if (typeof el.showPicker !== 'function') { return; }
try {
el.showPicker();
} catch (err) {
// NotAllowedError:不是用户手势触发的(或被跨域 iframe 挡住)。
// 忽略即可 —— 原生那套点击行为还在,用户仍有办法选时间。
}
});
}
for (var i = 0; i < pickers.length; i++) { hook(pickers[i]); }
})();
// ===== 图片裁剪 =====
// 比例由**这个位置显示成什么形状**决定(头像 1:1、题图 16:9),
// 由服务端通过 data-crop-ratio 告诉这里 —— 前端不再自己定一个数字。
//
// 缩放(用户明确要求「裁剪图片的时候,需要可以进行缩放」):
// 舞台是个**固定大小的视口**(baseW×baseH),图片按 zoom 放大到 baseW*zoom,
// 放不下就由舞台自己滚动。裁剪框的坐标一律相对**显示出来的图片**算,
// 最后除以 dispW/dispH 换成 0..1 —— 所以放大多少倍都不影响落库的那个矩形。
// 框的最小边长固定 32 显示像素,于是放大之后能选中原图上更小的区域,
// 这正是"缩放"要解决的问题(合影里挑出某一个人的脸)。
//
// ⚠️ 这里只是「让用户挑区域」,真正裁切在服务端做(见 app/Upload.php):
// 前端算出来的框只是**建议**,绕过前端也不会存进一张比例不对的图。
// 所以确认时写回的是**归一化矩形**(相对原图的 0..1),不是像素。
(function () {
var inputs = Array.prototype.slice.call(
document.querySelectorAll('input[type="file"][data-crop-ratio]'));
if (!inputs.length) { return; }
var overlay = null, stage = null, imgEl = null, boxEl = null;
var st = null; // 当前编辑状态
/**
* 解析 data-crop-ratio。
*
* ⚠️ 不能直接 parseFloat:'16:9' 会被读成 **16**(parseFloat 只吃开头那段数字),
* 于是裁剪框变成 16:1 的细长条,而钩子本身不报错、只是裁出来的图没法看。
* 头像的 '1:1' 恰好蒙对(=1),所以这个坑会潜伏到"启用题图"那天才炸。
* 两种写法都支持:'16:9' / '16/9' 按分数算,纯数字按它本身算。
*/
function parseRatioAttr(v) {
var s = String(v == null ? '' : v).trim();
var m = /^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)$/.exec(s);
if (m) {
var a = parseFloat(m[1]), b = parseFloat(m[2]);
return (isFinite(a) && isFinite(b) && b > 0 && a > 0) ? a / b : 1;
}
var f = parseFloat(s);
return (isFinite(f) && f > 0) ? f : 1;
}
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></div>' +
'<div class="crop-stage">' +
'<img class="crop-img" alt="" draggable="false">' +
'<div class="crop-box"><i class="crop-handle" aria-hidden="true"></i></div>' +
'</div>' +
'<div class="crop-zoom-row">' +
'<label for="cropZoom">缩放</label>' +
'<input type="range" class="crop-zoom" id="cropZoom" min="1" max="4" step="0.05" value="1">' +
'<span class="crop-zoom-val">1.0×</span>' +
'<button type="button" class="btn btn-ghost btn-sm" data-act="zoom-reset">还原</button>' +
'</div>' +
'<p class="crop-tip">拖动方框挪位置,拖右下角改大小。框里就是最终会保存的样子。</p>' +
'<div class="crop-actions">' +
'<button type="button" class="btn btn-ghost" data-act="cancel">取消</button>' +
'<button type="button" class="btn" data-act="ok">确定裁剪</button>' +
'</div>' +
'</div>';
document.body.appendChild(overlay);
stage = overlay.querySelector('.crop-stage');
imgEl = overlay.querySelector('.crop-img');
boxEl = overlay.querySelector('.crop-box');
overlay.addEventListener('click', function (e) {
if (e.target === overlay) { close(false); return; }
var act = e.target.getAttribute && e.target.getAttribute('data-act');
if (act === 'cancel') { close(false); }
if (act === 'ok') { close(true); }
if (act === 'zoom-reset') { setZoom(1); }
});
document.addEventListener('keydown', function (e) {
if (overlay && overlay.parentNode && e.key === 'Escape') { close(false); }
});
var zoomEl = overlay.querySelector('.crop-zoom');
zoomEl.addEventListener('input', function () {
var z = parseFloat(zoomEl.value);
if (isFinite(z) && z > 0) { setZoom(z); }
});
// Ctrl/⌘ + 滚轮 缩放;**普通滚轮不拦**,留给舞台自己滚动(放大之后要能平移)
stage.addEventListener('wheel', function (e) {
if (!st || !(e.ctrlKey || e.metaKey)) { return; }
e.preventDefault();
setZoom(st.zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1));
}, { passive: false });
bindDrag(overlay.querySelector('.crop-handle'), 'resize');
bindDrag(boxEl, 'move');
}
/** 让元素可拖动。pointer 事件同时覆盖鼠标与触摸,不用分别写两套。 */
function bindDrag(el, mode) {
el.addEventListener('pointerdown', function (e) {
if (!st || e.button > 0) { return; }
e.preventDefault();
e.stopPropagation();
try { el.setPointerCapture(e.pointerId); } catch (err) { /* 老浏览器忽略 */ }
var x0 = e.clientX, y0 = e.clientY;
var b0 = { x: boxEl.offsetLeft, y: boxEl.offsetTop, w: boxEl.offsetWidth, h: boxEl.offsetHeight };
function onMove(ev) {
var dx = ev.clientX - x0, dy = ev.clientY - y0;
if (mode === 'move') {
place(b0.x + dx, b0.y + dy, b0.w, b0.h);
} else {
// 缩放时**锁定比例**:以宽为准推高,宽被夹住时高也跟着被夹住
var nw = b0.w + dx;
place(b0.x, b0.y, nw, nw / st.ratio);
}
}
function onUp() {
el.removeEventListener('pointermove', onMove);
el.removeEventListener('pointerup', onUp);
el.removeEventListener('pointercancel', onUp);
}
el.addEventListener('pointermove', onMove);
el.addEventListener('pointerup', onUp);
el.addEventListener('pointercancel', onUp);
});
}
var MIN = 32; // 裁剪框最小边长(显示像素),太小了没法拖
var MAX_ZOOM = 4;
/** 把裁剪框放到指定位置,并夹在图片范围内 */
function place(x, y, w, h) {
w = Math.max(MIN, Math.min(w, st.dispW));
h = Math.max(MIN * st.ratio, Math.min(h, st.dispH));
// 比例锁定后再夹一次高度(宽高比可能因为上面的夹取而失配)
if (w / h > st.ratio) { w = h * st.ratio; } else { h = w / st.ratio; }
x = Math.max(0, Math.min(x, st.dispW - w));
y = Math.max(0, Math.min(y, st.dispH - h));
boxEl.style.left = x + 'px';
boxEl.style.top = y + 'px';
boxEl.style.width = w + 'px';
boxEl.style.height = h + 'px';
}
/**
* 改缩放。
*
* 关键:**框在屏幕上的位置和大小都不动**,动的是它底下那张图 ——
* 放大=同一块区域看得更清楚;缩小=框进更多内容。
* (2026-09-30 用户反馈:之前的做法是"归一化区域不变",结果框跟着图一起变大,
* 缩放几下框就大得找不到了。)
* 落库的归一化矩形由 place() 夹取后自动重算 —— 缩放改变选中的区域,这是本意。
*/
function setZoom(z) {
if (!st) { return; }
z = Math.max(1, Math.min(MAX_ZOOM, z));
var b = { x: boxEl.offsetLeft, y: boxEl.offsetTop, w: boxEl.offsetWidth, h: boxEl.offsetHeight };
st.zoom = z;
st.dispW = Math.max(1, Math.round(st.baseW * z));
st.dispH = Math.max(1, Math.round(st.baseH * z));
imgEl.style.width = st.dispW + 'px';
imgEl.style.height = st.dispH + 'px';
// 舞台是**固定大小的视口**(不跟着图片变大),放不下就滚动
stage.style.width = st.baseW + 'px';
stage.style.height = st.baseH + 'px';
var zoomEl = overlay.querySelector('.crop-zoom');
if (zoomEl) { zoomEl.value = String(z); }
var valEl = overlay.querySelector('.crop-zoom-val');
if (valEl) { valEl.textContent = z.toFixed(1) + '×'; }
// 框保持屏显大小;位置也保持(place 只做越界夹取,缩小到 1× 时兜底用)
place(b.x, b.y, b.w, b.h);
}
function open(input, file) {
if (!overlay) { build(); }
var ratio = parseRatioAttr(input.getAttribute('data-crop-ratio'));
var url = URL.createObjectURL(file);
var probe = new Image();
probe.onload = function () {
// 图片按「等比缩放到可用区域」后显示;裁剪框的坐标都相对这块显示区域,
// 最后再除以它换成 0..1 —— 所以原图多大都不影响结果。
var availW = Math.min(window.innerWidth * 0.86, 620);
var availH = Math.min(window.innerHeight * 0.52, 420);
var scale = Math.min(availW / probe.naturalWidth, availH / probe.naturalHeight, 1);
var baseW = Math.max(1, Math.round(probe.naturalWidth * scale));
var baseH = Math.max(1, Math.round(probe.naturalHeight * scale));
st = { input: input, ratio: ratio,
baseW: baseW, baseH: baseH, zoom: 1,
dispW: baseW, dispH: baseH,
naturalW: probe.naturalWidth, naturalH: probe.naturalHeight,
previewSrc: null, outW: parseInt(input.getAttribute('data-crop-w'), 10) || baseW,
outH: parseInt(input.getAttribute('data-crop-h'), 10) || baseH };
imgEl.src = url;
imgEl.style.width = baseW + 'px';
imgEl.style.height = baseH + 'px';
stage.style.width = baseW + 'px';
stage.style.height = baseH + 'px';
stage.scrollLeft = 0;
stage.scrollTop = 0;
// ⚠️ 倍率每次打开都从 1× 开始:上一张图缩到 3× 不该带到这一张上,
// 否则新图的框会莫名其妙是上一张的"放大版"。
var zoomEl = overlay.querySelector('.crop-zoom');
if (zoomEl) { zoomEl.value = '1'; }
var valEl = overlay.querySelector('.crop-zoom-val');
if (valEl) { valEl.textContent = '1.0×'; }
var shape = overlay.querySelector('.crop-shape');
shape.textContent = input.getAttribute('data-crop-label') || '';
overlay.querySelector('.crop-tip').textContent =
input.getAttribute('data-crop-hint') ||
'拖动方框挪位置,拖右下角改大小;放不下就用「缩放」放大再看细节。框里就是最终会保存的样子。';
// 默认框 = 图里最大的、符合目标比例的那块(居中)—— 什么都不动直接确定,
// 效果就等于老的「居中裁切」,所以默认值是安全的。
var w = baseW, h = w / ratio;
if (h > baseH) { h = baseH; w = h * ratio; }
place((baseW - w) / 2, (baseH - h) / 2, w, h);
// 记住预览图原来的 src:取消时恢复,否则预览会停在"没裁过的图"上
var prev = preview(input);
st.previewSrc = prev ? prev.src : null;
st._url = url;
overlay.classList.add('is-open');
};
probe.onerror = function () {
URL.revokeObjectURL(url);
alert('这张图片读不出来,换一张试试。');
input.value = '';
};
probe.src = url;
}
function preview(input) {
var sel = input.getAttribute('data-crop-preview');
return sel ? document.querySelector(sel) : null;
}
function close(ok) {
if (!st) { return; }
if (ok) {
var nx = boxEl.offsetLeft / st.dispW;
var ny = boxEl.offsetTop / st.dispH;
var nw = boxEl.offsetWidth / st.dispW;
var nh = boxEl.offsetHeight / st.dispH;
// 写回归一化矩形 —— 服务端按它裁,与源图分辨率无关
var hidden = hiddenFor(st.input);
if (hidden) {
hidden.value = [nx, ny, nw, nh].map(function (v) {
return (Math.round(v * 1e6) / 1e6).toFixed(6);
}).join(',');
}
drawPreview();
} else {
st.input.value = ''; // 取消 = 不换图,别让它悄悄传上去被居中裁
var prev = preview(st.input);
if (prev && st.previewSrc !== null) { prev.src = st.previewSrc; }
var hid = hiddenFor(st.input);
if (hid) { hid.value = ''; }
}
overlay.classList.remove('is-open');
if (st._url) { URL.revokeObjectURL(st._url); }
st = null;
}
/** 按当前框把结果画出来当预览 —— 让用户看到"框里那块"长什么样 */
function drawPreview() {
var prev = preview(st.input);
if (!prev) { return; }
var canvas = document.createElement('canvas');
canvas.width = st.outW;
canvas.height = st.outH;
var g = canvas.getContext('2d');
var sx = boxEl.offsetLeft / st.dispW * st.naturalW;
var sy = boxEl.offsetTop / st.dispH * st.naturalH;
var sw = boxEl.offsetWidth / st.dispW * st.naturalW;
var sh = boxEl.offsetHeight / st.dispH * st.naturalH;
try {
g.drawImage(imgEl, sx, sy, sw, sh, 0, 0, st.outW, st.outH);
prev.src = canvas.toDataURL('image/jpeg', 0.9);
} catch (err) {
// 跨域图片会让 canvas 变"污染",这里都是本地 ObjectURL,正常不会发生
}
}
/** 同表单里那个存裁剪矩形的隐藏字段 */
function hiddenFor(input) {
var form = input.form;
if (!form) { return null; }
return form.querySelector('input[name="' + input.name + '_crop"]');
}
inputs.forEach(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);
});
});
})();
// 头像选好后即时预览(仅用于**没走裁剪器**的文件框,避免和裁剪预览打架)
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.insertBefore(host, group.firstChild);
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); }
})();