/* 家书 · 前端小助手(无框架,无构建步骤) */ (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 = ''; 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 就收起来(用
实现,没这段也能用) 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 列表),一次提交写完 —— // 逐条提交会在中途留下自相矛盾的中间态(两个人暂时同号),刷新一下就变样。 // // 没有这段 JS 也完全能用:卡片上的「⋯ → 调整分组」表单还在, // 而且手机上 HTML5 拖放本来就不生效,那套表单就是窄屏的正路。 (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 dragging = null; var noteEl = null; function each(list, fn) { Array.prototype.forEach.call(list, fn); } /** 从事件目标往上找那张可拖的卡片(点在卡片里的链接/表单上时也算这张卡) */ function cardFrom(e) { var t = e.target; while (t && t !== roster) { if (t.classList && t.classList.contains('person') && t.getAttribute('draggable') === 'true') { return t; } t = t.parentNode; } return null; } function blocks() { return roster.querySelectorAll('.group-block'); } /** 每栏人数与"空栏"标记跟着 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); }); } /** 右下角那个小提示(成功/失败都只活两秒,不打断操作) */ 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); }); } roster.addEventListener('dragstart', function (e) { var card = cardFrom(e); if (!card) { return; } // 「⋯」菜单里的下拉与按钮不参与拖动 —— 在那儿按下多半是想选项,不是想拖卡 var t = e.target; while (t && t !== roster) { if (t.getAttribute && t.getAttribute('data-no-drag') !== null) { e.preventDefault(); return; } t = t.parentNode; } dragging = card; roster.classList.add('is-dragging'); card.classList.add('is-dragged'); if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', card.getAttribute('data-person')); } catch (err) { /* 老浏览器忽略 */ } } }); roster.addEventListener('dragover', function (e) { if (!dragging) { return; } var block = e.target && e.target.closest ? e.target.closest('.group-block') : null; if (!block) { return; } e.preventDefault(); if (e.dataTransfer) { e.dataTransfer.dropEffect = 'move'; } var grid = block.querySelector('.people-grid'); /* 落点判定(用户 2026-10-03 第 4 条:"好像只能往后调整,不能往前调整")。 名册已从网格改成**竖向列表**,所以: 指针在某行的**上半部分** → 插到它前面;下半部分 → 插到它后面。 ⚠️ 原来只记了"第一个满足 clientY < 中点 的卡片"然后 insertBefore —— 当目标就在**当前拖着的这张之后**时,insertBefore 到它的位置 等于没动(因为 dragged 自己先被移走、后面的元素整体前移一位), 于是"往前拖"看起来完全无效。现在改成显式记录"插到谁前面/后面", 并且在计算时**排除 dragged 自己**,两种方向都成立。 */ 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 (e.clientY < r.top + r.height / 2) { target = kids[i]; before = true; break; } target = kids[i]; before = false; } if (!target) { grid.appendChild(dragging); } else if (before) { grid.insertBefore(dragging, target); } else { grid.insertBefore(dragging, target.nextSibling); } }); roster.addEventListener('drop', function (e) { // 不 preventDefault 的话浏览器会按"跳到某个地址"处理,整页就跳走了 if (dragging) { e.preventDefault(); } }); roster.addEventListener('dragend', function () { if (!dragging) { return; } dragging.classList.remove('is-dragged'); roster.classList.remove('is-dragging'); dragging = null; refreshCounts(); save(); }); })(); // ===== 点页面其它地方 / 按 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; } }); })(); })();