Files
FamilyBook/assets/js/app.js
T
laoyang 02f5d072a4 家谱卡片重做 / 首页控制台 / 名册改列表 / 重复日程与旅程公开
用户 2026-10-03 提的四条:

① 家谱卡片重做
   · 去掉「⇄ 姻亲」按钮,改成**每个人**卡片右下角都有「以 TA 为中心」
     (点了以他重画一张家谱,涵盖原来的姻亲场景)。
   · 头像外框改**长方形**(96×72,宽度贴合头像),object-fit: contain 不裁切,
     姓名/称呼在头像下方。
   · 修「点右下角按钮误进成员页」:卡片不再整张是 <a>,
     只有姓名是链接,右下角是独立的 <button>(物理上分开,不抢点击)。

② 家谱文案与按钮清理
   · 去掉副标题与下方图例说明;整块去掉「看全部 / 回到我 / 回到我的家谱」
     (顺带消掉"出现两个回到我的家谱")。镜头由库自动 fit,拖拽/滚轮即可。

③ 首页控制台
   · 标题下显示个人 slogan;去掉右侧三个统计;「我的资料/我的家谱」换成图标按钮;
     去掉与导航条重复的「名册」按钮。
   · 去掉「接下来的日子」(生日归到"未来七天"里,不再重复)。
   · 新增统计信息块:按时段随机问候 + 今日天气(Open-Meteo,免 key,可留空不显示)
     + 近七天日程/待办/逾期数。
   · 新增各模块小结卡(任务/日程/纪念日/观影/旅程/财务/名册/家谱/资源)。
   · 个人设置新增「首页上的模块」:可勾选与拖动排序(每人一份,存 user_settings)。
   · 保留「往年的今天」。

④ 名册改列表
   · 卡片网格 → **竖向列表**:左头像、中间姓名+与我的关系+手机号+生日。
   · 修拖动「只能往后不能往前」:落点改为显式记录"插到谁前面/后面",
     并排除被拖元素自身。
   · 去掉那句拖动提示(能力保留)。
   · 搜索新增**姓名首字母**检索(y / yy / yyq 都命中「杨永全」):
     新 app/Pinyin.php,intl 优先 + 内置常用字表兜底(本地无 intl 也能测);
     纯字母/数字查询词不走 SQL LIKE(拼音与加密手机号在库里都比不了)。

附带上两轮的收尾:
   · 菜单点外部自动关闭(全局事件委托,覆盖所有 details.menu)。
   · 旅程 is_public:列表与日历只显示公开的;详情页不拦(不丢数据)。
   · 重复日程:新 app/Repeat.php(RRULE 解析/生成/展开,含"工作日"),
     新表 calendar_event_skips(RFC 的 EXDATE);日历按规则展开;
     编辑支持三档「全部 / 这一次及以后 / 仅此一次」。
   · 结束时间可添加/去掉,并随开始时间联动。

验证:端到端 772 项通过 / 0 失败;几何 368、交互 97、移动端 516、家谱 26、
升级 123 全绿;SQL 列名静态校验 0 处可疑;部署 11 条线上探针全过。
2026-10-03 11:30:48 +08:00

648 lines
28 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]); }
})();
// ===== 图片裁剪 =====
// 比例由**这个位置显示成什么形状**决定(头像 1:1、题图 16:9),
// 由服务端通过 data-crop-ratio 告诉这里 —— 前端不再自己定一个数字。
//
// 缩放(用户明确要求「裁剪图片的时候,需要可以进行缩放」):
// 舞台是个**固定大小的视口**(baseW×baseH),图片按 zoom 放大到 baseW*zoom,
// 放不下就由舞台自己滚动。裁剪框的坐标一律相对**显示出来的图片**算,
// 最后除以 dispW/dispH 换成 0..1 —— 所以放大多少倍都不影响落库的那个矩形。
// 框的最小边长固定 32 显示像素,于是放大之后能选中原图上更小的区域,
// 这正是"缩放"要解决的问题(合影里挑出某一个人的脸)。
//
// ⚠️ 这里只是「让用户挑区域」,真正裁切在服务端做(见 app/Upload.php):
// 前端算出来的框只是**建议**,绕过前端也不会存进一张比例不对的图。
// 所以确认时写回的是**归一化矩形**(相对原图的 0..1),不是像素。
(function () {
var inputs = Array.prototype.slice.call(
document.querySelectorAll('input[type="file"][data-crop-ratio]'));
if (!inputs.length) { return; }
var overlay = null, stage = null, imgEl = null, boxEl = null;
var st = null; // 当前编辑状态
/**
* 解析 data-crop-ratio。
*
* ⚠️ 不能直接 parseFloat:'16:9' 会被读成 **16**(parseFloat 只吃开头那段数字),
* 于是裁剪框变成 16:1 的细长条,而钩子本身不报错、只是裁出来的图没法看。
* 头像的 '1:1' 恰好蒙对(=1),所以这个坑会潜伏到"启用题图"那天才炸。
* 两种写法都支持:'16:9' / '16/9' 按分数算,纯数字按它本身算。
*/
function parseRatioAttr(v) {
var s = String(v == null ? '' : v).trim();
var m = /^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)$/.exec(s);
if (m) {
var a = parseFloat(m[1]), b = parseFloat(m[2]);
return (isFinite(a) && isFinite(b) && b > 0 && a > 0) ? a / b : 1;
}
var f = parseFloat(s);
return (isFinite(f) && f > 0) ? f : 1;
}
function build() {
overlay = document.createElement('div');
overlay.className = 'crop-overlay';
overlay.innerHTML =
'<div class="crop-panel" role="dialog" aria-modal="true" aria-label="裁剪图片">' +
'<div class="crop-head"><b>裁剪图片</b><span class="crop-shape"></span></div>' +
'<div class="crop-stage">' +
'<img class="crop-img" alt="" draggable="false">' +
'<div class="crop-box"><i class="crop-handle" aria-hidden="true"></i></div>' +
'</div>' +
'<div class="crop-zoom-row">' +
'<label for="cropZoom">缩放</label>' +
'<input type="range" class="crop-zoom" id="cropZoom" min="1" max="4" step="0.05" value="1">' +
'<span class="crop-zoom-val">1.0×</span>' +
'<button type="button" class="btn btn-ghost btn-sm" data-act="zoom-reset">还原</button>' +
'</div>' +
'<p class="crop-tip">拖动方框挪位置,拖右下角改大小。框里就是最终会保存的样子。</p>' +
'<div class="crop-actions">' +
'<button type="button" class="btn btn-ghost" data-act="cancel">取消</button>' +
'<button type="button" class="btn" data-act="ok">确定裁剪</button>' +
'</div>' +
'</div>';
document.body.appendChild(overlay);
stage = overlay.querySelector('.crop-stage');
imgEl = overlay.querySelector('.crop-img');
boxEl = overlay.querySelector('.crop-box');
overlay.addEventListener('click', function (e) {
if (e.target === overlay) { close(false); return; }
var act = e.target.getAttribute && e.target.getAttribute('data-act');
if (act === 'cancel') { close(false); }
if (act === 'ok') { close(true); }
if (act === 'zoom-reset') { setZoom(1); }
});
document.addEventListener('keydown', function (e) {
if (overlay && overlay.parentNode && e.key === 'Escape') { close(false); }
});
var zoomEl = overlay.querySelector('.crop-zoom');
zoomEl.addEventListener('input', function () {
var z = parseFloat(zoomEl.value);
if (isFinite(z) && z > 0) { setZoom(z); }
});
// Ctrl/⌘ + 滚轮 缩放;**普通滚轮不拦**,留给舞台自己滚动(放大之后要能平移)
stage.addEventListener('wheel', function (e) {
if (!st || !(e.ctrlKey || e.metaKey)) { return; }
e.preventDefault();
setZoom(st.zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1));
}, { passive: false });
bindDrag(overlay.querySelector('.crop-handle'), 'resize');
bindDrag(boxEl, 'move');
}
/** 让元素可拖动。pointer 事件同时覆盖鼠标与触摸,不用分别写两套。 */
function bindDrag(el, mode) {
el.addEventListener('pointerdown', function (e) {
if (!st || e.button > 0) { return; }
e.preventDefault();
e.stopPropagation();
try { el.setPointerCapture(e.pointerId); } catch (err) { /* 老浏览器忽略 */ }
var x0 = e.clientX, y0 = e.clientY;
var b0 = { x: boxEl.offsetLeft, y: boxEl.offsetTop, w: boxEl.offsetWidth, h: boxEl.offsetHeight };
function onMove(ev) {
var dx = ev.clientX - x0, dy = ev.clientY - y0;
if (mode === 'move') {
place(b0.x + dx, b0.y + dy, b0.w, b0.h);
} else {
// 缩放时**锁定比例**:以宽为准推高,宽被夹住时高也跟着被夹住
var nw = b0.w + dx;
place(b0.x, b0.y, nw, nw / st.ratio);
}
}
function onUp() {
el.removeEventListener('pointermove', onMove);
el.removeEventListener('pointerup', onUp);
el.removeEventListener('pointercancel', onUp);
}
el.addEventListener('pointermove', onMove);
el.addEventListener('pointerup', onUp);
el.addEventListener('pointercancel', onUp);
});
}
var MIN = 32; // 裁剪框最小边长(显示像素),太小了没法拖
var MAX_ZOOM = 4;
/** 把裁剪框放到指定位置,并夹在图片范围内 */
function place(x, y, w, h) {
w = Math.max(MIN, Math.min(w, st.dispW));
h = Math.max(MIN * st.ratio, Math.min(h, st.dispH));
// 比例锁定后再夹一次高度(宽高比可能因为上面的夹取而失配)
if (w / h > st.ratio) { w = h * st.ratio; } else { h = w / st.ratio; }
x = Math.max(0, Math.min(x, st.dispW - w));
y = Math.max(0, Math.min(y, st.dispH - h));
boxEl.style.left = x + 'px';
boxEl.style.top = y + 'px';
boxEl.style.width = w + 'px';
boxEl.style.height = h + 'px';
}
/**
* 改缩放。
*
* 关键:**框在屏幕上的位置和大小都不动**,动的是它底下那张图 ——
* 放大=同一块区域看得更清楚;缩小=框进更多内容。
* (2026-09-30 用户反馈:之前的做法是"归一化区域不变",结果框跟着图一起变大,
* 缩放几下框就大得找不到了。)
* 落库的归一化矩形由 place() 夹取后自动重算 —— 缩放改变选中的区域,这是本意。
*/
function setZoom(z) {
if (!st) { return; }
z = Math.max(1, Math.min(MAX_ZOOM, z));
var b = { x: boxEl.offsetLeft, y: boxEl.offsetTop, w: boxEl.offsetWidth, h: boxEl.offsetHeight };
st.zoom = z;
st.dispW = Math.max(1, Math.round(st.baseW * z));
st.dispH = Math.max(1, Math.round(st.baseH * z));
imgEl.style.width = st.dispW + 'px';
imgEl.style.height = st.dispH + 'px';
// 舞台是**固定大小的视口**(不跟着图片变大),放不下就滚动
stage.style.width = st.baseW + 'px';
stage.style.height = st.baseH + 'px';
var zoomEl = overlay.querySelector('.crop-zoom');
if (zoomEl) { zoomEl.value = String(z); }
var valEl = overlay.querySelector('.crop-zoom-val');
if (valEl) { valEl.textContent = z.toFixed(1) + '×'; }
// 框保持屏显大小;位置也保持(place 只做越界夹取,缩小到 1× 时兜底用)
place(b.x, b.y, b.w, b.h);
}
function open(input, file) {
if (!overlay) { build(); }
var ratio = parseRatioAttr(input.getAttribute('data-crop-ratio'));
var url = URL.createObjectURL(file);
var probe = new Image();
probe.onload = function () {
// 图片按「等比缩放到可用区域」后显示;裁剪框的坐标都相对这块显示区域,
// 最后再除以它换成 0..1 —— 所以原图多大都不影响结果。
var availW = Math.min(window.innerWidth * 0.86, 620);
var availH = Math.min(window.innerHeight * 0.52, 420);
var scale = Math.min(availW / probe.naturalWidth, availH / probe.naturalHeight, 1);
var baseW = Math.max(1, Math.round(probe.naturalWidth * scale));
var baseH = Math.max(1, Math.round(probe.naturalHeight * scale));
st = { input: input, ratio: ratio,
baseW: baseW, baseH: baseH, zoom: 1,
dispW: baseW, dispH: baseH,
naturalW: probe.naturalWidth, naturalH: probe.naturalHeight,
previewSrc: null, outW: parseInt(input.getAttribute('data-crop-w'), 10) || baseW,
outH: parseInt(input.getAttribute('data-crop-h'), 10) || baseH };
imgEl.src = url;
imgEl.style.width = baseW + 'px';
imgEl.style.height = baseH + 'px';
stage.style.width = baseW + 'px';
stage.style.height = baseH + 'px';
stage.scrollLeft = 0;
stage.scrollTop = 0;
// ⚠️ 倍率每次打开都从 1× 开始:上一张图缩到 3× 不该带到这一张上,
// 否则新图的框会莫名其妙是上一张的"放大版"。
var zoomEl = overlay.querySelector('.crop-zoom');
if (zoomEl) { zoomEl.value = '1'; }
var valEl = overlay.querySelector('.crop-zoom-val');
if (valEl) { valEl.textContent = '1.0×'; }
var shape = overlay.querySelector('.crop-shape');
shape.textContent = input.getAttribute('data-crop-label') || '';
overlay.querySelector('.crop-tip').textContent =
input.getAttribute('data-crop-hint') ||
'拖动方框挪位置,拖右下角改大小;放不下就用「缩放」放大再看细节。框里就是最终会保存的样子。';
// 默认框 = 图里最大的、符合目标比例的那块(居中)—— 什么都不动直接确定,
// 效果就等于老的「居中裁切」,所以默认值是安全的。
var w = baseW, h = w / ratio;
if (h > baseH) { h = baseH; w = h * ratio; }
place((baseW - w) / 2, (baseH - h) / 2, w, h);
// 记住预览图原来的 src:取消时恢复,否则预览会停在"没裁过的图"上
var prev = preview(input);
st.previewSrc = prev ? prev.src : null;
st._url = url;
overlay.classList.add('is-open');
};
probe.onerror = function () {
URL.revokeObjectURL(url);
alert('这张图片读不出来,换一张试试。');
input.value = '';
};
probe.src = url;
}
function preview(input) {
var sel = input.getAttribute('data-crop-preview');
return sel ? document.querySelector(sel) : null;
}
function close(ok) {
if (!st) { return; }
if (ok) {
var nx = boxEl.offsetLeft / st.dispW;
var ny = boxEl.offsetTop / st.dispH;
var nw = boxEl.offsetWidth / st.dispW;
var nh = boxEl.offsetHeight / st.dispH;
// 写回归一化矩形 —— 服务端按它裁,与源图分辨率无关
var hidden = hiddenFor(st.input);
if (hidden) {
hidden.value = [nx, ny, nw, nh].map(function (v) {
return (Math.round(v * 1e6) / 1e6).toFixed(6);
}).join(',');
}
drawPreview();
} else {
st.input.value = ''; // 取消 = 不换图,别让它悄悄传上去被居中裁
var prev = preview(st.input);
if (prev && st.previewSrc !== null) { prev.src = st.previewSrc; }
var hid = hiddenFor(st.input);
if (hid) { hid.value = ''; }
}
overlay.classList.remove('is-open');
if (st._url) { URL.revokeObjectURL(st._url); }
st = null;
}
/** 按当前框把结果画出来当预览 —— 让用户看到"框里那块"长什么样 */
function drawPreview() {
var prev = preview(st.input);
if (!prev) { return; }
var canvas = document.createElement('canvas');
canvas.width = st.outW;
canvas.height = st.outH;
var g = canvas.getContext('2d');
var sx = boxEl.offsetLeft / st.dispW * st.naturalW;
var sy = boxEl.offsetTop / st.dispH * st.naturalH;
var sw = boxEl.offsetWidth / st.dispW * st.naturalW;
var sh = boxEl.offsetHeight / st.dispH * st.naturalH;
try {
g.drawImage(imgEl, sx, sy, sw, sh, 0, 0, st.outW, st.outH);
prev.src = canvas.toDataURL('image/jpeg', 0.9);
} catch (err) {
// 跨域图片会让 canvas 变"污染",这里都是本地 ObjectURL,正常不会发生
}
}
/** 同表单里那个存裁剪矩形的隐藏字段 */
function hiddenFor(input) {
var form = input.form;
if (!form) { return null; }
return form.querySelector('input[name="' + input.name + '_crop"]');
}
inputs.forEach(function (input) {
input.addEventListener('change', function () {
var f = input.files && input.files[0];
if (!f) { return; }
if (!/^image\//.test(f.type)) { alert('请选择图片文件。'); input.value = ''; return; }
open(input, f);
});
});
})();
// 头像选好后即时预览(仅用于**没走裁剪器**的文件框,避免和裁剪预览打架)
var fileInput = document.getElementById('avatar');
if (fileInput && !fileInput.hasAttribute('data-crop-ratio') && window.FileReader) {
fileInput.addEventListener('change', function () {
var f = this.files && this.files[0];
if (!f) { return; }
var img = document.getElementById('avatar-preview');
if (!img) { return; }
var reader = new FileReader();
reader.onload = function (ev) { img.src = ev.target.result; };
reader.readAsDataURL(f);
});
}
// 下拉菜单:点外面 / 按 Esc 就收起来(用 <details> 实现,没这段也能用)
Array.prototype.forEach.call(document.querySelectorAll('details.menu'), function (d) {
document.addEventListener('click', function (e) {
if (d.open && !d.contains(e.target)) { d.open = false; }
});
document.addEventListener('keydown', function (e) {
if (d.open && e.key === 'Escape') { d.open = false; }
});
});
// 头像加载失败时换回内置剪影 —— 保证任何时候都不会出现破图
Array.prototype.forEach.call(document.querySelectorAll('img'), function (img) {
img.addEventListener('error', function () {
if (img.dataset.fallbackDone) { return; }
img.dataset.fallbackDone = '1';
img.src = '/assets/avatar-default.svg';
});
});
// ===== 三个生日:选择"以哪个为准" =====
// 阳历与农历其实是同一个人的同一天,不能各填各的(那样就自相矛盾了)。
// 表单上让用户明确指定"我这次填的是哪一个",服务端据此反推另一个。
(function () {
var sel = document.getElementById('bdSource');
if (!sel) { return; }
var solarBox = document.getElementById('bdSolarWrap');
var lunarBox = document.getElementById('bdLunarWrap');
function sync() {
var isLunar = sel.value === 'lunar';
if (solarBox) { solarBox.classList.toggle('is-primary', !isLunar); }
if (lunarBox) { lunarBox.classList.toggle('is-primary', isLunar); }
}
sel.addEventListener('change', sync);
sync();
})();
// ===== 身份证号 → 即时算出「身份证生日」 =====
// 让用户当场看到"系统从证件里读出来的是哪一天",
// 而不是存下去之后才发现跟自己的生日差了一个月。
(function () {
var idc = document.getElementById('id_card');
var out = document.getElementById('idCardBirthday');
if (!idc || !out) { return; }
function pad(n) { return (n < 10 ? '0' : '') + n; }
function calc() {
var s = (idc.value || '').toUpperCase().replace(/\s/g, '');
var y, m, d;
if (/^\d{17}[\dX]$/.test(s)) {
y = +s.substr(6, 4); m = +s.substr(10, 2); d = +s.substr(12, 2);
} else if (/^\d{15}$/.test(s)) {
y = 1900 + +s.substr(6, 2); m = +s.substr(8, 2); d = +s.substr(10, 2);
} else {
out.textContent = '';
return;
}
if (m < 1 || m > 12 || d < 1 || d > 31) { out.textContent = ''; return; }
out.textContent = y + '-' + pad(m) + '-' + pad(d);
}
idc.addEventListener('input', calc);
calc();
})();
// ===== 补充联系方式:「再加一个」克隆出下一行 =====
// 平行数组命名(ct_<kind>_label[] / ct_<kind>_value[])→ 克隆行就是合法的追加,
// 服务端按下标 zip,不需要任何计数器字段。
(function () {
document.addEventListener('click', function (e) {
var btn = e.target.closest ? e.target.closest('.ct-add') : null;
if (!btn) { return; }
var block = btn.closest('.ct-block');
if (!block) { return; }
var rows = block.querySelectorAll('[data-ct-row]');
if (!rows.length) { return; }
var clone = rows[rows.length - 1].cloneNode(true);
Array.prototype.forEach.call(clone.querySelectorAll('input'), function (i) { i.value = ''; });
rows[rows.length - 1].parentNode.insertBefore(clone, btn);
var first = clone.querySelector('input');
if (first) { first.focus(); }
});
})();
// ===== 名册:拖动卡片改顺序 / 拖到别的一栏改分组 =====
//
// 落库的是**整版布局**(每栏的有序 id 列表),一次提交写完 ——
// 逐条提交会在中途留下自相矛盾的中间态(两个人暂时同号),刷新一下就变样。
//
// 没有这段 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 条:「菜单栏点击后面,应该自动关闭,不应该一直保持打开」。
// 原生 <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;
}
});
})();
})();