时间框跳段 + 弹窗不再脏地址栏 + 统计脚本做成后台开关
时间框(flatpickr allowInput 的手输体验)
- flatpickr 把 <input type=datetime-local> 就地改成 type=text,原生分段编辑器被替换掉;
allowInput 又是「整串解析」语义:实测 parseDate('2026') 只出年份、'202666' 才出月份,
于是「年份很长才跳下一位」;'20260606' 还会被静默错解成 2030-12-06 且不报错。
- 新增 static/js/datetime-input.js:自定义 parseDate(纯数字只收 4/6/8/12/14 位,
歧义与非法日期一律拒)+ 输入引导(段满即补分隔符,敲完 4 位年份立刻跳月份位)。
按标准格式不做自动补零、不前滚;越界值原样保留交报错,不静默改成合法值。
- 三处页面(编辑/照片/专题)共用 attachPicker,配置与原先一致,模板净减 5 行。
弹窗
- window.prompt 会把提示文本与默认值写进地址栏并留进历史(confirm/alert 无此问题)。
- 新增 static/js/prompt-box.js 替代:原生 <dialog>.showModal(),遮罩/Esc/焦点齐全,
旧浏览器降级成页面顶部一条。已换 report.html 复制链接兜底与 edit.html 活动改名。
- 门禁 no_prompt_test.go:静态扫模板禁止 prompt(,并钉住 JS 与 CSS 都在。
统计脚本
- 原来只能改 conf/app.ini(要重启),现在后台「站点设置」可开关 + 填写,默认关闭。
- cleanTrackScript 挡住注入面:开闭 <script> 标签数不配平(提前闭合会让页面散架)、
src 的 javascript:/data: 伪协议、超长截断。注意不能用「出现 </script 就拒」当规则——
百度统计、Umami 贴的就是完整 HTML,正常用法会被挡死。
门禁
- datetime-input.test.js:34 条断言(node 直跑,无依赖)。
- TestCleanTrackScript:逐条边界 + 定义性不变量(任何输入都不得产出含 </script 的非空结果)。
- 三项均做过变异验证,改坏实现都会被对应测试抓到。
实测:真实站点逐键 2→20→202→2026-→2026-06-→2026-06-06T;
统计脚本六个场景(默认关/只填不开/开+URL/后台与登录页不挂/危险输入/关掉恢复)全部符合预期。
全量 internal/web 204 例 0 skip,其余包全绿,gofmt 干净。
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
/* datetime-input:修掉 flatpickr allowInput 的「整串解析」毛病。
|
||||
*
|
||||
* 背景(实测 flatpickr 4.6.13,见 memory/2026-10-08.md):
|
||||
* 1. flatpickr 会把 <input type="datetime-local"> 就地改成 type="text"
|
||||
* (altInput 默认 false、不新建节点),原生那套分段编辑器被整个替换掉,
|
||||
* 所以「敲满一段自动跳下一段」这个行为根本不存在。
|
||||
* 2. allowInput 的语义是「拿到整串再 parseDate」,不是「逐段消费」。实测:
|
||||
* parseDate('2026') → 2026 年 1 月
|
||||
* parseDate('202666') → 2026 年 6 月 ← 用户要打到这个长度才「看到月份」
|
||||
* parseDate('20260606') → 2030 年 12 月 ← 静默错解,且不报错
|
||||
* 年份得吃满 4 位才被认,之后月份位还要再吃 2 位 ⇒ 正是「年份很长才跳下一位」。
|
||||
*
|
||||
* 这里做两件事:
|
||||
* parseDateStrict 宽松但**无歧义**的解析器:接受 2026/6/6、2026-6-6、20260606、
|
||||
* 2026 等写法;解析不出来就是 null,绝不猜。
|
||||
* attach 按键引导:年份满 4 位自动补分隔符并把光标推到月份段,
|
||||
* 月/日/时/分满 2 位同理,让手输 behave 像原生分段。
|
||||
*
|
||||
* 产出格式恒为 'Y-m-dTH:i'(即 2026-06-06T09:30),与服务端
|
||||
* handlers_edit.go / handlers_topics.go 里的 "2006-01-02T15:04" 同一形状。
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var DATE_FMT = 'Y-m-dTH:i';
|
||||
|
||||
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 位年份光标就已经在月份位(这正是原版做不到的那件事)。
|
||||
*
|
||||
* 不做的事(有意为之):
|
||||
* - **不自动补零、不做前滚**:月就按 06 敲,敲 '6' 不会变成 '06'。
|
||||
* 原生分段也是这样:1 月要敲 01。少一套猜测逻辑,行为完全可预测。
|
||||
* - **不静默改值**:月份敲 '13'、日敲 '45' 一律原样保留,交给解析器报错。
|
||||
* 把 13 月悄悄改成 1 月比报错坏得多——用户会以为敲对了。
|
||||
*/
|
||||
var SEG_LEN = [4, 2, 2, 2, 2]; // 年月日时分
|
||||
var SEP_AFTER = ['-', '-', 'T', ':', ''];
|
||||
|
||||
// 各段填满后的合法范围(只判能明确非法的:月 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();
|
||||
}
|
||||
|
||||
function attach(el, opts) {
|
||||
opts = opts || {};
|
||||
|
||||
/* 按位切分并排出标准版式。返回 null 表示「用户敲的这段已越界,原样保留」。
|
||||
* 年份固定 4 位;月/日/时/分固定 2 位 —— 不猜、不前滚、不自动补零。
|
||||
*
|
||||
* 入参 raw 是当前框里的值,**可以带我们自己补的分隔符**。这里按位取数时
|
||||
* 必须跳过分隔符(用户敲到 '2026-0' 时,数字是 20260 而不是 2020),
|
||||
* 否则位次会整体错位一格——这是实测踩出来的。 */
|
||||
function layout(raw) {
|
||||
// 只保留数字。必须跳过分隔符:用户敲到 '2026-0' 时数字是 20260 而不是 2020,
|
||||
// 否则位次整体错位一格 —— 这是实测踩出来的。
|
||||
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 > 14) return null;
|
||||
var out = '', pos = 0, year = 0, mon = 0;
|
||||
for (var i = 0; i < SEG_LEN.length; i++) {
|
||||
var want = SEG_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 = SEP_AFTER[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 };
|
||||
}
|
||||
|
||||
/* input 事件统一排版:按键、粘贴、退格三个入口都会走到这里,比只挂 keydown 周全。
|
||||
*
|
||||
* 为什么要「延后一帧」挪光标:flatpickr 自己也监听 input/setDate,
|
||||
* 会在我们之后改写 selectionStart。我们在同步阶段设的位置会被它挪走,
|
||||
* 于是下一次按键就插到分隔符前面(实测出现 '2026-6' 这种月份位错位)。
|
||||
* 所以同步阶段只改 value,把光标用微任务补回去——那时 flatpickr 已经改完了。
|
||||
*
|
||||
* 交接判据是「值里除我们自己补的以外还有没有别的分隔符」:
|
||||
* 一旦用户亲手敲了 / 或 :,立刻彻底放手(否则会出现 '2026-/6/6' 这种
|
||||
* 我们补的 '-' 和用户的 '/' 混排的乱串),改由 fromSeparated 解他的写法。 */
|
||||
/* 分隔符是否全是标准套(- 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;
|
||||
}
|
||||
|
||||
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 = '';
|
||||
}
|
||||
|
||||
/* 原始输入里,紧跟第 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) {
|
||||
// 找到第 n 位数字,看它后面紧跟的是不是分隔符
|
||||
var nx = raw.charAt(k + 1);
|
||||
if (nx && (nx === '-' || nx === 'T' || nx === ':')) return nx;
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function applyLayout(cur) {
|
||||
var r = layout(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-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) {
|
||||
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) +
|
||||
'T' + pad2(d.getHours()) + ':' + pad2(d.getMinutes());
|
||||
}
|
||||
el._rondFormatOut = formatOut;
|
||||
}
|
||||
|
||||
function init(selector, opts) {
|
||||
if (!global.flatpickr) return;
|
||||
var els = typeof selector === 'string' ? document.querySelectorAll(selector) : selector;
|
||||
Array.prototype.forEach.call(els, function (el) {
|
||||
attachPicker(el, opts);
|
||||
});
|
||||
}
|
||||
|
||||
/* 三处页面(编辑页 / 照片页 / 专题页)共用的挂载入口。
|
||||
配置刻意与原先完全一致,只多了 parseDate 与输入引导两件事:
|
||||
parseDate 修掉 allowInput 的「整串解析」与静默错解
|
||||
attach 让手输 behave 像原生分段(敲满 4 位年份就跳到月份位)
|
||||
提交格式仍是 'Y-m-dTH:i',服务端那条 "2006-01-02T15:04" 解析不受影响。 */
|
||||
function attachPicker(el, opts) {
|
||||
if (!global.flatpickr || el._rondPickerReady) return null;
|
||||
el._rondPickerReady = true;
|
||||
var inst = global.flatpickr(el, {
|
||||
enableTime: true,
|
||||
time_24hr: true,
|
||||
allowInput: true,
|
||||
// 补录靠回忆,没人记得 12:59:47——5 分钟步进贴合「大概那个点」的精度
|
||||
minuteIncrement: 5,
|
||||
dateFormat: DATE_FMT,
|
||||
parseDate: parseDateStrict
|
||||
});
|
||||
attach(el, opts);
|
||||
return inst;
|
||||
}
|
||||
|
||||
global.rondDatetime = {
|
||||
DATE_FMT: DATE_FMT,
|
||||
parseDate: parseDateStrict,
|
||||
attach: attach,
|
||||
attachPicker: attachPicker,
|
||||
init: init,
|
||||
pad2: pad2
|
||||
};
|
||||
})(window);
|
||||
@@ -0,0 +1,85 @@
|
||||
/* parseDateStrict 的单元测试(node 直接跑,无依赖)。
|
||||
* 用法:node internal/web/static/js/datetime-input.test.js
|
||||
* 这里只测解析,不碰 DOM —— 解析是整个修复的核心,边界必须钉死。
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, 'datetime-input.js'), 'utf8');
|
||||
const sandbox = { window: {} };
|
||||
sandbox.window.document = undefined;
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(src, sandbox);
|
||||
const api = sandbox.window.rondDatetime;
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function eq(got, want, label) {
|
||||
const g = JSON.stringify(got), w = JSON.stringify(want);
|
||||
if (g === w) { pass++; console.log(' ok ' + label); }
|
||||
else { fail++; console.log(' FAIL ' + label + '\n got ' + g + '\n want ' + w); }
|
||||
}
|
||||
function parse(s) {
|
||||
const d = api.parseDate(s);
|
||||
if (d === false || d === null || d === undefined) return null;
|
||||
if (isNaN(d.getTime())) return 'INVALID_DATE';
|
||||
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(d.getDate()).padStart(2, '0') + 'T' + String(d.getHours()).padStart(2, '0') +
|
||||
':' + String(d.getMinutes()).padStart(2, '0');
|
||||
}
|
||||
|
||||
console.log('=== 必须接受的写法 ===');
|
||||
eq(parse('2026'), '2026-01-01T00:00', '2026(只给年)');
|
||||
eq(parse('2026/6/6'), '2026-06-06T00:00', '2026/6/6 斜杠');
|
||||
eq(parse('2026-6-6'), '2026-06-06T00:00', '2026-6-6 单数字月日');
|
||||
eq(parse('2026-06-06'), '2026-06-06T00:00', '2026-06-06 完整');
|
||||
eq(parse('20260606'), '2026-06-06T00:00', '20260606 八位纯数字');
|
||||
eq(parse('2026-06-06T09:30'),'2026-06-06T09:30', '标准格式');
|
||||
eq(parse('2026/6/6 09:30'), '2026-06-06T09:30', '斜杠+空格+时间');
|
||||
eq(parse('2026-06-06 09:30'),'2026-06-06T09:30', '横杠+空格+时间');
|
||||
eq(parse('2026-6-6 9:30'), '2026-06-06T09:30', '时间也用单数字');
|
||||
eq(parse('202606'), '2026-06-01T00:00', '202606 六位(年月)');
|
||||
eq(parse('2026-06'), '2026-06-01T00:00', '2026-06 年月');
|
||||
eq(parse(' 2026/6/6 '), '2026-06-06T00:00', '前后空白');
|
||||
eq(parse('2026.6.6'), '2026-06-06T00:00', '点号分隔');
|
||||
eq(parse('20260606T0930'), '2026-06-06T09:30', '十二位纯数字');
|
||||
eq(parse('202606060930'), '2026-06-06T09:30', '十二位纯数字无 T');
|
||||
|
||||
console.log('\n=== 必须拒绝的脏输入(flatpickr 原版会静默错解) ===');
|
||||
eq(parse('202606'), '2026-06-01T00:00', '202606 按位取仍是合法年月(歧义但唯一解)');
|
||||
eq(parse('2026060630'), null, '2026060630 十位:日=30 但月推断不了 → 拒');
|
||||
eq(parse('2026/13/6'), null, '月 13 非法');
|
||||
eq(parse('2026/2/30'), null, '2 月 30 日不存在');
|
||||
eq(parse('2026/4/31'), null, '4 月 31 日不存在');
|
||||
eq(parse('2026/00/6'), null, '月 0');
|
||||
eq(parse('2026/6/0'), null, '日 0');
|
||||
eq(parse('2026666'), null, '2026666 七位:原版解成 2031-06 → 必须拒');
|
||||
eq(parse('20260606456'), null, '超长脏串');
|
||||
eq(parse('abc'), null, '非数字');
|
||||
eq(parse(''), null, '空串');
|
||||
eq(parse(' '), null, '纯空白');
|
||||
eq(parse('2026-6-6x'), null, '尾部残留字符');
|
||||
eq(parse('2026年6月6日'), null, '中文写法(服务端不收)');
|
||||
eq(parse('99'), null, '两位数(补录也只可能是四位年)');
|
||||
eq(parse('2026-6-6T25:00'), null, '小时 25 非法');
|
||||
eq(parse('2026-6-6T09:60'), null, '分钟 60 非法');
|
||||
eq(parse('2026/6/6/9'), null, '多一段');
|
||||
|
||||
console.log('\n=== round-trip:解析结果必须能原样喂回 formatDate ===');
|
||||
(function () {
|
||||
const cases = ['2026-06-06T09:30', '2026-01-01T00:00', '2026-12-31T23:55'];
|
||||
let bad = [];
|
||||
for (const c of cases) {
|
||||
const d = api.parseDate(c);
|
||||
if (!d) { bad.push(c + ' 解析失败'); continue; }
|
||||
const out = d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' +
|
||||
String(d.getDate()).padStart(2,'0') + 'T' + String(d.getHours()).padStart(2,'0') +
|
||||
':' + String(d.getMinutes()).padStart(2,'0');
|
||||
if (out !== c) bad.push(c + ' → ' + out);
|
||||
}
|
||||
eq(bad, [], 'round-trip 一致');
|
||||
})();
|
||||
|
||||
console.log('\n=== 汇总 ===');
|
||||
console.log(' pass=' + pass + ' fail=' + fail);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,146 @@
|
||||
/* rondPrompt:替代 window.prompt 的轻量输入弹层。
|
||||
*
|
||||
* 为什么必须换掉 prompt():它会把提示文本与默认值写进地址栏
|
||||
* (Chrome 拼成 `当前页?新的活动名称=吃饭` 这类 URL 并留进历史),
|
||||
* 刷新即丢、分享出去还带着原文。confirm / alert 不污染地址栏,只有 prompt 有这毛病。
|
||||
*
|
||||
* 能力对齐原生 prompt:返回 Promise<string|null>,回车确认、Esc 取消、点遮罩取消。
|
||||
* 额外做了两件 prompt 给不了的:只读模式下直接把内容放进一个可全选的框(复制链接用),
|
||||
* 以及读值时可要求「非空」(改名这种必填的场景直接少一层判空)。
|
||||
*
|
||||
* 降级:浏览器不支持 <dialog> 时,弹层挂到页面顶部一条,同样不碰地址栏。
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
function supportsDialog() {
|
||||
var d = document.createElement('dialog');
|
||||
// 无头 / 老浏览器里 showModal 可能不存在,只看 typeof
|
||||
return typeof d.showModal === 'function';
|
||||
}
|
||||
|
||||
function buildFallback(title, value, opts) {
|
||||
var box = document.createElement('div');
|
||||
box.className = 'prompt-fallback';
|
||||
var lab = document.createElement('label');
|
||||
lab.textContent = title;
|
||||
lab.style.fontSize = '13px';
|
||||
var input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = value || '';
|
||||
var ok = document.createElement('button');
|
||||
ok.type = 'button';
|
||||
ok.className = 'btn primary';
|
||||
ok.textContent = '确定';
|
||||
var cancel = document.createElement('button');
|
||||
cancel.type = 'button';
|
||||
cancel.className = 'btn';
|
||||
cancel.textContent = '取消';
|
||||
var done = false;
|
||||
function finish(v) {
|
||||
if (done) return;
|
||||
done = true;
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
box.remove();
|
||||
resolve(v);
|
||||
}
|
||||
function onKey(e) {
|
||||
if (e.key === 'Escape') { e.preventDefault(); finish(null); }
|
||||
}
|
||||
ok.addEventListener('click', function () { finish(input.value); });
|
||||
cancel.addEventListener('click', function () { finish(null); });
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
lab.appendChild(document.createElement('br'));
|
||||
lab.appendChild(input);
|
||||
var acts = document.createElement('div');
|
||||
acts.style.cssText = 'display:flex;gap:8px';
|
||||
acts.appendChild(cancel);
|
||||
acts.appendChild(ok);
|
||||
lab.appendChild(acts);
|
||||
box.appendChild(lab);
|
||||
document.body.appendChild(box);
|
||||
input.focus();
|
||||
input.select();
|
||||
var resolve;
|
||||
var p = new Promise(function (r) { resolve = r; });
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹一个输入框。
|
||||
* @param {string} title 标题(原文)
|
||||
* @param {string} [value] 初始值
|
||||
* @param {object} [opts] { readonly: 只读展示(复制链接), required: 取消时忽略空值 }
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
function ask(title, value, opts) {
|
||||
opts = opts || {};
|
||||
if (!supportsDialog()) return buildFallback(title, value, opts);
|
||||
|
||||
return new Promise(function (resolve) {
|
||||
var dlg = document.createElement('dialog');
|
||||
dlg.className = 'prompt-box';
|
||||
|
||||
var body = document.createElement('div');
|
||||
body.className = 'pb-body';
|
||||
var h = document.createElement('div');
|
||||
h.className = 'pb-title';
|
||||
h.textContent = title;
|
||||
var input = document.createElement('input');
|
||||
input.className = 'pb-input';
|
||||
input.type = 'text';
|
||||
input.value = value == null ? '' : String(value);
|
||||
if (opts.readonly) input.readOnly = true;
|
||||
body.appendChild(h);
|
||||
body.appendChild(input);
|
||||
|
||||
var acts = document.createElement('div');
|
||||
acts.className = 'pb-acts';
|
||||
var cancel = document.createElement('button');
|
||||
cancel.type = 'button';
|
||||
cancel.className = 'btn';
|
||||
cancel.textContent = '取消';
|
||||
var ok = document.createElement('button');
|
||||
ok.type = 'button';
|
||||
ok.className = 'btn primary';
|
||||
ok.textContent = '确定';
|
||||
acts.appendChild(cancel);
|
||||
acts.appendChild(ok);
|
||||
dlg.appendChild(body);
|
||||
dlg.appendChild(acts);
|
||||
|
||||
var settled = false;
|
||||
function finish(v) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
dlg.remove();
|
||||
resolve(v);
|
||||
}
|
||||
function submit() {
|
||||
var v = input.value;
|
||||
// required:空值视作取消(改名这种必填的场景,少一层判空)
|
||||
if (opts.required && !v.trim()) return;
|
||||
finish(opts.readonly ? v : v);
|
||||
}
|
||||
cancel.addEventListener('click', function () { finish(null); });
|
||||
ok.addEventListener('click', submit);
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); submit(); }
|
||||
});
|
||||
// 点遮罩(dialog 元素本身)= 取消。inner 区域点击要挡住,否则点标题也算取消。
|
||||
dlg.addEventListener('click', function (e) {
|
||||
if (e.target === dlg) finish(null);
|
||||
});
|
||||
// Esc 触发 cancel 事件(<dialog> 原生行为),统一在这收口
|
||||
dlg.addEventListener('cancel', function (e) { e.preventDefault(); finish(null); });
|
||||
|
||||
document.body.appendChild(dlg);
|
||||
dlg.showModal();
|
||||
input.focus();
|
||||
// 只读场景(复制链接)全选好让用户直接 Ctrl+C;可编辑场景选中原值便于改写
|
||||
input.select();
|
||||
});
|
||||
}
|
||||
|
||||
global.rondPrompt = { ask: ask };
|
||||
})(window);
|
||||
Reference in New Issue
Block a user