时间框跳段 + 弹窗不再脏地址栏 + 统计脚本做成后台开关
时间框(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,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);
|
||||
Reference in New Issue
Block a user