/* datetime-input:把 换成**我们完全掌控**的 * 文本输入 + 手输引导。 * * 为什么不用原生控件(两次踩过,都是实测): * 1. flatpickr 会把 type=datetime-local 就地改成 type=text(altInput 默认 false、不新建节点), * 原生那套分段编辑器被整个替换掉;而 allowInput 的语义是「拿到整串再 parseDate」, * 实测 parseDate('2026') → 1 月、'202666' → 2026-06、'20260606' → 2030-12(静默错解)。 * 2. **原生 type=date 自己也不可靠**:Chrome 的年段允许超过 4 位数字(年份上限 275760), * 于是敲 20260606 会被切成 年=202606 + 月=06,框里显示 202606/06(zh-CN 用 / 分隔), * 而 el.value 是**空**=非法 ⇒ 筛选条件静默丢掉。2026-10-09 用户报的就是这条。 * 原生分段状态读不到、光标也设不了(setSelectionRange 对 date 类输入直接抛), * 所以「修原生」这条路走不通,只能换掉控件。 * * 于是这里做三件事: * parseDateStrict 宽松但**无歧义**的解析器:接受 2026/6/6、2026-6-6、20260606、2026 等, * 解析不出来就是 null,绝不猜(交给 flatpickr 的 parseDate 钩子)。 * attach 输入引导:年满 4 位自动补分隔符并把光标推到月份段,其余段满 2 位同理。 * attachAll **统一入口**:一次覆盖 type=datetime-local 与 type=date 两种输入。 * 2026-10-09 的事故根因就是「只挂了 datetime-local」,后来加的 type=date * 筛选框没人管 —— 以后加日期字段一律走 attachAll,别在页面里散着手写选择器。 * * 产出格式:datetime 恒为 'Y-m-dTH:i'(2026-06-06T09:30),只日期恒为 'Y-m-d'(2026-06-06), * 分别与服务端 handlers_edit.go 的 "2006-01-02T15:04" 和筛选区的 "2006-01-02" 同一形状。 */ (function (global) { 'use strict'; var DATE_FMT = 'Y-m-dTH:i'; var DAY_FMT = 'Y-m-d'; /* 两种版式。段长/分隔符/输出格式只差这几个数,算法完全共用。 */ var SPEC = { dt: { LEN: [4, 2, 2, 2, 2], SEP: ['-', '-', 'T', ':', ''], fmt: DATE_FMT }, date: { LEN: [4, 2, 2], SEP: ['-', '-', ''], fmt: DAY_FMT } }; function specFor(opts) { return (opts && opts.dateOnly) ? SPEC.date : SPEC.dt; } function pad2(n) { return n < 10 ? '0' + n : String(n); } function validYMD(y, mo, d) { if (y < 1000 || y > 9999) return false; if (mo < 1 || mo > 12) return false; if (d < 1 || d > 31) return false; // 月末天数要真判:2 月 30 日 / 4 月 31 日这类「格式对但日期不存在」不能放过 var probe = new Date(y, mo - 1, d); return probe.getFullYear() === y && probe.getMonth() === mo - 1 && probe.getDate() === d; } function validHM(h, mi) { return h >= 0 && h <= 23 && mi >= 0 && mi <= 59; } // 把「已经确定」的年/月/日/时/分拼成合法日期;缺时间则补 00:00 function build(y, mo, d, h, mi, hasTime) { if (!validYMD(y, mo, d)) return null; if (hasTime && !validHM(h, mi)) return null; var dt = new Date(y, mo - 1, d, hasTime ? h : 0, hasTime ? mi : 0, 0, 0); return isNaN(dt.getTime()) ? null : dt; } /* 从纯数字串里按位取:严格按 [年4][月2][日2][时2][分2] 切。 * 长度只收 4/6/8/12/14 这几种能唯一解读的组合: * 5/7/9/10/11 位一律拒(如 '2026060630' 到底是「日=30」还是「时=06 分=30」说不清)。 * 这条规则专门用来干掉「20260606 被静默解成 2030-12-06」那类脏读。 */ function fromDigits(s) { if (!/^[0-9]+$/.test(s)) return null; var y, mo, d, h = 0, mi = 0, hasTime = false; switch (s.length) { case 4: y = +s; mo = 1; d = 1; break; case 6: y = +s.slice(0, 4); mo = +s.slice(4, 6); d = 1; break; case 8: y = +s.slice(0, 4); mo = +s.slice(4, 6); d = +s.slice(6, 8); break; case 12: y = +s.slice(0, 4); mo = +s.slice(4, 6); d = +s.slice(6, 8); h = +s.slice(8, 10); mi = +s.slice(10, 12); hasTime = true; break; case 14: y = +s.slice(0, 4); mo = +s.slice(4, 6); d = +s.slice(6, 8); h = +s.slice(8, 10); mi = +s.slice(10, 12); hasTime = true; break; default: return null; } return build(y, mo, d, h, mi, hasTime); } /* 带分隔符的写法:先把分隔符统一成 '- ',再按「T 或空格切出时间」两半, * 日期半边按 - 切、时间半边按 : 切。段数不足 3/2 时右侧补默认值(补录常见只写到月)。 * 任何多余分隔符或残留字符都判非法。 */ function fromSeparated(s) { // 年月日之间的分隔允许 / - . 或空白;日与时间之间允许 T 或空白或 - var norm = s.trim().replace(/[\/\.]/g, '-'); var timeSep = norm.search(/[T\s]/); var datePart, timePart; if (timeSep < 0) { datePart = norm; timePart = ''; } else { datePart = norm.slice(0, timeSep); timePart = norm.slice(timeSep).replace(/^[T\s]+/, ''); } // 日期半边还可能带 '-' 接时间(2026-06-06-09:30),补一次。 // 时间侧必须带冒号或够 4 位纯数字,否则 '2026/6/6/9' 这种多一段的会被误读成「日期+小时」 if (timePart === '') { var m = /^(\d{4}-\d{1,2}-\d{1,2})-(\d{1,2}:\d{1,2}|\d{4})$/.exec(datePart); if (m) { datePart = m[1]; timePart = m[2]; } } // 时间半边必须整段都是「时分」,允许 0930(无冒号)/ 9:30 / 9(只有小时) if (timePart !== '' && !/^\d{2}:?\d{2}$|^\d{1,2}:\d{1,2}$|^\d{1,2}$/.test(timePart)) return null; var tp; if (/^\d{4}$/.test(timePart)) tp = [timePart.slice(0, 2), timePart.slice(2, 4)]; else tp = timePart.split(':'); if (tp.length > 2) return null; // 日期半边可能是「无分隔符的纯数字」(20260606T0930 这种混写), // 那就按位取——但位数必须能唯一解读,否则交给 fromDigits 判非法 var y, mo, d; if (/^\d+$/.test(datePart)) { var bits = datePart.length; if (bits !== 4 && bits !== 6 && bits !== 8) return null; y = +datePart.slice(0, 4); mo = bits >= 6 ? +datePart.slice(4, 6) : 1; d = bits >= 8 ? +datePart.slice(6, 8) : 1; } else { var dp = datePart.split('-'); if (dp.length > 3) return null; if (!dp[0] || !/^\d{4}$/.test(dp[0])) return null; y = +dp[0]; mo = dp[1] ? dp[1] : '1'; d = dp[2] ? dp[2] : '1'; if (!/^\d{1,2}$/.test(mo) || !/^\d{1,2}$/.test(d)) return null; mo = +mo; d = +d; } if (timePart === '') return build(y, mo, d, 0, 0, false); var h = +tp[0]; var mi = tp[1] ? +tp[1] : 0; return build(y, mo, d, h, mi, true); } // flatpickr 的 parseDate 钩子:签名 (dateStr, format) -> Date | false // 必须返回 false 而不是 null(flatpickr 内部判的是 !e,null 也行,但统一 false 更清楚) function parseDateStrict(dateStr) { if (typeof dateStr !== 'string') return false; var s = dateStr.trim(); if (!s) return false; // 'today' 是 flatpickr 的默认约定,交给它自己走(返回 false 会让它回落到当前时间) if (s === 'today') return false; var d = /^[0-9]+$/.test(s) ? fromDigits(s) : fromSeparated(s); return d ? d : false; } /* ---------- 输入引导:让手输 behave 像原生分段 ---------- * * 严格分段格式:年 4 位、月/日/时/分各 2 位,一律补零,敲满一段立刻补分隔符并跳下一段。 * 于是「2026」+「06」+「06」+「09」+「30」会边敲边变成 * 2026-06-06T09:30,敲完 4 位年份光标就已经在月份位(这正是原版做不到的那件事)。 * 只日期版少掉时分两段,输出 2026-06-06。 * * 不做的事(有意为之): * - **不自动补零、不做前滚**:月就按 06 敲,敲 '6' 不会变成 '06'。 * 原生分段也是这样:1 月要敲 01。少一套猜测逻辑,行为完全可预测。 * - **不静默改值**:月份敲 '13'、日敲 '45' 一律原样保留,交给解析器报错。 * 把 13 月悄悄改成 1 月比报错坏得多——用户会以为敲对了。 */ // 各段填满后的合法范围(只判能明确非法的:月 13、日 32、时 24、分 60) var SEG_FULL_MAX = [ null, function (s) { return +s > 12; }, function (s, y, mo) { return +s > daysInMonth(y, mo); }, function (s) { return +s > 23; }, function (s) { return +s > 59; } ]; // 各段「还没填满时」的前缀上限:已经不可能合法就当场停手。 // 月 '1' 放行(可能是 10~12)、'2' 放行(20 不存在但等第 2 位更自然)、 // '3'~'9' 拦下(13~19 不存在),'0' 放行(01~09)。 var SEG_PREFIX_MAX = [ null, // 年固定 4 位 function (s) { return +s > 1; }, // 月:首位 >1 即不可能 function (s) { return +s > 3; }, // 日:首位 >3 即不可能 function (s) { return +s > 2; }, // 时:首位 >2 即不可能 function (s) { return +s > 5; } // 分:首位 >5 即不可能 ]; function daysInMonth(y, mo) { if (mo < 1 || mo > 12) return 31; return new Date(y, mo, 0).getDate(); } /* 原始输入里,紧跟第 n 位数字之后的那个分隔符是什么(没有就返回空)。 * 用来在「段未填满」时把用户已经敲好的分隔符原样带回来, * 避免把 '2026-6' 硬补零成 '2026-06'(用户 1 月就该敲 01,但 * 6 月可以只敲 6 —— 这两种都得能往下走)。 */ function sepAfterDigits(raw, n) { var seen = 0; for (var k = 0; k < raw.length; k++) { var ch = raw.charAt(k); if (ch >= '0' && ch <= '9') { seen++; if (seen === n) { var nx = raw.charAt(k + 1); if (nx && (nx === '-' || nx === 'T' || nx === ':')) return nx; return ''; } } } return ''; } /* 按位切分并排出标准版式。返回 null 表示「用户敲的这段已越界,原样保留」。 * 年份固定 4 位;月/日(时/分)固定 2 位 —— 不猜、不前滚、不自动补零。 * * 入参 raw 是当前框里的值,**可以带我们自己补的分隔符**。这里按位取数时 * 必须跳过分隔符(用户敲到 '2026-0' 时,数字是 20260 而不是 2020), * 否则位次会整体错位一格——这是实测踩出来的。 * * 导出成纯函数(layoutTo)是为了能在 node 里直接跑断言,不用起浏览器。 */ function layoutTo(spec, raw) { var chars = String(raw).split(''); var digits = ''; for (var k = 0; k < chars.length; k++) { var ch = chars[k]; if (ch >= '0' && ch <= '9') digits += ch; } if (!digits) return { value: '', caret: 0 }; if (digits.length > spec.LEN.reduce(function (a, b) { return a + b; }, 0)) return null; var out = '', pos = 0, year = 0, mon = 0; for (var i = 0; i < spec.LEN.length; i++) { var want = spec.LEN[i]; var have = digits.slice(pos, pos + want); var full = have.length === want; var check = full ? SEG_FULL_MAX[i] : SEG_PREFIX_MAX[i]; if (check && check(have, year, mon)) return null; // 本段还没填满 → 保持原样让用户继续敲。 // 若原始输入里这一段后面已经带了分隔符(用户手敲 '2026-6'), // 就把它带回来,光标停在分隔符之后,而不是硬补零。 if (!full) { var tailSep = sepAfterDigits(raw, pos + have.length); var val = out + have + tailSep; return { value: val, caret: val.length }; } // 填满 → 结算本段 if (i === 0) year = +have; if (i === 1) mon = +have; out += have; pos += want; var sep = spec.SEP[i] || ''; // 数字用完了:本段后面还有段要填就立刻补分隔符、把光标推到下一段起点。 // 这一步就是「敲完 4 位年份自动跳到月份位」的实现。 if (pos >= digits.length) { if (!sep) return { value: out, caret: out.length }; return { value: out + sep, caret: out.length + sep.length }; } out += sep; } return { value: out, caret: out.length }; } function attach(el, opts) { opts = opts || {}; if (el._rondAttached) return; el._rondAttached = true; var spec = specFor(opts); /* 先把控件换成 text。引导过程会往框里写 '2026-06-' 这种**半成品**, 而 type=date / datetime-local 对非法值一律置空(值当场消失), 只有 text 我们才拿得住。提交格式不变(date → Y-m-d、datetime → Y-m-dTH:i)。 */ if (el.type !== 'text') { el.type = 'text'; el.setAttribute('inputmode', 'numeric'); // 手机上直接给数字键盘 el.setAttribute('autocomplete', 'off'); if (!el.placeholder) el.placeholder = opts.dateOnly ? '2026-06-06' : '2026-06-06T09:30'; } /* 分隔符是否全是标准套(- T :)。'2026-6-6' 算(横杠是我们和用户通用的), * '2026/6/6' 不算(外来写法,交给 fromSeparated 原样解)。 */ function isStandardSep(v) { var seps = v.match(/[-T:/.\s]/g); if (!seps) return true; // 纯数字 for (var i = 0; i < seps.length; i++) { var c = seps[i]; if (c !== '-' && c !== 'T' && c !== ':') return false; } return true; } /* input 事件统一排版:按键、粘贴、退格三个入口都会走到这里,比只挂 keydown 周全。 * * 为什么要「延后一帧」挪光标:flatpickr 自己也监听 input/setDate, * 会在我们之后改写 selectionStart。我们在同步阶段设的位置会被它挪走, * 于是下一次按键就插到分隔符前面(实测出现 '2026-6' 这种月份位错位)。 * 所以同步阶段只改 value,把光标用微任务补回去——那时 flatpickr 已经改完了。 */ function onInput() { var cur = el.value; if (!cur) { el._rondLaidOut = ''; return; } // 纯数字(刚开始敲)⇒ 接管,按标准版式排 if (/^[0-9]+$/.test(cur)) { applyLayout(cur); return; } /* 带标准分隔符 ⇒ 仍接管。但**只在数字已按标准位数填满各段时**才重排, 否则用户手敲的 '2026-6-6' 会被强行补零成 '2026-06-06-'。 段还没填满就原样留着(他还在敲)。 */ if (isStandardSep(cur)) { applyLayout(cur); return; } /* 外来写法(/ . 空格)。只做一件最小修复:把「紧跟在分隔符后面、 与前一个叠起来」的那个分隔符压掉——'2026-' + '/' 会变成 '2026-', 双分隔符会让 fromSeparated 判非法。除此之外一个字符都不动, 保持用户原样。 */ var fixed = cur.replace(/([-T:])([/.])/g, '$2').replace(/([-T:])\1/g, '$1'); if (fixed !== cur) { el.value = fixed; placeCaret(fixed.length); } el._rondLaidOut = ''; } function applyLayout(cur) { var r = layoutTo(spec, cur); // 越界(月份敲成 66、日敲成 45):原样保留用户输入,交给解析器报错。 // 这里必须 return 且**不动 el.value**——el.value 一旦被我们改写却没同步 // _rondLaidOut,下一轮就会拿旧串去排,越排越乱。 if (!r) return; var val = r.value; el._rondLaidOut = val; if (val === cur) { placeCaret(r.caret); return; } el.value = val; placeCaret(r.caret); } /* 光标落到 r.caret(= 刚补的分隔符之后,也就是下一段起点)。 * 同步先设一次让本轮内就位,再用微任务补一次压过 flatpickr。 */ function placeCaret(pos) { if (pos == null) return; try { el.setSelectionRange(pos, pos); } catch (e) { return; } var apply = function () { if (document.activeElement !== el) return; if (el.selectionStart === pos && el.selectionEnd === pos) return; try { el.setSelectionRange(pos, pos); } catch (e) { /* 已失焦 */ } }; if (window.Promise) Promise.resolve().then(apply); else setTimeout(apply, 0); } el.addEventListener('input', onInput); /* blur 时把「敲了一半」的串收敛成能被解析的形状:2026 → 2026-01-01(只日期) * 或 2026-01-01T00:00(带时间)。不这么做的话用户只填了年份就点走, * flatpickr 拿到 '2026' 会按原版规则补成当月 1 号,但 '2026-' 这种带尾巴的残串会被判非法。 */ function onBlur() { var cur = el.value.trim(); if (!cur) return; var d = parseDateStrict(cur); if (!d) return; el.value = formatOut(d); } el.addEventListener('blur', onBlur); function formatOut(d) { var day = d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); if (opts.dateOnly) return day; return day + 'T' + pad2(d.getHours()) + ':' + pad2(d.getMinutes()); } el._rondFormatOut = formatOut; } /* 三处页面(编辑页 / 照片页 / 专题页)共用的挂载入口。 配置刻意与原先完全一致,只多了 parseDate 与输入引导两件事: parseDate 修掉 allowInput 的「整串解析」与静默错解 attach 让手输 behave 像原生分段(敲满 4 位年份就跳到月份位) 提交格式仍是 'Y-m-dTH:i',服务端那条 "2006-01-02T15:04" 解析不受影响。 */ function attachPicker(el, opts) { opts = opts || {}; var dateOnly = !!opts.dateOnly; if (!global.flatpickr) { attach(el, opts); return null; } if (!el._rondPickerReady) { el._rondPickerReady = true; global.flatpickr(el, { enableTime: !dateOnly, time_24hr: true, allowInput: true, // 补录靠回忆,没人记得 12:59:47——5 分钟步进贴合「大概那个点」的精度 minuteIncrement: 5, dateFormat: dateOnly ? DAY_FMT : DATE_FMT, parseDate: parseDateStrict }); } attach(el, opts); return el._rondPicker; } /* ---------- 统一入口 ---------- * 覆盖两种日期输入:带时间的走 flatpickr + 引导,只日期的走(有 flatpickr 就一并给日历, * 没有就只挂引导——公共页不加载 flatpickr,见 layout.html 的说明)。 * ⚠️ 以后再加日期字段一律走这里,别在页面里手写 querySelector('input[type=...]'): * 2026-10-09 的事故就是只挂了 datetime-local、新加的 type=date 没人管。 */ function attachAll(root, opts) { root = root || document; var els = root.querySelectorAll('input[type=datetime-local], input[type=date]'); Array.prototype.forEach.call(els, function (el) { var o = { dateOnly: el.type === 'date' }; if (global.flatpickr) attachPicker(el, o); else attach(el, o); }); return els.length; } function init(selector, opts) { var els = typeof selector === 'string' ? document.querySelectorAll(selector) : selector; Array.prototype.forEach.call(els, function (el) { attachPicker(el, opts); }); } global.rondDatetime = { DATE_FMT: DATE_FMT, DAY_FMT: DAY_FMT, SPEC: SPEC, layoutTo: layoutTo, parseDate: parseDateStrict, attach: attach, attachPicker: attachPicker, attachAll: attachAll, init: init, pad2: pad2 }; })(window);