v24 第 10 批:名册真性能(根因是每卡内联表单)等九条

- #6 名册线上真实数据(1184 人)卡顿:根因是**每张卡片内联一整份分组弹层**——
  全页 2369 个 form / 27234 个 input / 22496 个 radio / 98100 DOM 节点,
  密码管理器(Bitwarden 等)逐个扫描 form 把主线程占死(用户报的就是这个冲突)。
  改为卡片只带 data 属性 + 全页**唯一一份**共享浮层 #rosterMenus。
  实测:form 2369→3、input 27234→25、DOM 98100→17659、解压 7694→2306 KB、
  长任务 1/52ms → 0。新增哨兵 _fb_formbloat_check.py(含反向验证)。
- #5 日视图翻页失效:day_columns() 无条件吸到周一,天视图只有 1 列 → 永远显示那周周一。
  改为仅 span>1 时吸附(周视图行为不变)。
- #2 财务「欠款」类别(负债);并修一个真 bug:负债填负数会让净资产**反向变大**。
  写入口 normalizeAmount 归一化 + summary/timeline 侧 abs 防御 + Schema v17 纠正老数据。
- #3 账目行「盘点」「修改」两个并列按钮(各自就地弹面板);图移到账目上方,
  总金额图跨整行 + 每个账目一条线 + 类别筛选条;charts() 改为覆盖全部账目。
- #4 日程新增「农历」「节假日」两本不可删除只读日历(app/Holiday.php):
  两级自动更新 —— 内置规则(公历固定 + 清明节气 + 农历节日)保证断网也有;
  官方 JSON 镜像自动抓取补齐调休。抓取**不在渲染路径联网**(响应后 fastcgi_finish_request
  + 频率闸门),缓存落 storage/cache。仅派生本不可写日程。
- #6 日程页设置按钮前加「跳到某一天」(选中即跳)。
- #7 起止范围改用一个 Semi dateTimeRange 控件(观影/旅程/节点/日程共 4 处);
  新增 window.fbSetDT / fbUpgradeRange;修范围重挂(React 非受控组件须 unmount 再 createRoot)
  与范围里 required 未摘除导致表单无法提交两个真实问题。

验证:e2e 786/0、batch10 44/0、batch9 38/0、batch_e 91/0、upgrade 126/0、picker 28/0、
semi_integrate 27/0、formbloat 5/0(+反向 3 红)、mobile 688/0、interact 97/0、drag 5/0、geom 5/5;
线上自检 19/19 + 线上真实账号巡检 28/0(含 Firefox 实测)。
This commit is contained in:
2026-10-04 10:46:50 +08:00
parent 52cc1ecccb
commit b2ca4f336e
16 changed files with 1624 additions and 286 deletions
+92 -14
View File
@@ -542,6 +542,26 @@ textarea.input { min-height: 76px; resize: vertical; }
/* 工作经历的月份选择器(type="month" 升级):占满原 .job-cell 格子 */
.dt-semi.dt-semi-month { display: none; }
.dt-semi.dt-semi-month.is-live { display: block; width: 100%; }
/* ---- v24 第 10 批 #7:起止范围用一个控件(Semi dateTimeRange)----
一对 .dt-input 被包在 .dt-range 里;升级后两端各自 display:none,
由**一个** .dt-semi-range 顶上来。未升级(JS 没跑/加载失败)时,
里面两个原生控件照旧各占一行 —— 也就是"功能没变,只是不好看"。 */
.dt-range { display: flex; flex-direction: column; gap: 8px; }
.dt-range.is-semi-range { display: block; }
.dt-range.is-semi-range > .dt-input { display: none; } /* 两端都让位给范围控件 */
.dt-semi-range { display: none; width: 100%; max-width: 460px; }
.dt-range.is-semi-range .dt-semi-range { display: block; }
/* Semi 范围输入框:与站内 .input 同高同圆角;宽度撑开一点(要放两个时间) */
body[class] .dt-semi-range .semi-input-wrapper {
height: 40px; border: 1px solid var(--line); border-radius: var(--radius-s);
background: var(--surface);
}
body[class] .dt-semi-range .semi-input-wrapper:hover { border-color: var(--brand-2); }
body[class] .dt-semi-range .semi-input { font: inherit; font-size: 13.5px; color: var(--ink); }
@media (max-width: 640px) {
.dt-semi-range { max-width: none; }
}
.dt-input.dt-missing .semi-input-wrapper { border-color: #C0392B !important; box-shadow: 0 0 0 1px #C0392B inset; }
/* Semi 主题对齐宣纸色。semi.min.css 是懒加载的、必然排在 app.css 之后,
所以这里赢在**优先级**:body[class] 特异性(0,1,1) > semi 的 body(0,0,1)。 */
@@ -983,21 +1003,26 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
.person-edit {
padding: 3px 10px; font-size: 13px; white-space: nowrap;
}
.person-more > summary {
list-style: none; cursor: pointer; padding: 4px 10px;
font-size: 16px; color: var(--muted); border-radius: 8px;
.person-more-btn {
padding: 3px 9px; font-size: 15px; line-height: 1.3;
color: var(--muted); white-space: nowrap;
}
.person-more > summary:hover { background: var(--line-2); color: var(--brand); }
.person-more > summary::-webkit-details-marker { display: none; }
.person-more > summary::after { content: ' ▾'; }
.person-more[open] > summary::after { content: ' ▴'; }
.person-more > summary:hover { color: var(--brand); }
.person-more[open] {
position: absolute; right: 8px; top: 8px; transform: none;
.person-more-btn:hover { color: var(--brand); }
/* ---------------- 名册卡片动作的**共享浮层**(v24 第 10 批 #6)----------------
「分组」「移出名册」两份表单全页只渲染一份(原来每卡各一份,1184 人 =
2369 个 form / 27234 个 input,密码管理器逐个扫描把页面拖死)。
这里给共享浮层一个定位容器,面板由 JS 贴合触发按钮摆放。 */
.roster-menus { position: fixed; inset: 0; z-index: 60; pointer-events: none; }
.roster-menus .rm-panel {
position: fixed; pointer-events: auto;
background: var(--surface); border: 1px solid var(--line);
border-radius: 10px; box-shadow: var(--shadow-l); z-index: 30;
min-width: 232px; padding-bottom: 4px;
border-radius: 10px; box-shadow: var(--shadow-l);
padding: 8px 0; min-width: 200px;
}
.roster-menus .rm-panel .person-move { padding: 0 10px; }
.roster-menus .rm-panel .person-move.is-self { padding: 0 12px; }
.roster-menus[hidden] { display: none; }
.person-move { display: flex; gap: 6px; padding: 8px 12px; flex-wrap: wrap; }
.person-move .select { flex: 1 1 auto; min-width: 0; padding: 6px 8px; font-size: 13px; }
.person-move .btn { flex: 0 0 auto; }
@@ -1569,8 +1594,38 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
.ck-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }
.ck-head b { font-size: 14px; }
.ck-svg { display: block; margin-top: 2px; }
/* 盘点面板:与「更正」并列在一个 <details> 里,中间给一条分隔线才分得清两件事 */
.led-recon { padding-bottom: 10px; border-bottom: 1px dashed var(--line-2); margin-bottom: 10px; }
/* ---- v24 第 10 批 #3:总金额图在最前(跨整行),下面才是逐条账目 ---- */
.ck-total { margin-bottom: 14px; }
.ck-total .ck-head b { font-size: 15px; }
/* 类别筛选条:图表一多就是一坨,把筛选权交给用户(而不是少画几条骗自己) */
.ck-filter { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 12px; }
.ck-chip {
padding: 4px 11px; border: 1px solid var(--line); border-radius: 999px;
background: var(--surface); color: var(--ink); font: inherit; font-size: 13px;
cursor: pointer;
}
.ck-chip:hover { border-color: var(--brand-2); }
.ck-chip.is-on { background: var(--brand); border-color: var(--brand); color: #fff; }
.ck-chip.is-debt:not(.is-on) { color: #1F7A4D; }
/* ---- v24 第 10 批 #3:每行的「盘点 / 修改」两个按钮 + 就地展开的面板 ---- */
.led-acts { display: flex; gap: 6px; align-items: center; white-space: nowrap; }
.led-panel {
/* 作为 <tr> 的兄弟插进来(见模板里的 JS):表格列宽会把表单切碎,格子外才排得开 */
display: block; background: #FDFBF7;
border: 1px solid var(--line); border-radius: var(--radius-s);
padding: 12px 14px; margin: 0 0 8px;
}
.led-panel .field { margin-bottom: 10px; }
.led-panel .field > label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; }
.led-panel .input, .led-panel .select { width: 100%; max-width: 360px; }
.led-panel .btn-row { margin-top: 4px; }
.led-panel[hidden] { display: none; }
/* 「修改」面板里嵌了选人组件,给它一个不夸张的最大宽度 */
.led-panel .pp { max-width: 420px; }
/* 盘点面板与「修改」不再同处一个 details,分隔线也就不需要了 */
.led-recon { padding-bottom: 0; border-bottom: 0; margin-bottom: 0; }
.led-form-h { font-size: 12.5px; color: var(--muted); font-weight: 500; margin: 0 0 8px; }
.led-hist { margin-top: 6px; }
.led-hist summary { cursor: pointer; }
@@ -2403,6 +2458,29 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
background: var(--brand); border-color: var(--brand); color: #fff; font-weight: 600;
}
/* ---- v24 第 10 批 #6:日程页头的「跳到某一天」按钮 ----
外观与 .btn 一致(它是 <label> 包着一个原生 date 输入,所以自己画外壳)。
原生日历指示器收掉:整个按钮都是可点的,多一个小图标反而抢视线。 */
.cal-jump { display: inline-flex; margin: 0; }
.cal-jump-btn {
display: inline-flex; align-items: center; gap: 6px;
cursor: pointer; white-space: nowrap;
}
.cal-jump-btn:hover { border-color: var(--brand-2); }
.cal-jump-txt { font-size: 13px; }
.cal-jump-date {
border: 0; background: transparent; font: inherit; font-size: 13px;
color: var(--ink); cursor: pointer; padding: 0; min-width: 118px;
font-variant-numeric: tabular-nums;
}
.cal-jump-date::-webkit-calendar-picker-indicator { cursor: pointer; opacity: .55; }
.cal-jump-date:focus { outline: none; }
@media (max-width: 640px) {
/* 窄屏:只留图标 + 日期,把「跳到」两个字收掉(不然页头一排按钮换行) */
.cal-jump-txt { display: none; }
.cal-jump-date { min-width: 104px; }
}
/* ==================== 周 / 天:按小时铺的时间网格 ==================== */
/* 结构:左边一条"小时刻度"沟槽(tg-gutter),右边 N 个日期列(tg-col)。
列高固定 = 24 小时 × 每小时 44px,日程块按 top/height 百分比**绝对定位**,
+243 -71
View File
@@ -786,21 +786,12 @@
})();
// ===== 名册行的「分组」按钮:选中即提交 =====
// 用事件委托:名册有几行就有几个这种表单,而且以后动态加进来的一样生效。
// ⚠️ 只是"选中即提交"的锦上添花 —— 表单里那个「保存」按钮一直都在,
// 没有 JS(或 JS 出错)时照样能改分组,不会变成"点了没反应"。
document.addEventListener('change', function (e) {
var inp = e.target;
if (!inp || inp.type !== 'radio' || inp.name !== 'group_id') { return; }
var form = inp.form;
if (!form || !form.classList || !form.classList.contains('pg-menu')) { return; }
// requestSubmit 才会跑浏览器的原生校验;老浏览器退回 submit()
if (typeof form.requestSubmit === 'function') {
form.requestSubmit();
} else {
form.submit();
}
});
// ⚠️ v24 第 10 批 #6 起,**这段委托搬进了 templates/people/index.php 的页内脚本**。
// 原因:分组表单不再是"每卡一份"的内联 <form>,而是全页**唯一一份**共享浮层
// (1184 人的名册原来有 2369 个 form / 27234 个 input,密码管理器逐个扫描
// 会把页面拖死 —— 用户报的"和 Bitwarden 冲突"就是这个)。
// 表单是页内动态填值的,提交委托跟着它一起走,读起来才是一件事。
// 这里留一条注释而不是删干净,是为了下一个人不要又把委托加回来。
// ===== 点页面其它地方 / 按 Esc 就收起下拉菜单 =====
// 用户 2026-10-03 第 3 条:「菜单栏点击后面,应该自动关闭,不应该一直保持打开」。
@@ -905,8 +896,126 @@
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; }
if (typeof window.SemiUI === 'undefined' || !window.SemiUI.DatePicker) { return; }
if (!window.ReactDOM || !window.ReactDOM.createRoot) { return; }
var host = document.createElement('span');
host.className = 'dt-semi dt-semi-range';
// 插到这一对控件的**前面**(原生那对随后藏起来)
group.insertBefore(host, group.firstChild);
var fmt = 'yyyy-MM-dd HH:mm' + (secs ? ':ss' : '');
function onRange(dates, strs) {
var arr = Array.isArray(strs) ? strs : [strs];
writeDT(a, arr[0] || '');
writeDT(b, arr[1] || '');
}
// 同上:先 unmount 再 createRoot,否则 defaultValue 改了也不生效
var rroot = null;
function mountRange() {
if (rroot) { rroot.unmount(); rroot = null; }
host.innerHTML = '';
var q0 = parseDT(readDT(a)), q1 = parseDT(readDT(b));
rroot = ReactDOM.createRoot(host);
rroot.render(React.createElement(SemiUI.DatePicker, {
type: 'dateTimeRange',
format: fmt,
showClear: true,
defaultValue: (q0 || q1) ? [q0 || q1, q1 || q0] : undefined,
placeholder: ['开始时间', '结束时间'],
position: window.matchMedia('(max-width: 640px)').matches ? 'bottom' : 'bottomLeft',
onChange: onRange
}));
}
try {
mountRange();
} catch (e) {
if (window.console) { console.warn('Semi 范围选择器挂载失败,留在原生控件:', e && e.message); }
host.parentNode.removeChild(host);
return;
}
group.__fbRemount = mountRange;
a.style.display = 'none';
b.style.display = 'none';
// ⚠️ 范围里的两端同样要摘 required(隐藏的 required 会让整个表单提交不了)
guardRequired(a);
guardRequired(b);
group.setAttribute('data-semi-range-done', '1');
group.classList.add('is-semi-range');
}
function upgrade(box) {
if (box.getAttribute('data-semi-done')) { return; }
// 范围对的成员由 upgradeRange 统一处理(一个控件管两端,用户第 7 条)
if (box.classList.contains('dt-part')) { return; }
if (typeof window.SemiUI === 'undefined' || !window.SemiUI.DatePicker) { return; }
if (!window.ReactDOM || !window.ReactDOM.createRoot) { return; }
var cfg = {};
@@ -916,81 +1025,85 @@
if (!host || !native) { return; }
var fmt = 'yyyy-MM-dd HH:mm' + (cfg.seconds === false ? '' : ':ss');
var init = parseDT(
part(native, '.dt-date') + ' ' +
(part(native, '.dt-hour') || '00') + ':' +
(part(native, '.dt-minute') || '00') + ':' +
(part(native, '.dt-second') || '00')
);
var init = parseDT(readDT(box));
// Semi 的 onChange → 写回原生四字段(POST 契约),并派 change 事件给可能的监听者
function writeBack(date, 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');
}
}
function writeBack(date, str) { writeDT(box, str); }
try {
ReactDOM.createRoot(host).render(React.createElement(SemiUI.DatePicker, {
// ⚠️ 重挂必须**先 unmount 再 createRoot**,不能对同一个 root 再 render:
// DatePicker 是非受控组件(我们用 defaultValue),React 18 复用旧 root 时
// 新传的 defaultValue **不会**改变已显示的值 —— 于是 fbSetDT 改了原生字段、
// Semi 还显示旧值("点了 15:00 控件里还写 09:00"的真实成因)。
var root = null;
function mount() {
if (root) { root.unmount(); root = null; }
host.innerHTML = '';
root = ReactDOM.createRoot(host);
root.render(React.createElement(SemiUI.DatePicker, {
type: 'dateTime',
format: fmt,
showClear: true,
defaultValue: init,
defaultValue: parseDT(readDT(box)),
placeholder: '选择日期时间',
position: window.matchMedia('(max-width: 640px)').matches ? 'bottom' : 'bottomLeft',
onChange: writeBack
}));
}
try {
mount();
} catch (e) {
if (window.console) { console.warn('Semi DatePicker 挂载失败,留在原生控件:', e && e.message); }
return;
}
// 页内 JS 可能会**程序化**改这个字段(如日程页「点日历空白处」预填开始时间)——
// 原生值改了 Semi 却还显示旧值,两边就不一致了。所以把重挂函数挂到元素上,
// 供 window.fbSetDT() 调用(见文件末尾)。
box.__fbRemount = mount;
box.classList.add('is-semi');
native.style.display = 'none';
if (init) { box.classList.add('has-value'); }
// required 不能留在 display:none 的输入上(Chrome 会拦提交却不显示提示),
// 改成提交前自己查一遍。
var req = native.querySelector('.dt-date[required]');
if (req) {
req.removeAttribute('required');
box.setAttribute('data-required', '1');
var form = box.closest('form');
if (form && !form.getAttribute('data-dt-guard')) {
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'); });
var inp = bad[0].querySelector('.dt-semi input');
if (inp) { inp.focus(); }
}
});
}
}
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'); });
var inp = bad[0].querySelector('.dt-semi input');
if (inp) { inp.focus(); }
}
});
}
var kicked = false;
function boot() {
if (kicked) { return; }
@@ -1000,9 +1113,12 @@
.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();
watchNewMonths();
watchNewControls();
})
.catch(function (err) {
// 资源拉不动就留在原生控件上 —— 渐进增强的兜底,不弹任何打扰
@@ -1054,18 +1170,74 @@
Array.prototype.forEach.call(document.querySelectorAll('input[type="month"]'), upgradeMonth);
}
function watchNewMonths() {
/**
* 动态新增的控件也要升级。
*
* 三种会"事后长出来"的:
* · 工作经历「+ 再加一段」→ 新的 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 });
}
/**
* 对外接口:程序化设置某个日期时间字段,并**同步 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); }