Files
FamilyBook/assets/js/app.js
T
laoyang 6852b4ee2c v28 第 28 批(12 条):名册分组改标签 / 资源合并为两按钮 / 首页定位与主题色 / 笔记富文本双模式
其中 4 条是**上批遗漏的真 bug**(1/4/8/9),根因已逐条定位:

1. 首页日程没定位到红线 —— 真根因:刻度区间**不含当前时刻** → $showNow=false
   → 红线**整个不渲染**,而定位脚本用同一个越界比例算目标,被 clamp 到底部。
   修:把当前小时并进区间端点(现在必然落在区间内)。
2. 「看整月」→「更多日程」+ 日程主题色;3. 「手头在做」→「近期任务」、
   「任务看板」→「全部任务」+ 任务主题色(注入 --hd-c,不再写死白底)。
4. 任务复选框提示还在 —— 真根因:**JS 在点击后**给状态徽标挂 title
   (`badge.setAttribute('title',…)`),上批只去了静态渲染,静态断言永远抓不到。
5. 名册「分组」→「标签」(用户:一个人可能既是小学同学又是初中同学,没法分组):
   新增 person_tags(每人一份、一人多标签),名册改**平铺 + 顶部标签筛选**;
   🔴 迁移用**一次性哨兵**(第一版用 NOT EXISTS,实测被"用户清空标签"绕过 → 旧分组名被灌回)。
6. 名册卡片按钮加语义底色。
7. 资源四按钮 → **编辑 / 删除**两颗:三块内容进一个带页签的弹框(信息/附件/共享)。
8. 财务多选数字不变 —— 真根因:前端发 `kinds=a,b`(逗号串),服务端 `(array)` 收成
   单元素 → 求交为空 → **静默回落整本账**。修:两种形状都收。
9. 微信读书偶发很慢 —— 真根因:syncNow 扫 60 本 × 每本 1~3 次网关调用(单次超时 20s),
   全同步阻塞在列表页请求里。修:总时长预算 + 单次超时 8s + 自动/手动分批。
10. 新增全局可见的联想接口库(6 个实测可用来源)+ 三种新解析类型。
11. 导航右上角「设置」:默认进"添加网址 + 全部网址",四类设置收进 ?set=1 视图。
12. 笔记:段落/清单支持 Markdown(自研渲染器,**不允许原始 HTML**),
    实时预览走服务端(前后端只一份规则);底部附件上传;清单多级;
    列表改**定尺寸卡片网格**(溢出显示摘要、图片缩放下方);按用户要求**去掉标签侧栏**。
2026-10-08 02:20:19 +08:00

1690 lines
82 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';
}
);
/**
* 拖动上传(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` 并提示等一会儿 —— 否则
"拖了但没保存上"是必然发生的(概率还不低)。
============================================================ */
function queueDrop(box, input, files) {
var form = input.form;
if (!form) { return; }
// ⚠️ 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]');
// 队列状态挂在 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));
var 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);
// 撤回这张(令牌与卡片一起去掉;服务端的暂存由 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--; }); // 无论成败都要减,否则守卫会把表单永久锁住
});
});
}
/** 取当前表单里的 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();
}
});
}
/** 找"属于这个 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
if (input.id) {
Array.prototype.forEach.call(
document.querySelectorAll('[data-drop-stage][data-sd-for="' + input.id + '"]'),
function (bx) { bindZone(bx, input); bindBox(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;
}
});
})();
})();
// ==================== 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';
/* 🔴 v24 第 21 批 第 7 条:这里原来写的是 `group.insertBefore(host, a)` ——
**两端各自被 `.field` 包着**时 `a` 并不是 `group` 的直接子节点,
insertBefore 会直接抛
"Failed to execute 'insertBefore' on 'Node': The node before which the new
node is to be inserted is not a child of this node."
这个异常以前**从没被触发过**,因为上面那道"两端都得可见"的判断总是先 return
(结束那一端默认 hidden)→ 恰好把这一行挡住了。
一旦把两端都放出来,它就立刻炸:整个升级流程中断 → 范围控件永远挂不上
→ 用户看到一片空白。所以修"时间范围无法使用"必须**同时**修这里。
插到"第一个端点最外层包装"之前(包装拿不到就退回插在 a 前面),
这样控件出现在标签下方、两个端点位置,视觉顺序是 标签 → 控件。 */
var wrapA = (a.closest ? a.closest('.field') : null) || a;
var insertTarget = (wrapA !== a && group.contains(wrapA)) ? wrapA : a;
if (insertTarget.parentNode !== group) { insertTarget = a; }
group.insertBefore(host, insertTarget);
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;
/* ⚠️ 隐藏的是**端点最外层的包装**(那个 `.field`),不是里层的 `.dt-input`:
只藏里层的话,两端的 `<label>` 还留在页面上,会出现
"标签写着一套、下面只有一个控件"的错位。 */
var hideA = (a.closest ? a.closest('.field') : null) || a;
var hideB = (b.closest ? b.closest('.field') : null) || b;
hideA.style.display = 'none';
hideB.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,
// 🔴 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;
};
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); }
})();
/* ============================================================
周/日视图的「当前时间红线」自己走(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(); }
});
})();