Files
rond-with-you/internal/web/static/js/prompt-box.js
T
tamakyi 4015ddf373 时间框跳段 + 弹窗不再脏地址栏 + 统计脚本做成后台开关
时间框(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 干净。
2026-10-08 18:27:59 +08:00

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);