/* 家书 · 前端小助手(无框架,无构建步骤) */ (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 自动缩放进容器,而不是让容器去滚动。 // · **不要缩放条** → 去掉原来那条 ,改滚轮: // 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 = ''; 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]); } })(); // 头像选好后即时预览(仅用于**没走裁剪器**的文件框,避免和裁剪预览打架) 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 就收起来(用
实现,没这段也能用) 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__label[] / ct__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 也完全能用:行上的「分组」按钮(
里的表单)照样能改分组。 (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; } /** * 这次按下该不该算"拖动"? * * ⚠️ 按钮 / 输入框 / 下拉 / 一律不算 —— 在那儿按下是"要用它", * 不是"要拖这张卡"。唯一例外是 `.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` 虽然是 (点它会进详情页),但整行都能拖才顺手 —— // 靠 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 的页内脚本**。 // 原因:分组表单不再是"每卡一份"的内联
,而是全页**唯一一份**共享浮层 // (1184 人的名册原来有 2369 个 form / 27234 个 input,密码管理器逐个扫描 // 会把页面拖死 —— 用户报的"和 Bitwarden 冲突"就是这个)。 // 表单是页内动态填值的,提交委托跟着它一起走,读起来才是一件事。 // 这里留一条注释而不是删干净,是为了下一个人不要又把委托加回来。 // ===== 点页面其它地方 / 按 Esc 就收起下拉菜单 ===== // 用户 2026-10-03 第 3 条:「菜单栏点击后面,应该自动关闭,不应该一直保持打开」。 // 原生
只在再点一次 summary(或按 Esc)时收起 —— 点别处一律不管, // 于是菜单会"粘"在屏幕上,挡住下面的内容。 // // 覆盖全站的 details.menu(观影/任务的设置菜单、成员卡片的调整分组…), // 不给每个页面各写一份。用事件委托:以后新加的菜单自动生效。 (function () { var openOne = null; function closeAll(except) { var forEach = Array.prototype.forEach; forEach.call(document.querySelectorAll('details.menu[open]'), function (d) { if (d !== except) { d.open = false; } }); if (except) { openOne = except; } } // toggle 不冒泡,所以用捕获阶段监听,能拿到所有菜单的开合 document.addEventListener('toggle', function (e) { var d = e.target; if (!d || d.tagName !== 'DETAILS' || !d.classList.contains('menu')) { return; } if (d.open) { closeAll(d); // 同时只留一个开着 } else if (openOne === d) { openOne = null; } }, true); document.addEventListener('click', function (e) { if (!openOne) { return; } // 点在菜单内部(含 summary)→ 不动,交给原生行为 if (openOne.contains(e.target)) { return; } openOne.open = false; openOne = null; }); // Esc 虽然原生也会收,但这里统一收所有(原生只收获得焦点的那一个) document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { closeAll(null); openOne = null; } }); // 键盘 Tab 走到菜单外时也收起,避免"焦点走了菜单还开着" document.addEventListener('focusin', function (e) { if (openOne && !openOne.contains(e.target)) { openOne.open = false; openOne = null; } }); })(); })(); // ==================== Semi Design DatePicker(v24 第 9 批 #9) ==================== // 用户点名「所有日期选择换 https://semi.design 的 DatePicker,样式与系统统一」。 // 这里是**渐进增强**的升级层:templates/_datetime.php 照旧渲染原生「日期+时/分/秒」 // 四字段(表单契约与服务端 read_dt_input() 不动),Semi 的资源(合计约 4 MB)只在 // 页面上真出现 .dt-input 时才懒加载;加载成功 → 藏原生、挂 DatePicker、值回写原字段; // 加载失败 → 原生控件原样可用,页面不会坏。 (function () { var boxes = Array.prototype.slice.call(document.querySelectorAll('.dt-input[data-dt]')); var hasMonth = !!document.querySelector('input[type="month"]'); if (!boxes.length && !hasMonth) { return; } var meta = document.querySelector('meta[name="fb-semi"]'); if (!meta) { return; } var conf; try { conf = JSON.parse(meta.getAttribute('content')); } catch (e) { return; } if (!conf || !conf.css || !conf.react || !conf.dom || !conf.ui) { return; } function loadCss(href) { return new Promise(function (res) { var l = document.createElement('link'); l.rel = 'stylesheet'; l.href = href; l.onload = function () { res(true); }; l.onerror = function () { res(false); }; document.head.appendChild(l); }); } function loadJs(src) { return new Promise(function (res, rej) { var s = document.createElement('script'); s.src = src; s.onload = function () { res(); }; s.onerror = function () { rej(new Error('加载失败: ' + src)); }; document.head.appendChild(s); }); } function pad(n) { return (n < 10 ? '0' : '') + n; } function parseDT(str) { // 'YYYY-MM-DD [HH:mm[:ss]]' → 本地时区 Date | undefined var m = (str || '').trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?$/); if (!m) { return undefined; } var d = new Date(+m[1], +m[2] - 1, +m[3], +(m[4] || 0), +(m[5] || 0), +(m[6] || 0)); return isNaN(d.getTime()) ? undefined : d; } function part(native, sel) { var n = native.querySelector(sel); return n ? String(n.value || '') : ''; } /** * 把 Semi 给的字符串写回某个 .dt-input 的原生四字段(POST 契约)。 * str 形如 '2026-10-18 07:46:13' / '2026-10-18 07:46' / ''(清空)。 * ⚠️ 提成公用函数:单点控件与范围两端都走它,规则只有一处。 */ function writeDT(box, str) { var m = (str || '').trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$/); function set(sel, v) { var n = box.querySelector(sel); if (!n || n.value === v) { return; } n.value = v; n.dispatchEvent(new Event('change', { bubbles: true })); } if (m) { set('.dt-date', m[1] + '-' + pad(+m[2]) + '-' + pad(+m[3])); set('.dt-hour', pad(+m[4])); set('.dt-minute', pad(+m[5])); set('.dt-second', pad(+(m[6] || 0))); } else { set('.dt-date', ''); set('.dt-hour', '00'); set('.dt-minute', '00'); set('.dt-second', '00'); } } /** 读某个 .dt-input 当前的原生值 → 'YYYY-MM-DD HH:mm:ss' */ function readDT(box) { var native = box.querySelector('.dt-native'); if (!native) { return ''; } return part(native, '.dt-date') + ' ' + (part(native, '.dt-hour') || '00') + ':' + (part(native, '.dt-minute') || '00') + ':' + (part(native, '.dt-second') || '00'); } /** * 起止范围:把一对 .dt-input[data-dt.range=start|end] 合并成**一个** * Semi `dateTimeRange` 选择器(用户第 7 条:「应该用这个日期插件的时间范围选择方式, * 而不是将这个范围拆成两个,这样不好看,也多一步操作」)。 * * 契约不变:两个原生控件都留着(藏在隐藏容器里),Semi 选完把两端分别写回它们, * 所以服务端的 read_dt_input('starts_at') / ('ends_at') 与既有的 e2e 全部照旧。 */ function upgradeRange(group) { if (group.getAttribute('data-semi-range-done')) { return; } var boxes = Array.prototype.slice.call(group.querySelectorAll('.dt-input[data-dt]')); if (boxes.length !== 2) { return; } // ⚠️ **两端都得可见**才升级成范围控件。 // 日程页的「结束」默认是隐藏的(要点「+ 添加结束时间」才出现,见 _event_fields.php)—— // 那时用户表达的意图是"就一个时刻",硬给一个范围控件反而多一个空着的输入框。 // 用户在页面里点了那个开关之后,页内 JS 会再调一次 window.fbUpgradeRange(), // 这时两端都可见了,才真正合并成范围。 var visible = boxes.every(function (bx) { return bx.offsetParent !== null; }); if (!visible) { return; } // 按 data-dt.range 认起止;没标就按 DOM 顺序(起在前) var a = null, b = null, secs = true; boxes.forEach(function (bx) { var cfg = {}; try { cfg = JSON.parse(bx.getAttribute('data-dt') || '{}'); } catch (e) {} if (cfg.seconds === false) { secs = false; } if (cfg.range === 'end') { b = bx; } else if (cfg.range === 'start') { a = bx; } else if (a === null) { a = bx; } else { b = bx; } }); if (!a || !b) { return; } if (typeof window.SemiUI === 'undefined' || !window.SemiUI.DatePicker) { return; } if (!window.ReactDOM || !window.ReactDOM.createRoot) { return; } var host = document.createElement('span'); host.className = 'dt-semi dt-semi-range'; // ⚠️ 插到**第一个原生控件之前**,不能插到 group 的最前面。 // 因为 group 就是那个 `.field`,`