时间框(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 干净。
146 lines
5.3 KiB
JavaScript
146 lines
5.3 KiB
JavaScript
/* 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); |