v24 第 11 批:7 大条 20 子项(首页/名册/观影/裁剪/日程/财务)
用户 2026-10-04 第 11 批,7 大条: - #1 首页:未来七天过滤掉「农历」(每日一条会淹掉真日程);右侧日历本标签 从灰字(.dash-tag)改成**实心色块**(底色=日历色、白字、text-shadow 兜底浅色)。 - #2 名册:分组改**居中模态框**(用户要"像选观影人一样")。修掉"点第二次失效" ——根因是旧实现用**捕获阶段**监听 document click 做"点外面就关",与触发按钮 的 click 打架;模态把关闭收敛到遮罩/✕/取消。卡片「编辑」后加「删除」 (→ people/destroy);详情页底部删除改成 btn-danger + btn-block-lg 大红按钮 + 两步确认。 - #3 名册详情页重排:.detail(260px+1fr) → .pcard(头像左上 + 右侧姓名/标签) + .detail-stack 全宽往下。旧布局头像卡比右栏矮,头像下留一大片空白。 - #4 观影:影院下拉换**自定义组件**(原生 <option> 画不了色块)——按钮+列表、 每行色点,值仍写隐藏 input[name=cinema_id];「管理影院」→「新建影院」弹框 (带 back=movies/new,存完回本页并 ?cinema=<id> 自动选中)。 修「开始/结束时间标签跑到控件下面」:upgradeRange 原来 insertBefore(host, group.firstChild) 把控件插到了 label 之上 —— 改成插在第一个原生控件之前。 - #5 裁剪改 **Cropper.js**(assets/vendor/cropper/,按需加载走 fb-cropper meta): 弹框 overflow:hidden + 固定画布(**无滚动条**)、**去掉缩放条**改滚轮 wheelZoomRatio、 viewMode:1 + autoCropArea 保证框小于画布、8 个触点全露。 结果仍写 *_crop = 归一化 x,y,w,h(提交契约不变)。 - #6 日程:内置节假日原来每天只标**一天**(国庆只出一条)→ FIXED 改成 [名字, 放假天数] 按区间铺;月视图从周日改**周一**起(表头同步); 跳转日期从"长条+原生框"改成**单个 btn-icon**,点开弹 Semi 日期选择器。 - #7 财务:单条曲线无盘点时只回一个点(图看着像全 0)→ 改成"从创建日(0)画到今天"; 图下条目从"每账目一张小图卡"改成**一张 chart_multi 多线图 + 颜色图例** (Ledger::LEGEND_COLORS 按 id 取模,保证颜色稳定);账目页卡顿同源治理 (行上只留按钮、表单全页一份 #ledPanels);修改按钮改弹框并回填归属人。 ## 顺带修的真 bug(本批最要紧的三条) 1. **观影表单提交静默失效**:「新建影院」弹框被误嵌在观影主表单 <form> 内。 HTML 不允许 form 嵌套 → 浏览器拆开内层,弹框里 required 的"影院名称"变成 主表单必填项、又因 hidden 无法聚焦 → 点「记下这场电影」什么都不发生 (Chrome: invalid form control with name='name' is not focusable)。 → 弹框移到主表单之后。部署脚本加防线:按顺序配平 form 开闭判断是否嵌套。 2. **裁剪取消没清空文件框**(换 Cropper 时丢)→ close(cancelled),4 个取消入口 传 true 清空;否则"点了取消"仍会把没裁的原图传上去。 3. **裁剪预览退化成原图**(同批丢)→ 用 canvas 按 Cropper 的原图像素矩形重画, toDataURL 写回预览,恢复"所见即所得"。 ## 连带修复:选人组件懒渲染(线上实测的必要性) 线上真实数据下账目页 6939 KB / 16788 个 input、观影表单 1194 个 input —— 根因是 _person_picker.php 首屏就把**全部候选人**渲成 <label>+<input>。 改成:首屏只留「不选」+ data-pp-people(JSON),**打开弹框时**才 buildList(); 无 JS 兜底走 <noscript>(HTML5 规定启用脚本时其内容按纯文本解析,不产生节点)。 新增 window.ppSetSelected / decorateItems(动态项要补算首字母)。 实测账目页 6939 KB → 544 KB。新增 _fb_picker_lazy_check.py(11/0 + 反向 2 红)。 验证:e2e 786/0、交互 90/0、半集成 27/0、batch_e 91/0、batch10 46/0、batch9 38/0、 crop 17/0(+反向)、picker_unify 28/0、picker_lazy 11/0(+反向)、formbloat 9/0、 upgrade 126/0、mobile 688/0、drag 5/0、geom 5/5。 线上自检 19/19;线上真实账号验收 7/0(含"主表单无隐藏必填""线上 Cropper 接管+取消清空")。
This commit is contained in:
+232
-259
@@ -26,308 +26,278 @@
|
||||
for (var i = 0; i < pickers.length; i++) { hook(pickers[i]); }
|
||||
})();
|
||||
|
||||
// ===== 图片裁剪 =====
|
||||
// 比例由**这个位置显示成什么形状**决定(头像 1:1、题图 16:9),
|
||||
// 由服务端通过 data-crop-ratio 告诉这里 —— 前端不再自己定一个数字。
|
||||
// ===== 图片裁剪(v24 第 11 批 #5:改用 Cropper.js)=====
|
||||
//
|
||||
// 缩放(用户明确要求「裁剪图片的时候,需要可以进行缩放」):
|
||||
// 舞台是个**固定大小的视口**(baseW×baseH),图片按 zoom 放大到 baseW*zoom,
|
||||
// 放不下就由舞台自己滚动。裁剪框的坐标一律相对**显示出来的图片**算,
|
||||
// 最后除以 dispW/dispH 换成 0..1 —— 所以放大多少倍都不影响落库的那个矩形。
|
||||
// 框的最小边长固定 32 显示像素,于是放大之后能选中原图上更小的区域,
|
||||
// 这正是"缩放"要解决的问题(合影里挑出某一个人的脸)。
|
||||
// 用户原话:「所有上传图片带有裁剪功能的地方,都需要重做,现在仍然不好用。
|
||||
// 用 Cropper.js 这个库,弹框不要有任何滚动条,图片就无论多大,
|
||||
// 就缩放到这个框内,也不要缩放条,鼠标滚轮滚动后,缩放大小,
|
||||
// 裁剪框,要比外面的弹框小,让所有的裁剪触点都露出来。」
|
||||
//
|
||||
// ⚠️ 这里只是「让用户挑区域」,真正裁切在服务端做(见 app/Upload.php):
|
||||
// 前端算出来的框只是**建议**,绕过前端也不会存进一张比例不对的图。
|
||||
// 所以确认时写回的是**归一化矩形**(相对原图的 0..1),不是像素。
|
||||
// 逐条落地:
|
||||
// · **弹框不许有滚动条** → 面板 overflow:hidden;图片用 `img { max-width:100% }`
|
||||
// 交给 Cropper 的 viewMode:1 自动缩放进容器,而不是让容器去滚动。
|
||||
// · **不要缩放条** → 去掉原来那条 <input type="range">,改滚轮:
|
||||
// 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 overlay = null, stage = null, imgEl = null, boxEl = null;
|
||||
var st = null; // 当前编辑状态
|
||||
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。
|
||||
* 解析 data-crop-ratio;拿不到就退回 1(正方形)。
|
||||
*
|
||||
* ⚠️ 不能直接 parseFloat:'16:9' 会被读成 **16**(parseFloat 只吃开头那段数字),
|
||||
* 于是裁剪框变成 16:1 的细长条,而钩子本身不报错、只是裁出来的图没法看。
|
||||
* 头像的 '1:1' 恰好蒙对(=1),所以这个坑会潜伏到"启用题图"那天才炸。
|
||||
* 两种写法都支持:'16:9' / '16/9' 按分数算,纯数字按它本身算。
|
||||
* ⚠️ 必须支持 **"16:9" 这种带冒号的写法**:`parseFloat('16:9')` 会得到 **16**
|
||||
* (它遇到 ':' 就停),于是"题图"槽位会裁出一个 16:1 的细长条 ——
|
||||
* 页面不报错、图也存下去了,只有肉眼看才发现不对。
|
||||
* (旧自研版正因为同一处栽过,交互测试里专门留了一条断言守它。)
|
||||
*/
|
||||
function parseRatioAttr(v) {
|
||||
var s = String(v == null ? '' : v).trim();
|
||||
var m = /^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)$/.exec(s);
|
||||
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]);
|
||||
return (isFinite(a) && isFinite(b) && b > 0 && a > 0) ? a / b : 1;
|
||||
if (isFinite(a) && isFinite(b) && a > 0 && b > 0) { return a / b; }
|
||||
}
|
||||
var f = parseFloat(s);
|
||||
return (isFinite(f) && f > 0) ? f : 1;
|
||||
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 =
|
||||
'<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-head"><b>裁剪图片</b><span class="crop-shape"></span>' +
|
||||
'<button type="button" class="crop-x" aria-label="关闭">✕</button></div>' +
|
||||
// ⚠️ 这里**不再有** stage / 缩放条 / 自画框:全部交给 Cropper.js。
|
||||
'<div class="crop-canvas"><img class="crop-img" alt=""></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>' +
|
||||
'<button type="button" class="btn btn-ghost crop-cancel">取消</button>' +
|
||||
'<button type="button" class="btn crop-ok">就用这块</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
document.body.appendChild(overlay);
|
||||
stage = overlay.querySelector('.crop-stage');
|
||||
imgEl = overlay.querySelector('.crop-img');
|
||||
boxEl = overlay.querySelector('.crop-box');
|
||||
panel = overlay.querySelector('.crop-panel');
|
||||
imgEl = overlay.querySelector('.crop-img');
|
||||
hintEl = overlay.querySelector('.crop-tip');
|
||||
|
||||
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); }
|
||||
});
|
||||
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 (overlay && overlay.parentNode && e.key === 'Escape') { close(false); }
|
||||
if (e.key === 'Escape' && overlay.classList.contains('is-open')) { close(true); }
|
||||
});
|
||||
|
||||
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';
|
||||
overlay.querySelector('.crop-ok').addEventListener('click', confirm);
|
||||
}
|
||||
|
||||
/**
|
||||
* 改缩放。
|
||||
* 关闭浮层。
|
||||
*
|
||||
* 关键:**框在屏幕上的位置和大小都不动**,动的是它底下那张图 ——
|
||||
* 放大=同一块区域看得更清楚;缩小=框进更多内容。
|
||||
* (2026-09-30 用户反馈:之前的做法是"归一化区域不变",结果框跟着图一起变大,
|
||||
* 缩放几下框就大得找不到了。)
|
||||
* 落库的归一化矩形由 place() 夹取后自动重算 —— 缩放改变选中的区域,这是本意。
|
||||
* @param {boolean} cancelled true = 用户**取消**(点 ✕ / 取消 / 遮罩 / Esc)。
|
||||
*
|
||||
* ⚠️ 取消时必须把 file input 清空:否则用户以为"没选图",实际上表单里
|
||||
* 仍挂着那张**没裁过的原图**,一提交就传上去了(旧自研版有这一步,
|
||||
* 换成 Cropper.js 时被我漏掉,靠交互测试的"取消后文件框被清空"抓回来)。
|
||||
* 确认(confirm)走的是另一条路 —— 那时文件正是用户要传的,不能清。
|
||||
*/
|
||||
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));
|
||||
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;
|
||||
}
|
||||
|
||||
imgEl.style.width = st.dispW + 'px';
|
||||
imgEl.style.height = st.dispH + 'px';
|
||||
// 舞台是**固定大小的视口**(不跟着图片变大),放不下就滚动
|
||||
stage.style.width = st.baseW + 'px';
|
||||
stage.style.height = st.baseH + 'px';
|
||||
/**
|
||||
* 对外暴露当前「图片显示尺寸 + 裁剪框」,**只给测试用**。
|
||||
*
|
||||
* ⚠️ 存在的理由:验证"滚轮真的缩放了"不能只看"有没有 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};
|
||||
};
|
||||
|
||||
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 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 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));
|
||||
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
|
||||
: '拖动图片挪位置,滚轮缩放;虚线框里就是最终保存的样子。';
|
||||
|
||||
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 };
|
||||
if (objUrl) { URL.revokeObjectURL(objUrl); }
|
||||
objUrl = URL.createObjectURL(file);
|
||||
imgEl.src = objUrl;
|
||||
overlay.classList.add('is-open');
|
||||
|
||||
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);
|
||||
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 = ''; }
|
||||
}
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
})();
|
||||
|
||||
// 头像选好后即时预览(仅用于**没走裁剪器**的文件框,避免和裁剪预览打架)
|
||||
@@ -966,8 +936,11 @@
|
||||
|
||||
var host = document.createElement('span');
|
||||
host.className = 'dt-semi dt-semi-range';
|
||||
// 插到这一对控件的**前面**(原生那对随后藏起来)
|
||||
group.insertBefore(host, group.firstChild);
|
||||
// ⚠️ 插到**第一个原生控件之前**,不能插到 group 的最前面。
|
||||
// 因为 group 就是那个 `.field`,`<label>` 也是它的子节点:
|
||||
// 用 group.firstChild 会把控件塞到 label **上面**(第一版就是这么写的),
|
||||
// 用户看到的现象正是"开始/结束时间这几个字跑到日期控件下面去了"。
|
||||
group.insertBefore(host, a);
|
||||
|
||||
var fmt = 'yyyy-MM-dd HH:mm' + (secs ? ':ss' : '');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user