用户原话(三段):
①「选择日期那个地方给,非常不好用。如果我不选日期,只选时间,框里面仍然是空白的,
也没有确定按钮,这个需要重新调整一下。」
②「现在上传附件,也就是在观影模块里面上传附件,怎么还是有问题?上传的附件现在
都没有保存,有时候我上传两个附件,它只保存一张。」
③「除了海报之外,票根、现场照片等等都不分类别,都只分一个类,放在一块。…
只有海报是单独的,因为它是从豆瓣里面下载回来的。其他的都放在一个模块就行,
然后所有照片都上传到一起,直接写"观影照片"就可以了。」
① 日期控件:**Semi 的时间面板根本不可用**(这条最费时间)
实测(独立最小复现页 + 真机):Semi 的 dateTimeRange 面板**只给日期网格**,
时间藏在 `.semi-datepicker-switch-time` 那个小时钟按钮后面;**点了它切到时间面板,
面板仍然是空的**(timeLists=0、无选项、无 footer、无"确定")→ 根本无法"只选时间"。
单点 dateTime 同样。⇒ 与用户描述完全一致。
修:弃用 Semi 的时间选择,改用**原生** date + time(step=1)
(24 小时制、精确到秒、点了就选、不选日期也有值、**不需要**确认按钮)。
原来的 时/分/秒 三个下拉**仍留在 DOM**(POST 契约 *_date/_hour/_minute/_second
一字不改),只是加 .dt-merged 视觉收起。
app.js 的 upgradeRange()/upgrade() 都不再挂 Semi;CSS 加 .is-native-range 系列
把 Semi 时代的"藏原生 / 两端让位"规则覆盖回来。
② 观影附件上传丢文件:**两条路径都断了**
· 暂存路径(拖放):上传框走"拖进来即刻上传到暂存区",令牌进 stage_token[];
而 movies/create|update 与 movies/photo_add **只读 $_FILES['photo']**
⇒ stage_token[] **从来没人认领** ⇒ 拖进去的一个都没存。
· Movie::update() **完全不写 photos**(那句"photos 由单独的删除路由维护"的注释下
根本没写),而路由明明算好了 $d['photos'] 传进来 ⇒ 编辑保存丢照片。
修:路由新增 stage_token[] 认领循环(与 $_FILES 路径并存);photo_add 加兜底;
Movie::update 补上 savePhotos。
③ 归并:表单去掉独立"票根"上传框,"现场合影"并入**一个**「观影照片」块;
详情页「影像记录」→「观影照片」、去掉票根/照片分类标签。
数据层本来就是平的(event_photos 只有 key/position);
⚠️ 旧数据 movie_details.ticket_key **仍要显示**(历史票根不能丢)+ 服务端仍接收
$_FILES['ticket'](向后兼容,直接 POST 仍可存)。
验证(全部真机跑,不是"看代码觉得对"):
· _fb_b34_dtrange.py 33/0(四个表单页:组可见 + 两端日期框可见 + 两端时间框
可见且 step=1 + 三个原生下拉仍在 DOM + 只选时间也能写回 *_hour/_minute/_second);
· _fb_b35_movie_media.py 14/0(点击上传 2 张→库里 2 行;真 HTTP 打 upload_stage.php
拿 stoken→随表单提交→落库;编辑补传→3 行;详情页补传→4 行;归并断言);
· 反向验证:_fb_b34_dtrange_falsify 4/0、_fb_b35_media_falsify 8/0;
· e2e 1503/0(两条断言随 v35 语义更新)、几何交互 92/0、各批真机/反向全绿、
部署脚本静态断言空跑通过;线上 app.js / form.php 的 SHA256 与本地一致。
顺带修掉的工具链脆弱(本轮暴露):
· push.sh 开头加"确保本地测试库在跑"的守卫(长时间跑流水线时 mysqld 会被环境
回收 → e2e/真机探针全崩,看着像代码坏了);
· 探针别用固定字符窗口找函数体(注释一长就假红)、断言别写死字段名前缀
(改期表单是 rev_ends_at_*)、set_input_files 的 id 要照 _stage_drop 的净化规则
(photo[] → sd-photo)、提交按钮要精确定位(编辑页有多个 submit)。
1777 lines
87 KiB
JavaScript
1777 lines
87 KiB
JavaScript
/* 家书 · 前端小助手(无框架,无构建步骤) */
|
||
(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]); }
|
||
})();
|
||
|
||
// ===== 图片裁剪(v24 第 11 批 #5:改用 Cropper.js)=====
|
||
//
|
||
// 用户原话:「所有上传图片带有裁剪功能的地方,都需要重做,现在仍然不好用。
|
||
// 用 Cropper.js 这个库,弹框不要有任何滚动条,图片就无论多大,
|
||
// 就缩放到这个框内,也不要缩放条,鼠标滚轮滚动后,缩放大小,
|
||
// 裁剪框,要比外面的弹框小,让所有的裁剪触点都露出来。」
|
||
//
|
||
// 逐条落地:
|
||
// · **弹框不许有滚动条** → 面板 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 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;拿不到就退回 1(正方形)。
|
||
*
|
||
* ⚠️ 必须支持 **"16:9" 这种带冒号的写法**:`parseFloat('16:9')` 会得到 **16**
|
||
* (它遇到 ':' 就停),于是"题图"槽位会裁出一个 16:1 的细长条 ——
|
||
* 页面不报错、图也存下去了,只有肉眼看才发现不对。
|
||
* (旧自研版正因为同一处栽过,交互测试里专门留了一条断言守它。)
|
||
*/
|
||
function parseRatioAttr(v) {
|
||
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]);
|
||
if (isFinite(a) && isFinite(b) && a > 0 && b > 0) { return a / b; }
|
||
}
|
||
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>' +
|
||
'<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 crop-cancel">取消</button>' +
|
||
'<button type="button" class="btn crop-ok">就用这块</button>' +
|
||
'</div>' +
|
||
'</div>';
|
||
document.body.appendChild(overlay);
|
||
panel = overlay.querySelector('.crop-panel');
|
||
imgEl = overlay.querySelector('.crop-img');
|
||
hintEl = overlay.querySelector('.crop-tip');
|
||
|
||
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 (e.key === 'Escape' && overlay.classList.contains('is-open')) { close(true); }
|
||
});
|
||
overlay.querySelector('.crop-ok').addEventListener('click', confirm);
|
||
}
|
||
|
||
/**
|
||
* 关闭浮层。
|
||
*
|
||
* @param {boolean} cancelled true = 用户**取消**(点 ✕ / 取消 / 遮罩 / Esc)。
|
||
*
|
||
* ⚠️ 取消时必须把 file input 清空:否则用户以为"没选图",实际上表单里
|
||
* 仍挂着那张**没裁过的原图**,一提交就传上去了(旧自研版有这一步,
|
||
* 换成 Cropper.js 时被我漏掉,靠交互测试的"取消后文件框被清空"抓回来)。
|
||
* 确认(confirm)走的是另一条路 —— 那时文件正是用户要传的,不能清。
|
||
*/
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 对外暴露当前「图片显示尺寸 + 裁剪框」,**只给测试用**。
|
||
*
|
||
* ⚠️ 存在的理由:验证"滚轮真的缩放了"不能只看"有没有 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};
|
||
};
|
||
|
||
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 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
|
||
: '拖动图片挪位置,滚轮缩放;虚线框里就是最终保存的样子。';
|
||
|
||
if (objUrl) { URL.revokeObjectURL(objUrl); }
|
||
objUrl = URL.createObjectURL(file);
|
||
imgEl.src = objUrl;
|
||
overlay.classList.add('is-open');
|
||
|
||
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 = ''; }
|
||
});
|
||
}
|
||
|
||
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]);
|
||
}
|
||
})();
|
||
|
||
/**
|
||
* 通用「点预览图 = 打开文件选择框」。
|
||
*
|
||
* ⚠️ 为什么必须做成通用的(2026-10-05 第 15 批 用户第 3 条实测踩到):
|
||
* 原来这句 `prev.addEventListener('click', () => file.click())` 只写在
|
||
* **templates/people/form.php 的内页脚本**里(那是"头像"专用的)。
|
||
* 后来加的「旅程题图」「图书封面」照抄了同一套 DOM(.cover-picker +
|
||
* #cover-preview + 隐藏的 file input),却**没有对应的 JS** ——
|
||
* 于是图块看着能点(CSS 给了 cursor:pointer 与 hover 高亮),
|
||
* 点下去什么都不会发生,用户以为功能坏了(原话:"点击哪里都没反应")。
|
||
*
|
||
* 做法:谁想"点图选文件",就在图上写 `data-pick-input="#文件框id"`,
|
||
* 这里统一接管(一个页面有多个也各绑各的)。声明式,模板里不再各写一段 JS。
|
||
*/
|
||
Array.prototype.forEach.call(
|
||
document.querySelectorAll('[data-pick-input]'),
|
||
function (pv) {
|
||
var sel = pv.getAttribute('data-pick-input');
|
||
var file = sel ? document.querySelector(sel) : null;
|
||
if (!file) { return; }
|
||
// ⚠️ 用 <label for> 包住图片也能触发文件框,但那样 hover/光标语义会变、
|
||
// 且部分浏览器在 label 里点 <img> 不会激活控件(要额外 CSS 才能稳)。
|
||
// 直接 click() 最省事,也与原来头像那一套保持一致。
|
||
pv.addEventListener('click', function () { file.click(); });
|
||
pv.style.cursor = 'pointer';
|
||
}
|
||
);
|
||
|
||
/**
|
||
* 拖动上传(v24 第 19 批 用户第 1 条)。
|
||
*
|
||
* 用户原话:「为所有的图片视频附件上传处,提供拖动上传的功能,就是允许用户,
|
||
* 通过把文件拖动到那里进行上传。」
|
||
*
|
||
* 做法:**给页面上每一个 `<input type="file">` 自动挂上**拖动接收 —— 不改模板,
|
||
* 于是全站的附件上传点(旅程照片、观影海报/票根/现场照片、资源、任务、日程附件、
|
||
* 图书封面与文件、名册头像与通讯录导入…)一次性都有了拖动能力。
|
||
* 新出现的文件框也接得住:MutationObserver 补挂(时间控件那套用过同一招)。
|
||
*
|
||
* 🔴 两个必须踩准的点:
|
||
* ① **必须真的把 drop 的文件写进 input.files**,不能只"显示个名字"。
|
||
* 写回去要用 DataTransfer:`dt.items.add(file)` 再 `input.files = dt.files`。
|
||
* 不这么做的话,用户以为拖上去了,一提交却发现什么都没上传。
|
||
* ② 写回之后要**手动派发一次 change** —— 页面上挂的预览 / 裁剪器都监听 change,
|
||
* 不派发的话拖进来的图不会弹裁剪框、也不会更新预览("拖了没反应")。
|
||
* 注意:`input.files = …` 本身**不会**触发 change(不是用户交互),必须手动补。
|
||
*
|
||
* ⚠️ 只接拖动落点,不改 accept 语义:accept 是给选择框用的提示,浏览器**不**拿它
|
||
* 过滤 drop(拖什么都进得来)。服务端本来就会再判一次 MIME,这里不重复造规则。
|
||
* ⚠️ dragover 里必须 preventDefault,否则浏览器不认这里是"可放下"的(默认行为是拒收)。
|
||
* ⚠️ 用计数器算 dragenter/dragleave:拖过子元素时也会触发 leave,
|
||
* 只按"进了/出了"切换高亮会让高亮疯狂闪。
|
||
*/
|
||
(function () {
|
||
/**
|
||
* 给一个"落点元素"挂拖动接收。
|
||
*
|
||
* @param {Element} zone 用户实际往上拖东西的那个元素(可见的那块)
|
||
* @param {Element} input 目标 <input type="file">
|
||
*
|
||
* ⚠️ 为什么落点与 input 要分开:题图/头像那类上传,`<input>` 是 **sr-only**
|
||
* (看不见),用户能看见、能点的是旁边那张预览图。只把拖动挂在 input 上,
|
||
* 用户往图上拖是**没有任何反应**的 —— 而"拖到那块图上"才是最自然的动作。
|
||
* 所以:有 `data-pick-input` 指向这个 input 的图,就把那图也当落点。
|
||
*/
|
||
function bindZone(zone, input) {
|
||
if (!zone || !input || zone.getAttribute('data-drop-bound') === '1') { return; }
|
||
zone.setAttribute('data-drop-bound', '1');
|
||
zone.className += (zone.className ? ' ' : '') + 'dropzone';
|
||
|
||
var depth = 0; // dragenter/dragleave 计数器(见上面的说明)
|
||
|
||
function over(state) {
|
||
if (state) { zone.classList.add('is-over'); } else { zone.classList.remove('is-over'); }
|
||
}
|
||
|
||
zone.addEventListener('dragenter', function (e) {
|
||
if (!hasFiles(e)) { return; }
|
||
e.preventDefault();
|
||
depth++;
|
||
over(true);
|
||
});
|
||
zone.addEventListener('dragover', function (e) {
|
||
if (!hasFiles(e)) { return; }
|
||
// 🔴 少了这句浏览器就不允许放下(默认是拒收),后头的 drop 根本不会来
|
||
e.preventDefault();
|
||
if (e.dataTransfer) { e.dataTransfer.dropEffect = 'copy'; }
|
||
over(true);
|
||
});
|
||
zone.addEventListener('dragleave', function (e) {
|
||
if (!hasFiles(e)) { return; }
|
||
depth--;
|
||
if (depth <= 0) { depth = 0; over(false); }
|
||
});
|
||
zone.addEventListener('drop', function (e) {
|
||
if (!hasFiles(e)) { return; }
|
||
e.preventDefault();
|
||
depth = 0;
|
||
over(false);
|
||
var files = Array.prototype.slice.call(e.dataTransfer.files || []);
|
||
if (!files.length) { return; }
|
||
// ⚠️ 目标是**暂存式**拖放区(第 20 批 第 4 条)→ 文件直接**即刻上传**,
|
||
// 一个接一个排队;用户可以接着再拖,最后一次"保存"统一认领。
|
||
// 普通文件框(没有 data-drop-stage)仍走第 19 批那套"写回 input.files"。
|
||
// 🔴 找落点必须**就近**:一个表单里可能有好几个(电影的 海报/票根/合影 三块),
|
||
// `form.querySelector('[data-drop-stage]')` 只会拿到**第一个** ——
|
||
// 那样往"票根"里拖会跑到"海报"上(用户看到的是"拖到这儿,结果上到那儿")。
|
||
// 所以先找**同一个 .field 里**的那个,找不到才退回表单里的第一个。
|
||
var stageBox = null;
|
||
var ownField = input.closest ? input.closest('.field') : '';
|
||
if (ownField) { stageBox = ownField.querySelector('[data-drop-stage]'); }
|
||
// v24 第 21 批 第 7 条:新结构里文件框是 **sr-only、且与框互为兄弟**
|
||
// (不再包在 .field 里 —— 片段自己画框,调用方只 require 一行)。
|
||
// 所以再认一次"显式指向":框上写了 data-sd-for="<input的id>"。
|
||
if (!stageBox && input.id) {
|
||
stageBox = document.querySelector('[data-drop-stage][data-sd-for="' + input.id + '"]');
|
||
}
|
||
// 最后才退回"表单里第一个"(老结构;多上传区的表单**必须**能就近命中,
|
||
// 否则往「票根」拖会跑到「海报」上 —— 第 20 批踩过,注释见上)
|
||
if (!stageBox && input.form) { stageBox = input.form.querySelector('[data-drop-stage]'); }
|
||
if (stageBox) {
|
||
queueDrop(stageBox, input, files);
|
||
return;
|
||
}
|
||
// 单选的只取第一个(多选也塞多个会与 accept 语义打架:
|
||
// 题图/头像那种单选框塞两个,浏览器提交的仍是第一个,但用户会以为两个都传了)
|
||
var multi = input.hasAttribute('multiple');
|
||
var put = multi ? files : files.slice(0, 1);
|
||
try {
|
||
var dt = new DataTransfer();
|
||
put.forEach(function (f) { dt.items.add(f); });
|
||
input.files = dt.files;
|
||
} catch (err) {
|
||
// 老浏览器不支持给 input.files 赋值 → 至少把光标引到框上,不让用户白拖
|
||
if (window.console) { console.warn('拖动上传:这个浏览器不支持把文件写进控件', err); }
|
||
input.focus();
|
||
return;
|
||
}
|
||
// 🔴 手动派发 change:预览与裁剪器都听它(input.files 赋值不会自己触发)
|
||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||
});
|
||
}
|
||
|
||
/* ============================================================
|
||
暂存式"拖一个就上传"(v24 第 20 批 用户第 4 条)
|
||
------------------------------------------------------------
|
||
用户原话:「所有上传图片视频和附件的模块,都允许多次拖动添加文件上传,
|
||
拖动一个进去,就开始上传,还可以继续拖动,继续上传,
|
||
最后统一保存即可。」
|
||
|
||
做法(三条链路,缺一不可):
|
||
① 拖进来 → **立刻** POST 到 upload_stage.php(`?r=upload/stage` 由路由转过去),
|
||
服务端落进暂存目录并回一个 `stoken`;
|
||
② 把 stoken 塞进隐藏字段(名字由落点给,见 `data-stage-field`)——
|
||
表单提交时这些令牌就跟着走了,业务路由在 attach_save_files 里**认领**;
|
||
③ 立刻画一张缩略图(指向 `media.php?stage=<stoken>`),
|
||
让用户看得见"这一张已经上去了"。
|
||
|
||
⚠️ 三个必须踩准的点(都是"看着能跑、实际会丢"的坑):
|
||
· **队列要串行**:用户一次拖 5 个/连着拖几次时,并发 POST 会让"完成顺序"
|
||
与"显示顺序"不一致,用户看到的缩略图顺序跳来跳去。这里用一个 FIFO 队列,
|
||
一张传完再传下一张(上传是本地网络,串行也够快)。
|
||
· **失败必须显示出来**:不能静默丢("我拖了三张,怎么只上去一张")。
|
||
失败的那张在缩略图上标红并写清原因。
|
||
· **保存前要拦住还在上传的**:用户拖完立刻点"保存"时,最后一张可能还在传。
|
||
提交时若队列没空,`preventDefault` 并提示等一会儿 —— 否则
|
||
"拖了但没保存上"是必然发生的(概率还不低)。
|
||
============================================================ */
|
||
function queueDrop(box, input, files) {
|
||
var form = input.form;
|
||
if (!form) { return; }
|
||
// ⚠️ v24 第 21 批 第 7 条:清单与提示行现在在框**外面**(同一个 `.upfield` 容器里)——
|
||
// 清单放框内的话,加了五张图那个虚线框就变成"大框套着五张图"。
|
||
// 所以先在容器里找,找不到才退回框自身(`.upfield` 不在时至少别静默失效:
|
||
// 没有清单 = 上传成功却什么都不显示,用户会以为没上去)。
|
||
var host = (box.closest && box.closest('.upfield')) || box.parentNode || box;
|
||
var list = host.querySelector('[data-stage-list]') || box.querySelector('[data-stage-list]');
|
||
var tip = host.querySelector('[data-stage-tip]') || box.querySelector('[data-stage-tip]');
|
||
// 队列状态挂在 box 上:**跨多次拖放**共享(用户拖一次、再拖一次,仍要串行)
|
||
var st = box.__stage || (box.__stage = { pending: 0, chain: Promise.resolve() });
|
||
|
||
// 提交守卫:还有没传完的就别让它提交(见上面第 ③ 点)。
|
||
// ⚠️ 只挂一次(用 __stageGuard 记着)—— 反复拖会让 form 上叠出十几个监听。
|
||
if (!st.guard) {
|
||
st.guard = true;
|
||
form.addEventListener('submit', function (ev) {
|
||
if (st.pending > 0) {
|
||
ev.preventDefault();
|
||
if (tip) { tip.textContent = '还有 ' + st.pending + ' 个文件在上传,稍等一下再保存。'; }
|
||
}
|
||
});
|
||
}
|
||
|
||
files.forEach(function (f) {
|
||
// 每张一个卡片:立刻出现"上传中",成功换成缩略图,失败标红
|
||
var cell = document.createElement('div');
|
||
cell.className = 'stage-cell is-uploading';
|
||
var nm = document.createElement('span');
|
||
nm.className = 'stage-name';
|
||
nm.textContent = f.name;
|
||
var meta = document.createElement('span');
|
||
meta.className = 'stage-meta';
|
||
meta.textContent = '上传中…';
|
||
cell.appendChild(nm);
|
||
cell.appendChild(meta);
|
||
if (list) {
|
||
list.appendChild(cell);
|
||
// 🔴 必须把它**显示出来**:模板里 `.stage-list` 初始带 `hidden`
|
||
// (没人上传时不留一条空边框)。不 un-hide 的话卡片确实建出来了、
|
||
// 但整条清单是 display:none —— 用户看到的是"拖了没反应",
|
||
// 而且缩略图也点不到(真机探针实测:卡片在 DOM 里、尺寸 0×0)。
|
||
list.hidden = false;
|
||
// 提示行同理(模板也给了 hidden)—— 它要说"已上传,可以继续拖"这类话,
|
||
// 藏着等于一个字都不说。
|
||
if (tip) { tip.hidden = false; }
|
||
}
|
||
|
||
st.pending++;
|
||
// 🔴 串行:把这次上传接到链尾。**不能 Promise.all** ——
|
||
// 并发时"谁先传完"不定,缩略图的顺序会跳(见上面第 ① 点)。
|
||
st.chain = st.chain.then(function () {
|
||
var fd = new FormData();
|
||
fd.append('file', f);
|
||
fd.append('_csrf', csrfToken(form));
|
||
var slot = input.getAttribute('data-stage-slot') || '';
|
||
if (slot) { fd.append('slot', slot); }
|
||
return fetch(stageUrl, { method: 'POST', body: fd, credentials: 'same-origin' })
|
||
.then(function (r) {
|
||
// ⚠️ 服务端哪怕回 419/500,也要**先试 json**:它总归是 JSON 契约;
|
||
// 解析不出来才按"不是 JSON"报(比如整页 HTML 的错误页)。
|
||
return r.json().catch(function () {
|
||
return { ok: false, error: '服务器返回的不是 JSON(可能登录已过期)。' };
|
||
});
|
||
})
|
||
.then(function (d) {
|
||
if (d && d.ok) {
|
||
cell.className = 'stage-cell';
|
||
cell.innerHTML = '';
|
||
if (d.is_image) {
|
||
var im = document.createElement('img');
|
||
im.src = d.url;
|
||
im.alt = '';
|
||
cell.appendChild(im);
|
||
} else {
|
||
var ic = document.createElement('span');
|
||
ic.className = 'stage-ico';
|
||
ic.textContent = d.kind || '文件';
|
||
cell.appendChild(ic);
|
||
}
|
||
var n2 = document.createElement('span');
|
||
n2.className = 'stage-name';
|
||
n2.textContent = d.name || f.name;
|
||
cell.appendChild(n2);
|
||
var m2 = document.createElement('span');
|
||
m2.className = 'stage-meta';
|
||
m2.textContent = d.size_word || '';
|
||
cell.appendChild(m2);
|
||
// 令牌进隐藏字段 → 随表单提交,服务端在 attach_save_files 里认领它
|
||
var hid = document.createElement('input');
|
||
hid.type = 'hidden';
|
||
// ⚠️ 字段名由落点决定,**不能写死**:
|
||
// · 通用附件是 `stage_token[]` —— 🔴 v24 第 21 批 第 4 条:
|
||
// 它原来叫 `stage[]`,与行程表单里那个 `stage`(plan/actual)
|
||
// 同名 → PHP 把 `$_POST['stage']` 合并成**数组** →
|
||
// 路由 `(string)` 强转成 "Array" → 静默退回 'plan'
|
||
// (症状:拖过图的"实际行程"一保存就变回计划)。
|
||
// · 海报/票根是 `stage_poster` / `stage_ticket`(各自单槽位、要按比例重裁),
|
||
// 写死通用名会让海报存成 1:1、页面上变形。
|
||
// 两条都有详细说明在 templates/_stage_drop.php 的头部。
|
||
hid.name = box.getAttribute('data-stage-field') || 'stage_token[]';
|
||
hid.value = d.stoken;
|
||
cell.appendChild(hid);
|
||
// 撤回这张(令牌与卡片一起去掉;服务端的暂存由 TTL 清)
|
||
var x = document.createElement('button');
|
||
x.type = 'button';
|
||
x.className = 'stage-x';
|
||
x.textContent = '×';
|
||
x.title = '撤掉这张';
|
||
x.addEventListener('click', function () {
|
||
if (hid.parentNode) { hid.parentNode.removeChild(hid); }
|
||
if (cell.parentNode) { cell.parentNode.removeChild(cell); }
|
||
// 一张都不剩了就把清单藏回去(别留一条空边框 —— 与初始状态一致)
|
||
if (list && !list.querySelector('.stage-cell')) { list.hidden = true; }
|
||
if (tip) { tip.textContent = '已撤掉,保存时不会带上它。'; }
|
||
});
|
||
cell.appendChild(x);
|
||
if (tip) { tip.textContent = '已上传,可以继续拖;点「保存」时才真正记到这条上。'; }
|
||
} else {
|
||
// 🔴 失败必须**看得见**(写在卡片上,不弹 alert):静默丢是
|
||
// "我拖了三张怎么只上去一张"的根源。
|
||
cell.className = 'stage-cell is-failed';
|
||
meta.textContent = (d && d.error) ? d.error : '上传失败';
|
||
if (tip) { tip.textContent = '有文件没传上去,见红色那张。'; }
|
||
}
|
||
})
|
||
.catch(function (err) {
|
||
cell.className = 'stage-cell is-failed';
|
||
meta.textContent = '网络错误:' + (err && err.message ? err.message : '重试一下');
|
||
if (tip) { tip.textContent = '有文件没传上去,见红色那张。'; }
|
||
})
|
||
.then(function () { st.pending--; }); // 无论成败都要减,否则守卫会把表单永久锁住
|
||
});
|
||
});
|
||
}
|
||
|
||
/** 取当前表单里的 CSRF(上传接口要带;与 csrf_field() 同一个值) */
|
||
function csrfToken(form) {
|
||
var el = form.querySelector('input[name="_csrf"]');
|
||
return el ? el.value : '';
|
||
}
|
||
|
||
/** 暂存上传地址(从 meta 读,页面上没有就退回约定路由) */
|
||
var stageUrl = (function () {
|
||
var m = document.querySelector('meta[name="fb-stage-url"]');
|
||
return (m && m.getAttribute('content')) || 'index.php?r=upload/stage';
|
||
})();
|
||
|
||
/** 这次拖动里有没有真文件(拖选中的文字/链接也会触发 drag 事件,那些不该管) */
|
||
function hasFiles(e) {
|
||
var t = e.dataTransfer;
|
||
if (!t) { return false; }
|
||
if (t.types) {
|
||
for (var i = 0; i < t.types.length; i++) {
|
||
if (t.types[i] === 'Files') { return true; }
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 一个 input:既给它自己挂落点,也给"指向它的那张预览图 / 那个上传框"挂(见 bindZone)。
|
||
*
|
||
* ⚠️ v24 第 21 批 第 7 条:给「点击或拖动进行上传」那个框补**点击**。
|
||
* 用户原话:「点击这个框实现点击上传按钮的功能」——
|
||
* 框是 <div role=button>,它**不会**自己打开文件选择框(那是 <input>/<label> 的能力),
|
||
* 所以必须显式 `input.click()` 转发过去。
|
||
* ⚠️ 键盘同样要能操作(Tab 到框 + 回车/空格):只做鼠标的话,
|
||
* 键盘用户就完全用不了上传了。弹层/选人组件的既有做法也是这一套。
|
||
* ⚠️ 拖动由 bindZone 负责(它同时给了高亮);这里只管"点"。
|
||
*/
|
||
function bindBox(box, input) {
|
||
if (!box || box.getAttribute('data-sd-click') === '1') { return; }
|
||
box.setAttribute('data-sd-click', '1');
|
||
box.addEventListener('click', function (ev) {
|
||
// ⚠️ 点暂存清单里的「撤掉这张」时不要又弹一次文件选择框
|
||
// (那个 × 按钮在框**外面**,但保险起见还是挡一道 —— 结构将来可能挪)。
|
||
if (ev.target && ev.target.closest && ev.target.closest('button')) { return; }
|
||
input.click();
|
||
});
|
||
box.addEventListener('keydown', function (ev) {
|
||
if (ev.key === 'Enter' || ev.key === ' ' || ev.key === 'Spacebar') {
|
||
ev.preventDefault();
|
||
input.click();
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 「选中文件后要有反应」—— v30 用户第 1 条。
|
||
*
|
||
* 用户原话:「导入日程,选中文件后,那个地方没有任何反应,虽然能导入,
|
||
* 但是体验不好,选中文件后,那个文件输入框,得有点反应才对。」
|
||
*
|
||
* 根因:**非暂存**的上传框(导入型:ics / 通讯录)在选中文件后**什么都不显示** ——
|
||
* 暂存型框靠 queueDrop() 画缩略图卡片,而导入型只是把 File 写进 `<input>`,
|
||
* 框里那句"点击或拖动进行上传"从头到尾不变,用户完全看不出"到底选上没有"。
|
||
* ⇒ 这里给所有"指向某个 file input 的框"补一条 change 监听:
|
||
* 选到文件就把框里的话换成**文件名**(+ 可点"重选"),选不到(取消)就还原。
|
||
*
|
||
* ⚠️ 只认 `data-sd-for` 指向的那些框(片段自己声明的),不碰别的上传区 ——
|
||
* 暂存型框有自己的清单 UI,这里插一脚会和它打架。
|
||
* ⚠️ 用**原始**文案做还原基准(`data-sd-msg`):用户点了重选又取消时,
|
||
* 框里那句话必须回到原文,不能变成空字符串。
|
||
*/
|
||
function bindFileEcho(box, input) {
|
||
if (!box || !input || box.getAttribute('data-sd-echo') === '1') { return; }
|
||
// ⚠️ **只给非暂存框**挂(暂存型框由 queueDrop 画缩略图卡片,别去抢它的 UI)。
|
||
// 暂存型框带 data-drop-stage;导入型不带(见 _stage_drop.php 头部的 v30 说明)。
|
||
if (box.hasAttribute('data-drop-stage')) { return; }
|
||
box.setAttribute('data-sd-echo', '1');
|
||
// 记下原始文案(片段渲染的那句「点击或拖动进行上传」或 $sdMsg)
|
||
if (!box.hasAttribute('data-sd-msg')) {
|
||
var msgEl = box.querySelector('.upbox-msg');
|
||
box.setAttribute('data-sd-msg', msgEl ? msgEl.textContent : '');
|
||
}
|
||
var msgEl2 = box.querySelector('.upbox-msg');
|
||
var icoEl = box.querySelector('.upbox-ico');
|
||
function paint() {
|
||
var f = input.files && input.files[0];
|
||
var list = input.files ? Array.prototype.slice.call(input.files) : [];
|
||
if (!list.length) {
|
||
// 没选(或用户取消了)→ 还原成原始提示
|
||
box.classList.remove('is-picked');
|
||
if (msgEl2) { msgEl2.textContent = box.getAttribute('data-sd-msg'); }
|
||
if (icoEl) { icoEl.textContent = '+'; }
|
||
return;
|
||
}
|
||
box.classList.add('is-picked');
|
||
if (icoEl) { icoEl.textContent = '✓'; }
|
||
if (msgEl2) {
|
||
// 单选直接给文件名;多选给"第一个 + 等 N 个"
|
||
msgEl2.textContent = list.length === 1
|
||
? ('已选择:' + (f.name || '文件') + '(点这里可重选)')
|
||
: ('已选择 ' + list.length + ' 个文件:' + (f.name || '') + ' 等(点这里可重选)');
|
||
}
|
||
}
|
||
input.addEventListener('change', paint);
|
||
// 拖进来的文件也会派发 change(见 bindZone),所以这一条同时覆盖"点选"和"拖入"
|
||
paint();
|
||
}
|
||
|
||
/** 找"属于这个 input 的上传框/预览图",逐个挂上 */
|
||
function bindInput(input) {
|
||
bindZone(input, input);
|
||
var sel = input.id ? ('[data-pick-input="#' + input.id + '"]') : '';
|
||
if (sel) {
|
||
Array.prototype.forEach.call(document.querySelectorAll(sel), function (pv) {
|
||
bindZone(pv, input);
|
||
bindBox(pv, input);
|
||
});
|
||
}
|
||
// 新结构:框自己声明 data-sd-for 指向这个 input。
|
||
// 🔴 v30 用户第 1 条:选择器**不能**带上 `[data-drop-stage]` 这一层 ——
|
||
// "导入型"上传框(ics / 通讯录)故意**不带** data-drop-stage(它要走
|
||
// "写回 input.files、随表单提交、PHP 读完即弃"的老路,见 _stage_drop.php 头部)。
|
||
// 带上它就会漏掉这些框 → 框既点不开文件选择框、也接不住拖动。
|
||
if (input.id) {
|
||
Array.prototype.forEach.call(
|
||
document.querySelectorAll('[data-sd-for="' + input.id + '"]'),
|
||
function (bx) { bindZone(bx, input); bindBox(bx, input); bindFileEcho(bx, input); });
|
||
}
|
||
}
|
||
|
||
function scan(scope) {
|
||
Array.prototype.forEach.call(
|
||
(scope || document).querySelectorAll('input[type="file"]'), bindInput);
|
||
}
|
||
|
||
scan();
|
||
// 动态加进来的文件框(工作经历那种"再来一段"、日历弹框里的附件…)也要接得住
|
||
if (window.MutationObserver) {
|
||
new MutationObserver(function (muts) {
|
||
muts.forEach(function (m) {
|
||
Array.prototype.forEach.call(m.addedNodes || [], function (n) {
|
||
if (!n.querySelectorAll) { return; }
|
||
if (n.tagName === 'INPUT' && n.type === 'file') { bindInput(n); }
|
||
scan(n);
|
||
});
|
||
});
|
||
}).observe(document.body, { childList: true, subtree: true });
|
||
}
|
||
})();
|
||
|
||
// 头像选好后即时预览(仅用于**没走裁剪器**的文件框,避免和裁剪预览打架)
|
||
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 列表),一次提交写完 ——
|
||
// 逐条提交会在中途留下自相矛盾的中间态(两个人暂时同号),刷新一下就变样。
|
||
//
|
||
// 🔴🔴 这一段**不用 HTML5 拖放**(dragstart/dragover/drop),改用 Pointer Events。
|
||
// 换掉的原因(用户 2026-10-04 第 6 条:「chrome 下只有第一组可以拖动」):
|
||
// · HTML5 DnD 的拖放是浏览器在"协商"的:dragstart 之后必须由我们在
|
||
// dragover 里持续 preventDefault 才允许放下,而一旦拖动过程中**改了 DOM**
|
||
// (本功能本来就要把卡片挪来挪去),Chrome 会中途撤销这次拖放 ——
|
||
// 表现就是"第一栏能拖,后面的栏拖着拖着就断了"。
|
||
// · 它**没有自动滚动**:名册长了以后下面的分组在视口外,根本够不着。
|
||
// · 手机上 HTML5 DnD 一律不生效(这条以前是靠「⋯」表单兜的)。
|
||
// Pointer Events 三件事全都自己说了算:落点、自动滚动、触摸。
|
||
//
|
||
// 没有这段 JS 也完全能用:行上的「分组」按钮(<details> 里的表单)照样能改分组。
|
||
(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 MIN_MOVE = 5; // 超过这个位移才算"在拖",否则只是点了一下
|
||
var EDGE = 72; // 指针离视口上下边缘多少像素开始自动滚动
|
||
var EDGE_SPD = 18; // 自动滚动速度(像素/帧)
|
||
var pending = null; // 按下但还没到门槛
|
||
var dragging = null; // 真正在拖的那张卡
|
||
var suppressClick = false; // 拖完要掐掉紧接着的那次 click(否则会跳进详情页)
|
||
var startPt = null;
|
||
var lastPt = null;
|
||
var rafId = 0;
|
||
var noteEl = null;
|
||
|
||
function each(list, fn) { Array.prototype.forEach.call(list, fn); }
|
||
|
||
/* 🔴 v28 用户第 5 条:名册从「分栏」改成**平铺**(一个人可挂多个标签,
|
||
分栏会让他在多栏里重复出现)。拖动因此只剩"排序"这一件事。
|
||
⇒ 把整个 `.roster` 当成**唯一一栏**返回:下游的 placeAt / save / refreshCounts
|
||
全都按"栏"来写,只要这里返回一个元素,它们不用改就能继续工作
|
||
(比重写三条逻辑安全得多,也不会漏掉其中一条)。
|
||
⚠️ 返回的是 `[roster]` 而不是 `.people-grid`:placeAt 里会 `block.querySelector('.people-grid')`,
|
||
所以仍要有一个带 `.people-grid` 的容器;模板里 roster 直接包含 .people-grid。 */
|
||
function blocks() {
|
||
var flat = roster.querySelector('.people-grid');
|
||
return flat ? [roster] : [];
|
||
}
|
||
|
||
/** 从事件目标往上找那张可拖的卡片(点在卡片里的链接/表单上时也算这张卡) */
|
||
function cardFrom(node) {
|
||
var t = node;
|
||
while (t && t !== roster) {
|
||
if (t.classList && t.classList.contains('person')) { return t; }
|
||
t = t.parentNode;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* v24 第 25 批 用户第 5 条:拖拽的起手判据只剩一条 —— **必须按在 ⠿ 手柄上**。
|
||
*
|
||
* 原来的 `isControl()`(判断"这一点是不是按钮/输入框/大链接")**已删除**:
|
||
* 那个函数存在的前提是"整行都能拖",而用户明确要求改成
|
||
* 「点击列表中的人,是直接查看这个人的信息,拖动的时候才是调整数序」。
|
||
* 整行不再参与拖动之后,"哪些控件不该触发拖拽"这个问题就不存在了 ——
|
||
* 留着一个没人调用的函数只会让下一个人以为还有第二条拖拽路径。
|
||
*/
|
||
|
||
/** 每栏人数与"空栏"标记跟着 DOM 走 —— 不然拖完之后标题上的「N 人」还是旧的。
|
||
* ⚠️ v28:平铺之后只剩"一栏"(就是 .roster 本身),它**没有 .group-n 也没有
|
||
* data-group**,所以下面这些查询都会拿到 null —— 全部做了存在性判断,
|
||
* 并且**绝不能**再把 `b.hidden` 设成"没人就隐藏":
|
||
* 那会把整张名册(roster 自己)藏起来。平铺下没有"空栏"这个概念。 */
|
||
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); }
|
||
});
|
||
}
|
||
|
||
/** 右下角那个小提示(成功/失败都只活两秒,不打断操作) */
|
||
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); }
|
||
});
|
||
/* ⚠️ v28:平铺之后**不报 `g`** —— 见 PersonRoster::applyLayout 的注释:
|
||
报了组号(哪怕是 0)就会覆盖 group_id,拖一次就把所有人的旧分组清空,
|
||
而那是"旧分组→同名标签"迁移的数据源、也是 vCard 的读点。
|
||
不报 `g` = 只更新顺序,这正是"拖动只改先后"的新语义。 */
|
||
cols.push({ 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);
|
||
});
|
||
}
|
||
|
||
/** 按当前指针位置把拖着的卡插到正确的栏、正确的位置 */
|
||
function placeAt(pt) {
|
||
// ⚠️ 落点必须用 elementFromPoint 现算:拖动的卡自己就在指针底下,
|
||
// 而它在 CSS 里是 pointer-events:none(见 .person.is-dragged),
|
||
// 所以拿到的永远是它**下面**那个元素 —— 正是落点。
|
||
var under = document.elementFromPoint(pt.x, pt.y);
|
||
if (!under) { return; }
|
||
var block = under.closest ? under.closest('.group-block') : null;
|
||
if (!block || !roster.contains(block)) { return; }
|
||
var grid = block.querySelector('.people-grid');
|
||
if (!grid) { return; }
|
||
|
||
/* 落点判定(用户 2026-10-03 第 4 条:"好像只能往后调整,不能往前调整")。
|
||
名册是**竖向列表**,所以:指针在某行的**上半部分** → 插到它前面;
|
||
下半部分 → 插到它后面。
|
||
⚠️ 候选里要**排除 dragging 自己**,否则它自己会变成参照物、
|
||
两个方向都会算成"不动"(这正是"往前拖无效"的根因)。 */
|
||
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 (pt.y < r.top + r.height / 2) {
|
||
target = kids[i];
|
||
before = true;
|
||
break;
|
||
}
|
||
target = kids[i];
|
||
before = false;
|
||
}
|
||
if (!target) {
|
||
if (grid.lastChild !== dragging) { grid.appendChild(dragging); }
|
||
} else if (before) {
|
||
if (target.previousSibling !== dragging) { grid.insertBefore(dragging, target); }
|
||
} else {
|
||
if (target.nextSibling !== dragging) { grid.insertBefore(dragging, target.nextSibling); }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 自动滚动:指针靠近视口上下边缘时带着页面走。
|
||
* 名册长到一千人时,下面的分组在视口外面 —— 没有它根本拖不过去。
|
||
*/
|
||
function autoScroll() {
|
||
rafId = 0;
|
||
if (!dragging || !lastPt) { return; }
|
||
var dy = 0;
|
||
if (lastPt.y < EDGE) { dy = -Math.round(EDGE_SPD * (1 - lastPt.y / EDGE)); }
|
||
else if (lastPt.y > window.innerHeight - EDGE) {
|
||
dy = Math.round(EDGE_SPD * (1 - (window.innerHeight - lastPt.y) / EDGE));
|
||
}
|
||
if (dy !== 0) {
|
||
window.scrollBy(0, dy);
|
||
placeAt(lastPt);
|
||
}
|
||
rafId = window.requestAnimationFrame(autoScroll);
|
||
}
|
||
|
||
function beginDrag(card, pt) {
|
||
dragging = card;
|
||
// ⚠️ v24 第 25 批:`is-dragging` 从 pointerdown 挪到了这里(见 pointerdown 的说明)。
|
||
// 只有从 ⠿ 手柄起拖,所以"提前露空栏"不再必要 —— 真开始拖时再露。
|
||
roster.classList.add('is-dragging');
|
||
card.classList.add('is-dragged');
|
||
placeAt(pt);
|
||
if (!rafId) { rafId = window.requestAnimationFrame(autoScroll); }
|
||
}
|
||
|
||
function endDrag() {
|
||
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
|
||
if (!dragging) { pending = null; return; }
|
||
dragging.classList.remove('is-dragged');
|
||
roster.classList.remove('is-dragging');
|
||
dragging = null;
|
||
pending = null;
|
||
// ⚠️ 紧接着的那次 click 要掐掉:指针从卡片上的链接按下、拖走了又松开,
|
||
// 浏览器会当成"点了那个链接"→ 直接跳进详情页,拖了半天等于白拖。
|
||
suppressClick = true;
|
||
window.setTimeout(function () { suppressClick = false; }, 0);
|
||
refreshCounts();
|
||
save();
|
||
}
|
||
|
||
roster.addEventListener('pointerdown', function (e) {
|
||
if (e.button !== 0 && e.pointerType === 'mouse') { return; }
|
||
var card = cardFrom(e.target);
|
||
if (!card) { return; }
|
||
/* 🔴🔴 v24 第 25 批 用户第 5 条:**只有从 ⠿ 手柄上按下才算拖动**。
|
||
*
|
||
* 用户原话:「现在名册的操作还是很不好,应该是点击列表中的人,是直接查看这个人的信息,
|
||
* 拖动的时候才是调整数序,现在是混乱的。」
|
||
*
|
||
* 改之前的行为(三处叠加,所以"混乱"):
|
||
* ① 鼠标在整行任何非控件处按下都算"待拖";
|
||
* ② 而且**按下那一刻**就给 .roster 加 `is-dragging`(本来是为了提前露出空栏当落点);
|
||
* ③ 于是轻轻点一下卡片,空栏先"冒出来"、页面高度跳一下,视觉上就是"我点了一下,
|
||
* 页面自己动了一格";抬起时又收回去 —— 用户根本分不清自己是在点还是在拖。
|
||
* ⇒ 现在:**整行都不再触发拖拽**;只有那枚 ⠿(.drag-handle,专门用来抓的)
|
||
* 按下才进入待拖状态。点击整行 = 沿 .person-main 链接进详情页(原样保留)。
|
||
*
|
||
* ⚠️ 鼠标与触摸**同一判据**(不再按 pointerType 分叉):
|
||
* 以前触摸只认手柄、鼠标认整行 —— 两种设备手感不同,用户换台机器就"又变了"。
|
||
* 统一成"都只认手柄"之后,语义只有一条:**拖这个 ⠿**。
|
||
* ⚠️ `is-dragging` 也挪到 beginDrag() 里加(不再在 pointerdown 加):
|
||
* 既然只有手柄能起拖,就没有"提前露空栏"的必要了 ——
|
||
* 手柄本身很小,用户按下它就是明确要拖,那一刻再露空栏不影响瞄准。 */
|
||
var fromHandle = !!(e.target.closest && e.target.closest('.drag-handle'));
|
||
if (!fromHandle) { return; }
|
||
pending = { card: card, id: e.pointerId, type: e.pointerType };
|
||
startPt = { x: e.clientX, y: e.clientY };
|
||
lastPt = { x: e.clientX, y: e.clientY };
|
||
e.preventDefault(); // 别顺手选中文字
|
||
});
|
||
|
||
document.addEventListener('pointermove', function (e) {
|
||
if (pending && !dragging && e.pointerId === pending.id) {
|
||
if (Math.abs(e.clientX - startPt.x) < MIN_MOVE
|
||
&& Math.abs(e.clientY - startPt.y) < MIN_MOVE) {
|
||
return;
|
||
}
|
||
beginDrag(pending.card, { x: e.clientX, y: e.clientY });
|
||
}
|
||
if (!dragging) { return; }
|
||
lastPt = { x: e.clientX, y: e.clientY };
|
||
placeAt(lastPt);
|
||
});
|
||
|
||
document.addEventListener('pointerup', function (e) {
|
||
if (dragging) { endDrag(); }
|
||
else if (pending && e.pointerId === pending.id) {
|
||
// 在手柄上按下、但位移没到门槛(= 没真拖)→ 清掉待拖态,什么都不做。
|
||
// ⚠️ v24 第 25 批:这里**不再**需要 `is-dragging` 的收回 ——
|
||
// 那个类现在只在 beginDrag() 里加(见 pointerdown 的说明)。
|
||
pending = null;
|
||
}
|
||
});
|
||
document.addEventListener('pointercancel', function () {
|
||
if (dragging) { endDrag(); }
|
||
else if (pending) { pending = null; }
|
||
});
|
||
|
||
// 拖完掐掉那一次 click(捕获阶段就拦,别等它冒泡到链接上)
|
||
document.addEventListener('click', function (e) {
|
||
if (!suppressClick) { return; }
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}, true);
|
||
})();
|
||
|
||
// ===== 地址:省 → 市 → 区 三级联动(用户 2026-10-04 第 4 条)=====
|
||
//
|
||
// 数据有 3429 条,一次性塞进页面会让表单页重一百多 KB,所以按级去取:
|
||
// 换了省就把「市」换成这个省下面的市、并把「区」清空;换了市同理。
|
||
//
|
||
// ⚠️ 这是**纯增强**:服务端已经把当前这一条链(省→市→区)的选项渲染好了,
|
||
// 没有这段 JS 时三个下拉照样能选、照样能提交,只是换了省不会自动带出新的市。
|
||
(function () {
|
||
var wrap = document.querySelector('[data-addr]');
|
||
if (!wrap) { return; }
|
||
var src = wrap.getAttribute('data-src');
|
||
var selProvince = wrap.querySelector('[data-addr-lvl="1"]');
|
||
var selCity = wrap.querySelector('[data-addr-lvl="2"]');
|
||
var selDistrict = wrap.querySelector('[data-addr-lvl="3"]');
|
||
if (!selProvince || !src) { return; }
|
||
|
||
var cache = {}; // 同一个父级取过一次就不再问(省下面那几个市来回切很常见)
|
||
|
||
function fill(sel, items, keep) {
|
||
sel.innerHTML = '';
|
||
var none = document.createElement('option');
|
||
none.value = '';
|
||
none.textContent = '(不填)';
|
||
sel.appendChild(none);
|
||
Array.prototype.forEach.call(items, function (it) {
|
||
var o = document.createElement('option');
|
||
o.value = it.name;
|
||
o.setAttribute('data-code', it.code);
|
||
o.textContent = it.name;
|
||
if (keep && keep === it.name) { o.selected = true; }
|
||
sel.appendChild(o);
|
||
});
|
||
sel.setAttribute('data-code', '');
|
||
}
|
||
|
||
function codeOf(sel) {
|
||
var o = sel.selectedOptions && sel.selectedOptions[0];
|
||
return (o && o.getAttribute('data-code')) || sel.getAttribute('data-code') || '';
|
||
}
|
||
|
||
function load(parent, done) {
|
||
var key = parent || '_root';
|
||
if (cache[key]) { done(cache[key]); return; }
|
||
if (!window.fetch) { done([]); return; }
|
||
window.fetch(src + (parent ? '&parent=' + encodeURIComponent(parent) : ''), {
|
||
credentials: 'same-origin',
|
||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||
}).then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (j) {
|
||
var items = (j && j.items) || [];
|
||
cache[key] = items;
|
||
done(items);
|
||
}).catch(function () { done([]); });
|
||
}
|
||
|
||
selProvince.addEventListener('change', function () {
|
||
var keep = selCity.value;
|
||
load(codeOf(selProvince), function (items) {
|
||
// ⚠️ 换了省之后**原来看中的市多半不在这个省里** ——
|
||
// 但也不一律清空:省没变只是重取了一次时,保留用户选过的那个。
|
||
var still = false;
|
||
Array.prototype.forEach.call(items, function (it) { if (it.name === keep) { still = true; } });
|
||
fill(selCity, items, still ? keep : '');
|
||
fill(selDistrict, [], '');
|
||
});
|
||
});
|
||
|
||
if (selCity) {
|
||
selCity.addEventListener('change', function () {
|
||
load(codeOf(selCity), function (items) {
|
||
fill(selDistrict, items, selDistrict.value);
|
||
});
|
||
});
|
||
}
|
||
})();
|
||
|
||
// ===== 名册行的「分组」按钮:选中即提交 =====
|
||
// ⚠️ v24 第 10 批 #6 起,**这段委托搬进了 templates/people/index.php 的页内脚本**。
|
||
// 原因:分组表单不再是"每卡一份"的内联 <form>,而是全页**唯一一份**共享浮层
|
||
// (1184 人的名册原来有 2369 个 form / 27234 个 input,密码管理器逐个扫描
|
||
// 会把页面拖死 —— 用户报的"和 Bitwarden 冲突"就是这个)。
|
||
// 表单是页内动态填值的,提交委托跟着它一起走,读起来才是一件事。
|
||
// 这里留一条注释而不是删干净,是为了下一个人不要又把委托加回来。
|
||
|
||
// ===== 点页面其它地方 / 按 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;
|
||
}
|
||
});
|
||
})();
|
||
})();
|
||
|
||
// ==================== Semi Design DatePicker(v24 第 9 批 #9) ====================
|
||
// 用户点名「所有日期选择换 https://semi.design 的 DatePicker,样式与系统统一」。
|
||
// 这里是**渐进增强**的升级层:templates/_datetime.php 照旧渲染原生「日期+时/分/秒」
|
||
// 四字段(表单契约与服务端 read_dt_input() 不动),Semi 的资源(合计约 4 MB)只在
|
||
// 页面上真出现 .dt-input 时才懒加载;加载成功 → 藏原生、挂 DatePicker、值回写原字段;
|
||
// 加载失败 → 原生控件原样可用,页面不会坏。
|
||
(function () {
|
||
var boxes = Array.prototype.slice.call(document.querySelectorAll('.dt-input[data-dt]'));
|
||
var hasMonth = !!document.querySelector('input[type="month"]');
|
||
if (!boxes.length && !hasMonth) { return; }
|
||
var meta = document.querySelector('meta[name="fb-semi"]');
|
||
if (!meta) { return; }
|
||
var conf;
|
||
try { conf = JSON.parse(meta.getAttribute('content')); } catch (e) { return; }
|
||
if (!conf || !conf.css || !conf.react || !conf.dom || !conf.ui) { return; }
|
||
|
||
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 s = document.createElement('script');
|
||
s.src = src;
|
||
s.onload = function () { res(); };
|
||
s.onerror = function () { rej(new Error('加载失败: ' + src)); };
|
||
document.head.appendChild(s);
|
||
});
|
||
}
|
||
|
||
function pad(n) { return (n < 10 ? '0' : '') + n; }
|
||
|
||
function parseDT(str) {
|
||
// 'YYYY-MM-DD [HH:mm[:ss]]' → 本地时区 Date | undefined
|
||
var m = (str || '').trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?$/);
|
||
if (!m) { return undefined; }
|
||
var d = new Date(+m[1], +m[2] - 1, +m[3], +(m[4] || 0), +(m[5] || 0), +(m[6] || 0));
|
||
return isNaN(d.getTime()) ? undefined : d;
|
||
}
|
||
|
||
function part(native, sel) {
|
||
var n = native.querySelector(sel);
|
||
return n ? String(n.value || '') : '';
|
||
}
|
||
|
||
/**
|
||
* 把 Semi 给的字符串写回某个 .dt-input 的原生四字段(POST 契约)。
|
||
* str 形如 '2026-10-18 07:46:13' / '2026-10-18 07:46' / ''(清空)。
|
||
* ⚠️ 提成公用函数:单点控件与范围两端都走它,规则只有一处。
|
||
*/
|
||
function writeDT(box, str) {
|
||
var m = (str || '').trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$/);
|
||
function set(sel, v) {
|
||
var n = box.querySelector(sel);
|
||
if (!n || n.value === v) { return; }
|
||
n.value = v;
|
||
n.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
if (m) {
|
||
set('.dt-date', m[1] + '-' + pad(+m[2]) + '-' + pad(+m[3]));
|
||
set('.dt-hour', pad(+m[4]));
|
||
set('.dt-minute', pad(+m[5]));
|
||
set('.dt-second', pad(+(m[6] || 0)));
|
||
} else {
|
||
set('.dt-date', '');
|
||
set('.dt-hour', '00');
|
||
set('.dt-minute', '00');
|
||
set('.dt-second', '00');
|
||
}
|
||
}
|
||
|
||
/** 读某个 .dt-input 当前的原生值 → 'YYYY-MM-DD HH:mm:ss' */
|
||
function readDT(box) {
|
||
var native = box.querySelector('.dt-native');
|
||
if (!native) { return ''; }
|
||
return part(native, '.dt-date') + ' ' +
|
||
(part(native, '.dt-hour') || '00') + ':' +
|
||
(part(native, '.dt-minute') || '00') + ':' +
|
||
(part(native, '.dt-second') || '00');
|
||
}
|
||
|
||
/**
|
||
* 起止范围:把一对 .dt-input[data-dt.range=start|end] 合并成**一个**
|
||
* Semi `dateTimeRange` 选择器(用户第 7 条:「应该用这个日期插件的时间范围选择方式,
|
||
* 而不是将这个范围拆成两个,这样不好看,也多一步操作」)。
|
||
*
|
||
* 契约不变:两个原生控件都留着(藏在隐藏容器里),Semi 选完把两端分别写回它们,
|
||
* 所以服务端的 read_dt_input('starts_at') / ('ends_at') 与既有的 e2e 全部照旧。
|
||
*/
|
||
function upgradeRange(group) {
|
||
if (group.getAttribute('data-semi-range-done')) { return; }
|
||
var boxes = Array.prototype.slice.call(group.querySelectorAll('.dt-input[data-dt]'));
|
||
if (boxes.length !== 2) { return; }
|
||
// ⚠️ **两端都得可见**才升级成范围控件。
|
||
// 日程页的「结束」默认是隐藏的(要点「+ 添加结束时间」才出现,见 _event_fields.php)——
|
||
// 那时用户表达的意图是"就一个时刻",硬给一个范围控件反而多一个空着的输入框。
|
||
// 用户在页面里点了那个开关之后,页内 JS 会再调一次 window.fbUpgradeRange(),
|
||
// 这时两端都可见了,才真正合并成范围。
|
||
var visible = boxes.every(function (bx) { return bx.offsetParent !== null; });
|
||
if (!visible) { return; }
|
||
// 按 data-dt.range 认起止;没标就按 DOM 顺序(起在前)
|
||
var a = null, b = null, secs = true;
|
||
boxes.forEach(function (bx) {
|
||
var cfg = {};
|
||
try { cfg = JSON.parse(bx.getAttribute('data-dt') || '{}'); } catch (e) {}
|
||
if (cfg.seconds === false) { secs = false; }
|
||
if (cfg.range === 'end') { b = bx; } else if (cfg.range === 'start') { a = bx; }
|
||
else if (a === null) { a = bx; } else { b = bx; }
|
||
});
|
||
if (!a || !b) { return; }
|
||
|
||
/* 🔴🔴 v35:**范围控件不再用 Semi 的 dateTimeRange**,改用原生
|
||
「日期框 + 时间框」两个小控件。
|
||
用户原话:「选择日期那个地方给,非常不好用。如果我不选日期,只选时间,
|
||
框里面仍然是空白的,也没有确定按钮,这个需要重新调整一下。」
|
||
|
||
实测结论(独立最小复现 + 真机,2026-10-09):
|
||
· Semi 的 dateTimeRange 面板**只给日期网格**,时间藏在
|
||
`.semi-datepicker-switch-time` 那个小时钟按钮后面;
|
||
· 点了那个按钮切到时间面板,**面板仍然是空的**
|
||
(`timeLists = 0`、无选项、无 footer、无"确定")→ 根本没法只选时间;
|
||
· 单点 `dateTime` 同样(面板里没有可选的时间列)。
|
||
⇒ 该组件在这套资源下**给不了"选完即确认"的时间选择**,也不能"只选时间",
|
||
与用户描述的症状完全一致。
|
||
⇒ 改用原生 `type="time" step="1"`:24 小时制、精确到秒、点了就选、
|
||
不选日期也照样有值(实测 `21:45:30` 会保留),且**不需要**确认按钮。
|
||
|
||
⚠️ 提交契约**一点没变**:仍是 `*_date` / `*_hour` / `*_minute` / `*_second`
|
||
四个字段(服务端 read_dt_input 照收、既有 e2e 照过)。
|
||
这里只是把两端的日期框**露出来**、并把原来的 时/分/秒 三个下拉
|
||
合成**一个**原生时间框(三个下拉仍留在 DOM 里,只是不单独显示)。 */
|
||
|
||
/* 把某个端点原来的「日期 + 三个下拉」变成「日期框 + 一个时间框」。
|
||
值照旧写回那三个下拉(POST 契约),只是视觉上收成一个控件。 */
|
||
function mergeTimeInputs(box) {
|
||
var native = box.querySelector('.dt-native');
|
||
if (!native) { return; }
|
||
var dateEl = native.querySelector('.dt-date');
|
||
var hourEl = native.querySelector('.dt-hour');
|
||
var minEl = native.querySelector('.dt-minute');
|
||
var secEl = native.querySelector('.dt-second');
|
||
if (!dateEl || !hourEl || !minEl) { return; }
|
||
var timeEl = document.createElement('input');
|
||
timeEl.type = 'time';
|
||
timeEl.className = 'dt-time-input';
|
||
timeEl.step = secEl ? '1' : '60'; // 要秒的槽位精确到秒
|
||
timeEl.setAttribute('aria-label', '时间(24 小时制)');
|
||
function syncFromSelects() {
|
||
timeEl.value = (hourEl.value || '00') + ':' + (minEl.value || '00')
|
||
+ (secEl ? ':' + (secEl.value || '00') : '');
|
||
}
|
||
syncFromSelects();
|
||
timeEl.addEventListener('change', function () {
|
||
var m = (timeEl.value || '').match(/^(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$/);
|
||
if (!m) { return; }
|
||
hourEl.value = pad(+m[1]);
|
||
minEl.value = pad(+m[2]);
|
||
if (secEl) { secEl.value = pad(+(m[3] || 0)); }
|
||
});
|
||
// 供"程序化改值时让时间框跟上"(如日程页点日历空白处预填)
|
||
box.__fbSyncTime = syncFromSelects;
|
||
// 插在日期框后面;三个下拉只留下标(提交用),视觉隐藏
|
||
dateEl.parentNode.insertBefore(timeEl, dateEl.nextSibling);
|
||
if (secEl) { secEl.classList.add('dt-merged'); }
|
||
minEl.classList.add('dt-merged');
|
||
hourEl.classList.add('dt-merged');
|
||
// 原来把 时:分:秒 串起来的分隔符也一起收掉
|
||
var nat = box.querySelector('.dt-native');
|
||
Array.prototype.forEach.call(nat.querySelectorAll('.dt-colon, .dt-time'), function (n) {
|
||
n.classList.add('dt-merged');
|
||
});
|
||
}
|
||
|
||
mergeTimeInputs(a);
|
||
mergeTimeInputs(b);
|
||
|
||
// 两端各自**露出来**(不再藏)—— v35 的形态就是"两个 日期+时间 对"
|
||
a.style.display = '';
|
||
b.style.display = '';
|
||
var na = a.querySelector('.dt-native'), nb = b.querySelector('.dt-native');
|
||
if (na) { na.style.display = 'contents'; }
|
||
if (nb) { nb.style.display = 'contents'; }
|
||
a.classList.add('has-value');
|
||
if (readDT(b).trim() !== ' ') { b.classList.add('has-value'); }
|
||
|
||
// 仅供 fbSetDT / fbUpgradeRange 的既有调用点使用(不再挂 Semi)
|
||
group.__fbRemount = function () {
|
||
if (a.__fbSyncTime) { a.__fbSyncTime(); }
|
||
if (b.__fbSyncTime) { b.__fbSyncTime(); }
|
||
};
|
||
|
||
/* ⚠️ 原来这里会隐藏"端点最外层的包装"(因为 Semi 范围控件顶替了两端)。
|
||
v35 起两端**就是要显示**的,所以不再隐藏任何东西 ——
|
||
v34 那个"共用包装导致整组被藏"的 bug 也随之不再可能发生。
|
||
(v34 的修法保留在 _fb_b34_dtrange.py 的断言里:组必须可见。) */
|
||
guardRequired(a);
|
||
guardRequired(b);
|
||
group.setAttribute('data-semi-range-done', '1');
|
||
group.classList.add('is-semi-range');
|
||
group.classList.add('is-native-range');
|
||
}
|
||
|
||
function upgrade(box) {
|
||
if (box.getAttribute('data-semi-done')) { return; }
|
||
// 范围对的成员由 upgradeRange 统一处理
|
||
if (box.classList.contains('dt-part')) { return; }
|
||
var cfg = {};
|
||
try { cfg = JSON.parse(box.getAttribute('data-dt') || '{}'); } catch (e) {}
|
||
var native = box.querySelector('.dt-native');
|
||
if (!native) { return; }
|
||
|
||
/* 🔴 v35:单点时间控件同样**不再用 Semi**(图省事不再区分两条路径)——
|
||
Semi 的 `dateTime` 面板同样没有可选的时间列(见 upgradeRange 里的实测)。
|
||
这里只把原来的 时/分/秒 三个下拉合成**一个原生时间框**,
|
||
日期框照旧是原生 `type="date"`,两者都**直接显示**。
|
||
契约不变(*_date/_hour/_minute/_second 仍在 DOM 里提交)。 */
|
||
if (!box.__fbMerged) {
|
||
box.__fbMerged = true;
|
||
var dateEl = native.querySelector('.dt-date');
|
||
var hourEl = native.querySelector('.dt-hour');
|
||
var minEl = native.querySelector('.dt-minute');
|
||
var secEl = native.querySelector('.dt-second');
|
||
if (dateEl && hourEl && minEl) {
|
||
var timeEl = document.createElement('input');
|
||
timeEl.type = 'time';
|
||
timeEl.className = 'dt-time-input';
|
||
timeEl.step = secEl ? '1' : '60';
|
||
timeEl.setAttribute('aria-label', '时间(24 小时制)');
|
||
function syncFromSelects() {
|
||
timeEl.value = (hourEl.value || '00') + ':' + (minEl.value || '00')
|
||
+ (secEl ? ':' + (secEl.value || '00') : '');
|
||
}
|
||
syncFromSelects();
|
||
timeEl.addEventListener('change', function () {
|
||
var m = (timeEl.value || '').match(/^(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$/);
|
||
if (!m) { return; }
|
||
hourEl.value = pad(+m[1]);
|
||
minEl.value = pad(+m[2]);
|
||
if (secEl) { secEl.value = pad(+(m[3] || 0)); }
|
||
});
|
||
box.__fbSyncTime = syncFromSelects;
|
||
dateEl.parentNode.insertBefore(timeEl, dateEl.nextSibling);
|
||
if (secEl) { secEl.classList.add('dt-merged'); }
|
||
minEl.classList.add('dt-merged');
|
||
hourEl.classList.add('dt-merged');
|
||
Array.prototype.forEach.call(native.querySelectorAll('.dt-colon, .dt-time'), function (n) {
|
||
n.classList.add('dt-merged');
|
||
});
|
||
}
|
||
}
|
||
// 直接露出原生(日期 + 时间)
|
||
native.style.display = 'contents';
|
||
box.classList.add('is-native');
|
||
box.classList.add('has-value');
|
||
// 程序化改值时让时间框跟上
|
||
box.__fbRemount = function () { if (box.__fbSyncTime) { box.__fbSyncTime(); } };
|
||
guardRequired(box);
|
||
box.setAttribute('data-semi-done', '1');
|
||
}
|
||
|
||
|
||
/**
|
||
* 原生控件被藏起来之后,它上面的 `required` 必须**摘掉**并改由我们自己校验。
|
||
*
|
||
* ⚠️ 这是 Chrome 的一个硬行为:`required` 落在 `display:none` 的控件上时,
|
||
* 表单提交会被拦住,但浏览器**没法把焦点移到那个元素**去提示 →
|
||
* 控制台报 "An invalid form control with name='…' is not focusable",
|
||
* 页面上**什么都不发生**(点了保存没反应,也没有红字提示)。
|
||
* 用户会以为"保存按钮坏了"。
|
||
*
|
||
* 所以:摘掉 required → 给容器打 data-required → 在 form 的 submit 上自查。
|
||
* 🔴 单点控件和**范围控件**都要走这里 —— 范围那次我漏了,
|
||
* 结果观影表单(starts_at 是必填)怎么点都提交不了。
|
||
*/
|
||
function guardRequired(box) {
|
||
var req = box.querySelector('.dt-date[required]');
|
||
if (!req) { return; }
|
||
req.removeAttribute('required');
|
||
box.setAttribute('data-required', '1');
|
||
var form = box.closest('form');
|
||
if (!form || form.getAttribute('data-dt-guard')) { return; }
|
||
form.setAttribute('data-dt-guard', '1');
|
||
form.addEventListener('submit', function (ev) {
|
||
// 不能用 :not([value]) 这类选择器:value 是动态写回的属性,得手动筛
|
||
var bad = [];
|
||
form.querySelectorAll('.dt-input[data-required="1"]').forEach(function (b) {
|
||
var d = b.querySelector('.dt-date');
|
||
if (d && !d.value) { bad.push(b); }
|
||
});
|
||
if (bad.length) {
|
||
ev.preventDefault();
|
||
bad.forEach(function (b) { b.classList.add('dt-missing'); });
|
||
// v35:不再有 .dt-semi —— 焦点落到原生日期框(那才是"该补的字段")
|
||
var inp = bad[0].querySelector('.dt-semi input')
|
||
|| bad[0].querySelector('.dt-date')
|
||
|| bad[0].querySelector('input, select');
|
||
if (inp) { inp.focus(); }
|
||
}
|
||
});
|
||
}
|
||
|
||
var kicked = false;
|
||
function boot() {
|
||
if (kicked) { return; }
|
||
kicked = true;
|
||
loadCss(conf.css);
|
||
loadJs(conf.react)
|
||
.then(function () { return loadJs(conf.dom); })
|
||
.then(function () { return loadJs(conf.ui); })
|
||
.then(function () {
|
||
// 先处理"起止成对"的(它们会各自挂一个范围控件并把自己藏起来),
|
||
// 再处理剩下的单点控件 —— 否则范围里的两个会先被当成两个独立控件升级掉。
|
||
Array.prototype.forEach.call(document.querySelectorAll('.dt-range'), upgradeRange);
|
||
boxes.forEach(upgrade);
|
||
upgradeAllMonths();
|
||
watchNewControls();
|
||
})
|
||
.catch(function (err) {
|
||
// 资源拉不动就留在原生控件上 —— 渐进增强的兜底,不弹任何打扰
|
||
if (window.console) { console.warn('Semi DatePicker 未启用:', err && err.message); }
|
||
});
|
||
}
|
||
|
||
// ---- 工作经历的 <input type="month">(YYYY-MM)也换 Semi DatePicker ----
|
||
// 原生 type="month" Firefox 压根不支持;同样渐进增强:原 input 藏起来当值载体,
|
||
// POST 契约(job_start[]/job_end[])不变。「再加一段」新增的行由 observer 补挂。
|
||
function parseYm(str) {
|
||
var m = (str || '').trim().match(/^(\d{4})-(\d{1,2})$/);
|
||
if (!m) { return undefined; }
|
||
var d = new Date(+m[1], +m[2] - 1, 1);
|
||
return isNaN(d.getTime()) ? undefined : d;
|
||
}
|
||
|
||
function upgradeMonth(inp) {
|
||
if (inp.getAttribute('data-semi-month')) { return; }
|
||
inp.setAttribute('data-semi-month', '1');
|
||
var host = document.createElement('span');
|
||
host.className = 'dt-semi dt-semi-month';
|
||
inp.parentNode.insertBefore(host, inp);
|
||
try {
|
||
ReactDOM.createRoot(host).render(React.createElement(SemiUI.DatePicker, {
|
||
type: 'month',
|
||
format: 'yyyy-MM',
|
||
showClear: true,
|
||
defaultValue: parseYm(inp.value),
|
||
placeholder: '选择年月',
|
||
position: window.matchMedia('(max-width: 640px)').matches ? 'bottom' : 'bottomLeft',
|
||
onChange: function (date, str) {
|
||
var v = (str || '').trim();
|
||
if (inp.value !== v) {
|
||
inp.value = v;
|
||
inp.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
}
|
||
}));
|
||
} catch (e) {
|
||
if (window.console) { console.warn('月份选择器挂载失败,留在原生控件:', e && e.message); }
|
||
return;
|
||
}
|
||
host.classList.add('is-live');
|
||
inp.style.display = 'none';
|
||
}
|
||
|
||
function upgradeAllMonths() {
|
||
Array.prototype.forEach.call(document.querySelectorAll('input[type="month"]'), upgradeMonth);
|
||
}
|
||
|
||
/**
|
||
* 动态新增的控件也要升级。
|
||
*
|
||
* 三种会"事后长出来"的:
|
||
* · 工作经历「+ 再加一段」→ 新的 input[type=month];
|
||
* · 旅程「+ 添加节点」→ 新的 .dt-range(成对起止);
|
||
* · 任何其它被 JS 插入的 .dt-input。
|
||
* ⚠️ 用一个 200ms 的去抖定时器:这些操作往往**一次插入一大片 DOM**
|
||
* (整段表单),逐个 MutationRecord 跑一遍会让升级逻辑反复扫全页。
|
||
*/
|
||
function watchNewControls() {
|
||
if (!window.MutationObserver) { return; }
|
||
var timer = null;
|
||
new MutationObserver(function () {
|
||
if (timer) { return; }
|
||
timer = setTimeout(function () {
|
||
timer = null;
|
||
// 范围先于单点(范围里的两个不能被当成两个独立控件)
|
||
Array.prototype.forEach.call(document.querySelectorAll('.dt-range'), upgradeRange);
|
||
Array.prototype.forEach.call(
|
||
document.querySelectorAll('.dt-input[data-dt]'),
|
||
function (bx) { upgrade(bx); });
|
||
upgradeAllMonths();
|
||
}, 200);
|
||
}).observe(document.body, {
|
||
childList: true, subtree: true,
|
||
// 🔴 v24 第 21 批 第 7 条:**必须也盯 hidden 属性的变化**。
|
||
// upgradeRange() 有一道"两端都得可见"的判断(对隐藏容器里的控件跳过升级)。
|
||
// 而日程的「新建」弹框**初始就是 hidden 的** —— 里面那对起止控件在弹框打开前
|
||
// 永远不可见,于是范围控件**永远挂不上**:用户打开弹框看到的是一片空白
|
||
// (CSS 又把未升级的原生控件设成 visibility:hidden),点哪儿都没反应。
|
||
// 这正是"时间范围选择器功能无法使用"的真根因。
|
||
// 加上 attributeFilter 之后:弹框一被打开(hidden 被摘掉)→ 观察器触发 →
|
||
// 重新扫一遍 → 范围控件就挂上了。**通用**:任何"藏在容器里、稍后才露出来"
|
||
// 的日期控件(弹框、折叠面板、tab)都会自动升级,不必逐个写钩子。
|
||
attributes: true, attributeFilter: ['hidden'],
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 对外接口:程序化设置某个日期时间字段,并**同步 Semi 的显示**。
|
||
*
|
||
* 为什么必须有它:日程页「点日历空白处 → 预填开始时间」是页内 JS 直接写原生
|
||
* `input.dt-date.value`。Semi 是非受控组件,原生值改了它不会跟着变 ——
|
||
* 于是用户会看到"我点了 15:00,控件里还写着 09:00",而提交上去却是 15:00,
|
||
* 是最难解释的一类不一致。
|
||
*
|
||
* 用法:window.fbSetDT('starts_at', '2026-10-05 15:00:00')
|
||
* 第二个参数传 '' 表示清空。
|
||
*/
|
||
/**
|
||
* 对外接口:重新扫描并升级所有范围对(供页内 JS 在"结束时间被显示出来"之后调用)。
|
||
* 幂等:已升级过的组会被 data-semi-range-done 挡掉。
|
||
*/
|
||
window.fbUpgradeRange = function () {
|
||
Array.prototype.forEach.call(document.querySelectorAll('.dt-range'), upgradeRange);
|
||
return true;
|
||
};
|
||
|
||
window.fbSetDT = function (name, str) {
|
||
if (!name) { return false; }
|
||
var box = null;
|
||
Array.prototype.forEach.call(document.querySelectorAll('.dt-input[data-dt]'), function (b) {
|
||
if (box) { return; }
|
||
var cfg = {};
|
||
try { cfg = JSON.parse(b.getAttribute('data-dt') || '{}'); } catch (e) {}
|
||
if (cfg.name === name) { box = b; }
|
||
});
|
||
if (!box) { return false; }
|
||
writeDT(box, str);
|
||
// 原生写完了 —— 现在把 Semi 一侧也刷新成同一个值
|
||
var group = box.closest ? box.closest('.dt-range') : null;
|
||
if (group && group.getAttribute('data-semi-range-done') && group.__fbRemount) {
|
||
group.__fbRemount();
|
||
} else if (box.__fbRemount) {
|
||
box.__fbRemount();
|
||
}
|
||
return true;
|
||
};
|
||
|
||
// 错开首屏:idle 时再拉 4 MB 资源,别挡首屏渲染
|
||
if ('requestIdleCallback' in window) { requestIdleCallback(boot, { timeout: 3000 }); }
|
||
else { setTimeout(boot, 500); }
|
||
})();
|
||
|
||
/* ============================================================
|
||
周/日视图的「当前时间红线」自己走(v24 第 20 批 用户第 6 条)
|
||
------------------------------------------------------------
|
||
用户原话:「用一个**不断**从上到下的红线,来表示当前时间在视图上的刻度和位置。」
|
||
⚠️ 服务端只给"渲染那一刻"的位置(%.4f,见 index.php)—— 用户开着页面不动,
|
||
过一小时线还停在原处就荒谬了。所以这里起一个定时器让它自己挪。
|
||
⚠️ 定时器**每分钟**走一次即可(线的精度就是分钟;每秒重排会白白耗电,
|
||
而这条线在视野里根本看不出每秒的差别)。
|
||
⚠️ 位置算法必须与 .tg-block / 服务端**同一套**:列高固定 24 小时 →
|
||
1 分钟 = 100/1440 %。三处口径一致,线才不会与日程块错位。
|
||
⚠️ 跨过午夜时线要**消失**(那一天已经不是"今天"了)—— 这里用"日期变了就重载一次"
|
||
处理,而不是把算出来的 top 硬塞到 >100%(那会让线停在格子底部不动,
|
||
看着像卡住了)。重载一次最稳,而且一天最多发生一次。
|
||
⚠️ 只在页面上真有这条线时才起定时器(月视图没有 `.tg-now`)。
|
||
============================================================ */
|
||
(function () {
|
||
'use strict';
|
||
var lines = document.querySelectorAll('[data-now-line]');
|
||
if (!lines.length) { return; }
|
||
|
||
var dayAtLoad = new Date().toDateString();
|
||
|
||
function place() {
|
||
var d = new Date();
|
||
if (d.toDateString() !== dayAtLoad) {
|
||
// 已经跨过午夜:这一列不再是"今天"了 → 让服务端重新渲染一次(见上面第 4 点)
|
||
window.location.reload();
|
||
return;
|
||
}
|
||
var pct = ((d.getHours() * 60 + d.getMinutes()) / 1440) * 100;
|
||
Array.prototype.forEach.call(lines, function (el) {
|
||
el.style.top = pct.toFixed(4) + '%';
|
||
el.setAttribute('title', '现在 ' + ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2));
|
||
});
|
||
}
|
||
|
||
// 对齐到"下一分钟整"再开始(否则第一次跳动会晚最多 60 秒,看着像没动)
|
||
var msToNextMinute = 60000 - (Date.now() % 60000);
|
||
setTimeout(function () {
|
||
place();
|
||
setInterval(place, 60000);
|
||
}, msToNextMinute);
|
||
|
||
// 从后台切回来时立刻纠一次(有些浏览器会冻结后台标签页的定时器)
|
||
document.addEventListener('visibilitychange', function () {
|
||
if (!document.hidden) { place(); }
|
||
});
|
||
})();
|