2026-09-30 00:49:53 +08:00
|
|
|
|
/* 家书 · 前端小助手(无框架,无构建步骤) */
|
|
|
|
|
|
(function () {
|
|
|
|
|
|
'use strict';
|
|
|
|
|
|
|
2026-09-30 09:44:55 +08:00
|
|
|
|
// ===== 时间框:点输入框任意一处都弹出选择器 =====
|
|
|
|
|
|
// 原生行为只在点右侧那个小日历图标时才弹(而且各浏览器还不一致),
|
|
|
|
|
|
// 用户会以为框只能读、或者找不到那个图标在哪。
|
|
|
|
|
|
// 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]); }
|
|
|
|
|
|
})();
|
|
|
|
|
|
|
2026-09-30 11:06:22 +08:00
|
|
|
|
// ===== 图片裁剪 =====
|
|
|
|
|
|
// 比例由**这个位置显示成什么形状**决定(头像 1:1、题图 16:9),
|
|
|
|
|
|
// 由服务端通过 data-crop-ratio 告诉这里 —— 前端不再自己定一个数字。
|
|
|
|
|
|
//
|
2026-09-30 13:24:26 +08:00
|
|
|
|
// 缩放(用户明确要求「裁剪图片的时候,需要可以进行缩放」):
|
|
|
|
|
|
// 舞台是个**固定大小的视口**(baseW×baseH),图片按 zoom 放大到 baseW*zoom,
|
|
|
|
|
|
// 放不下就由舞台自己滚动。裁剪框的坐标一律相对**显示出来的图片**算,
|
|
|
|
|
|
// 最后除以 dispW/dispH 换成 0..1 —— 所以放大多少倍都不影响落库的那个矩形。
|
|
|
|
|
|
// 框的最小边长固定 32 显示像素,于是放大之后能选中原图上更小的区域,
|
|
|
|
|
|
// 这正是"缩放"要解决的问题(合影里挑出某一个人的脸)。
|
|
|
|
|
|
//
|
2026-09-30 11:06:22 +08:00
|
|
|
|
// ⚠️ 这里只是「让用户挑区域」,真正裁切在服务端做(见 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">' +
|
2026-09-30 13:24:26 +08:00
|
|
|
|
'<img class="crop-img" alt="" draggable="false">' +
|
2026-09-30 11:06:22 +08:00
|
|
|
|
'<div class="crop-box"><i class="crop-handle" aria-hidden="true"></i></div>' +
|
|
|
|
|
|
'</div>' +
|
2026-09-30 13:24:26 +08:00
|
|
|
|
'<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>' +
|
2026-09-30 11:06:22 +08:00
|
|
|
|
'<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); }
|
2026-09-30 13:24:26 +08:00
|
|
|
|
if (act === 'zoom-reset') { setZoom(1); }
|
2026-09-30 11:06:22 +08:00
|
|
|
|
});
|
|
|
|
|
|
document.addEventListener('keydown', function (e) {
|
|
|
|
|
|
if (overlay && overlay.parentNode && e.key === 'Escape') { close(false); }
|
|
|
|
|
|
});
|
2026-09-30 13:24:26 +08:00
|
|
|
|
|
|
|
|
|
|
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 });
|
|
|
|
|
|
|
2026-09-30 11:06:22 +08:00
|
|
|
|
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; // 裁剪框最小边长(显示像素),太小了没法拖
|
2026-09-30 13:24:26 +08:00
|
|
|
|
var MAX_ZOOM = 4;
|
2026-09-30 11:06:22 +08:00
|
|
|
|
|
|
|
|
|
|
/** 把裁剪框放到指定位置,并夹在图片范围内 */
|
|
|
|
|
|
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 13:24:26 +08:00
|
|
|
|
/** 当前框的归一化矩形(相对**显示出来的图片**,与倍率无关) */
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 11:06:22 +08:00
|
|
|
|
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);
|
2026-09-30 13:24:26 +08:00
|
|
|
|
var baseW = Math.max(1, Math.round(probe.naturalWidth * scale));
|
|
|
|
|
|
var baseH = Math.max(1, Math.round(probe.naturalHeight * scale));
|
2026-09-30 11:06:22 +08:00
|
|
|
|
|
2026-09-30 13:24:26 +08:00
|
|
|
|
st = { input: input, ratio: ratio,
|
|
|
|
|
|
baseW: baseW, baseH: baseH, zoom: 1,
|
|
|
|
|
|
dispW: baseW, dispH: baseH,
|
2026-09-30 11:06:22 +08:00
|
|
|
|
naturalW: probe.naturalWidth, naturalH: probe.naturalHeight,
|
2026-09-30 13:24:26 +08:00
|
|
|
|
previewSrc: null, outW: parseInt(input.getAttribute('data-crop-w'), 10) || baseW,
|
|
|
|
|
|
outH: parseInt(input.getAttribute('data-crop-h'), 10) || baseH };
|
2026-09-30 11:06:22 +08:00
|
|
|
|
|
|
|
|
|
|
imgEl.src = url;
|
2026-09-30 13:24:26 +08:00
|
|
|
|
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×'; }
|
|
|
|
|
|
|
2026-09-30 11:06:22 +08:00
|
|
|
|
var shape = overlay.querySelector('.crop-shape');
|
|
|
|
|
|
shape.textContent = input.getAttribute('data-crop-label') || '';
|
|
|
|
|
|
overlay.querySelector('.crop-tip').textContent =
|
|
|
|
|
|
input.getAttribute('data-crop-hint') ||
|
2026-09-30 13:24:26 +08:00
|
|
|
|
'拖动方框挪位置,拖右下角改大小;放不下就用「缩放」放大再看细节。框里就是最终会保存的样子。';
|
2026-09-30 11:06:22 +08:00
|
|
|
|
|
|
|
|
|
|
// 默认框 = 图里最大的、符合目标比例的那块(居中)—— 什么都不动直接确定,
|
|
|
|
|
|
// 效果就等于老的「居中裁切」,所以默认值是安全的。
|
2026-09-30 13:24:26 +08:00
|
|
|
|
var w = baseW, h = w / ratio;
|
|
|
|
|
|
if (h > baseH) { h = baseH; w = h * ratio; }
|
|
|
|
|
|
place((baseW - w) / 2, (baseH - h) / 2, w, h);
|
2026-09-30 11:06:22 +08:00
|
|
|
|
|
|
|
|
|
|
// 记住预览图原来的 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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
|
|
// 头像选好后即时预览(仅用于**没走裁剪器**的文件框,避免和裁剪预览打架)
|
2026-09-30 00:49:53 +08:00
|
|
|
|
var fileInput = document.getElementById('avatar');
|
2026-09-30 11:06:22 +08:00
|
|
|
|
if (fileInput && !fileInput.hasAttribute('data-crop-ratio') && window.FileReader) {
|
2026-09-30 00:49:53 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 11:06:22 +08:00
|
|
|
|
// 下拉菜单:点外面 / 按 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; }
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-30 00:49:53 +08:00
|
|
|
|
// 头像加载失败时换回内置剪影 —— 保证任何时候都不会出现破图
|
|
|
|
|
|
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';
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-09-30 09:44:55 +08:00
|
|
|
|
|
|
|
|
|
|
// ===== 三个生日:选择"以哪个为准" =====
|
|
|
|
|
|
// 阳历与农历其实是同一个人的同一天,不能各填各的(那样就自相矛盾了)。
|
|
|
|
|
|
// 表单上让用户明确指定"我这次填的是哪一个",服务端据此反推另一个。
|
|
|
|
|
|
(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();
|
|
|
|
|
|
})();
|
2026-09-30 13:24:26 +08:00
|
|
|
|
|
|
|
|
|
|
// ===== 名册:拖动卡片改顺序 / 拖到别的一栏改分组 =====
|
|
|
|
|
|
//
|
|
|
|
|
|
// 落库的是**整版布局**(每栏的有序 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();
|
|
|
|
|
|
});
|
|
|
|
|
|
})();
|
2026-09-30 00:49:53 +08:00
|
|
|
|
})();
|