Files
tamakyi cd69efe8f1 日历改中文、修掉头部重叠;统计页那两张体量悬殊的卡不再并排
用户反馈:日历「空间显示有点异常」且是英文、和项目割裂;类型分布与一天中的分布仍是一左一右、
右边下面一大片空白。两件事的根因都不是表面看到的那个。

一、日历英文
- `flatpickr.localize(l10ns.zh)` 原先只写在三个后台页的 whenReady 里;上一批把 flatpickr 放到
  公共页(筛选区)之后就漏了 ⇒ 日历全是 Sun/Mon/January…
- 修法:移到 datetime-input.js 的 attachPicker 里(localizeOnce(),全局只调一次),
  三个后台页那三行重复调用删掉。

二、日历头部重叠(就是用户说的「空间异常」)
- 月份下拉被撑满整行、压在年份输入上。根因是**站点全局的 input/select 样式扫进了日历内部**:
  那条规则带 width:100%、内边距、边框与 16px 控件字号,而日历内部的
  `input.numInput`(type=number)与 `select.flatpickr-monthDropdown-months` 都躲不开。
- ⚠️ 第一版修法(在后面补一条 `.flatpickr-calendar input{...}` 对冲)**是错的**:全局选择器带
  10 个 :not([type=…])、特异性 (0,10,1),普通对冲压不过,实测年份框仍是站点样式。
  正确做法是在**全局规则里排除** `input…:not(.numInput)` / `select:not(.flatpickr-monthDropdown-months)`,
  `input:focus` 的焦点环同理(否则年份框上套一圈主色光圈)。
- 实测:星期「周日 周一 …」、月份「十月」、月份与年份 overlap=0、无 JS 报错。

三、统计页排版
- `align-items: start` 只消掉了卡内空白,消不掉并排本身的空:类型分布 1411px 与
  一天中的分布 256px 并排,右侧下面必然空一片。改成单列(`<div class="grid">`),
  两张卡各占整行 1080px —— 24 根柱终于有宽度可用,列表也像「城市排行」那样横铺。

回归:./scripts/verify.sh PASS=349 FAIL=0 SKIP=0;JS 单测 45/45;移动端 390 溢出全 0;
桌面 1440 与改前一致(−15 是滚动条宽)。
2026-10-10 09:45:32 +08:00

431 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* datetime-input:把 <input type="date" / type="datetime-local"> 换成**我们完全掌控**的
* 文本输入 + 手输引导。
*
* 为什么不用原生控件(两次踩过,都是实测):
* 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;
}
/* 中文文案只在这里设一次。以前是各页的 whenReady 里自己调 flatpickr.localize(),
公共页(筛选区)原先根本不加载 flatpickr,等它也开始加载之后就漏了 ——
日历是英文的(Sun/Mon/January…),跟站点的中文界面明显割裂。 */
var l10nDone = false;
function localizeOnce() {
if (l10nDone || !global.flatpickr) return;
l10nDone = true;
if (global.flatpickr.l10ns && global.flatpickr.l10ns.zh) {
global.flatpickr.localize(global.flatpickr.l10ns.zh);
}
}
/* 三处页面(编辑页 / 照片页 / 专题页)共用的挂载入口。
配置刻意与原先完全一致,只多了 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; }
localizeOnce();
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);