名册每人一份 · 分组两层 · 拖动排序 · 表单合一 · 裁剪可缩放

按用户十点反馈逐条落地(v5,Schema::VERSION = 5):

- 名册改成每人一份:新表 person_roster(owner_id, person_id, group_id,
  sort_order, source)。默认只看「我添加的 + 别人分享给我的」;
  source='seeded' 是升级时从老 persons.group_id 播进来的(先播数据再删列)。
- 排序不再手填:名册页直接拖动卡片(整版提交,一次 applyLayout 落库),
  无 JS 时卡片上的「调整分组」表单照旧可用。
- 分组分两层:管理员在「系统设置」维护公共分组(owner_id=0),
  用户在「个人设置」加自己的分组;显示顺序各人各定、互不影响。
- 名册可共享:把名片分享给别的用户(people/share);添加成员时若系统里
  已有这个人,可直接复用(people/pick),不必重复录入。
- 「编辑成员」与「改我自己的资料」合并为同一张表(templates/people/form.php),
  两个入口 href 完全相同,h1 按改谁切换文案;个人设置页只留
  资料入口 + 称呼 + 我的分组三块,删除 templates/settings/_profile.php。
- 系统设置收进头像下拉,顶栏只剩 首页/家谱/名册。
- 裁剪器加缩放(滑块 1-4x、Ctrl+滚轮、还原):缩放只改视图大小,
  落库的归一化矩形保持不变。
- 权限:管理员可以改任何人的权限(含自己),只拦「最后一位管理员」被降级。
- 去掉首页「我的一句话」后的链接、家谱说明段、以及「还没归到任何分组」提示。

迁移安全:数据回填(PersonRoster::seedFromPersons)先于删列执行、且不进
tryStep 容错;失败步骤记入 settings.schema_upgrade_warning 并在系统设置页提示。
线上已验证:schema_upgrade_warning 为空,person_roster/person_groups.owner_id
就位,persons.group_id / sort_order 已删。

验证:php -l 48 文件;本地 e2e 277/0、老库升级 81/0、桌面几何 108/0、
移动端几何 180/0、交互 96/0;反向验证 38 条注入 + 2 条缩放注入全部命中;
部署静态断言 53/0,站点自检 7/7(--resolve 锁本机走 443,探针真的进 PHP)。
This commit is contained in:
2026-09-30 13:24:26 +08:00
parent 1c460a0e83
commit ed38a40c08
21 changed files with 1780 additions and 594 deletions
+246 -12
View File
@@ -30,6 +30,13 @@
// 比例由**这个位置显示成什么形状**决定(头像 1:1、题图 16:9),
// 由服务端通过 data-crop-ratio 告诉这里 —— 前端不再自己定一个数字。
//
// 缩放(用户明确要求「裁剪图片的时候,需要可以进行缩放」):
// 舞台是个**固定大小的视口**(baseW×baseH),图片按 zoom 放大到 baseW*zoom,
// 放不下就由舞台自己滚动。裁剪框的坐标一律相对**显示出来的图片**算,
// 最后除以 dispW/dispH 换成 0..1 —— 所以放大多少倍都不影响落库的那个矩形。
// 框的最小边长固定 32 显示像素,于是放大之后能选中原图上更小的区域,
// 这正是"缩放"要解决的问题(合影里挑出某一个人的脸)。
//
// ⚠️ 这里只是「让用户挑区域」,真正裁切在服务端做(见 app/Upload.php):
// 前端算出来的框只是**建议**,绕过前端也不会存进一张比例不对的图。
// 所以确认时写回的是**归一化矩形**(相对原图的 0..1),不是像素。
@@ -67,9 +74,15 @@
'<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="">' +
'<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>' +
@@ -86,10 +99,24 @@
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');
}
@@ -125,6 +152,7 @@
}
var MIN = 32; // 裁剪框最小边长(显示像素),太小了没法拖
var MAX_ZOOM = 4;
/** 把裁剪框放到指定位置,并夹在图片范围内 */
function place(x, y, w, h) {
@@ -140,6 +168,49 @@
boxEl.style.height = h + 'px';
}
/** 当前框的归一化矩形(相对**显示出来的图片**,与倍率无关) */
function normOf() {
if (!st || !st.dispW || !st.dispH) { return { x: 0, y: 0, w: 1, h: 1 }; }
return {
x: boxEl.offsetLeft / st.dispW,
y: boxEl.offsetTop / st.dispH,
w: boxEl.offsetWidth / st.dispW,
h: boxEl.offsetHeight / st.dispH,
};
}
/**
* 改缩放。
*
* 关键:**框住的归一化区域保持不变**,只是把像素坐标整体重算 ——
* 于是"放大"只让你看得更清楚、能框得更细,绝不会把已经选好的区域改掉。
* 放大后把框滚回视野中间,不然用户会以为框不见了(框在滚动区外面)。
*/
function setZoom(z) {
if (!st) { return; }
z = Math.max(1, Math.min(MAX_ZOOM, z));
var before = normOf();
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(before.x * st.dispW, before.y * st.dispH,
before.w * st.dispW, before.h * st.dispH);
stage.scrollLeft = Math.max(0, boxEl.offsetLeft - (st.baseW - boxEl.offsetWidth) / 2);
stage.scrollTop = Math.max(0, boxEl.offsetTop - (st.baseH - boxEl.offsetHeight) / 2);
}
function open(input, file) {
if (!overlay) { build(); }
var ratio = parseRatioAttr(input.getAttribute('data-crop-ratio'));
@@ -151,28 +222,41 @@
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 dispW = Math.max(1, Math.round(probe.naturalWidth * scale));
var dispH = Math.max(1, Math.round(probe.naturalHeight * scale));
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, dispW: dispW, dispH: dispH,
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) || dispW,
outH: parseInt(input.getAttribute('data-crop-h'), 10) || dispH };
previewSrc: null, outW: parseInt(input.getAttribute('data-crop-w'), 10) || baseW,
outH: parseInt(input.getAttribute('data-crop-h'), 10) || baseH };
imgEl.src = url;
stage.style.width = dispW + 'px';
stage.style.height = dispH + 'px';
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 = dispW, h = w / ratio;
if (h > dispH) { h = dispH; w = h * ratio; }
place((dispW - w) / 2, (dispH - h) / 2, w, h);
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);
@@ -332,4 +416,154 @@
idc.addEventListener('input', calc);
calc();
})();
// ===== 名册:拖动卡片改顺序 / 拖到别的一栏改分组 =====
//
// 落库的是**整版布局**(每栏的有序 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');
// 落点 = 指针在谁的上半部分,就插到谁前面(和列表拖拽的直觉一致)
var after = null;
each(grid.querySelectorAll('.person'), function (c) {
if (after || c === dragging) { return; }
var r = c.getBoundingClientRect();
if (e.clientY < r.top + r.height / 2) { after = c; }
});
if (after) {
grid.insertBefore(dragging, after);
} else {
grid.appendChild(dragging);
}
});
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();
});
})();
})();