在 America/New_York 里,2026-03-08 02:30 是 1772955000。
2026-03-08 03:30 也是 1772955000。
同一个时区、同一天、相差一小时的两个时刻,转换成同一个 Unix 时间戳。不报错,不警告,批量表里两行看起来都是正常结果。
1. 只有恰好 13 位按毫秒算
工具介绍里承诺的那句原文:
自 1970-01-01 00:00:00 (UTC) 以来的秒数;13 位毫秒值会自动识别。粘贴日期(“2026-09-11 14:30”)则反向转换为时间戳。
判定就一行:
// 13-digit values are milliseconds; a bare number is seconds.
let sec = Number(line);
if (!Number.isFinite(sec) || sec < 0) return null;
if (/^\d{13}$/.test(line)) sec = sec / 1000;
return Math.abs(sec * 1000) > TS_MAX_MS ? null : sec;
/^\d{13}$/ 是恰好 13 位。少一位、多一位都不按毫秒算,而是直接当秒。
输入 位数 按什么算 结果
1760000000000 13 毫秒 2025-10-09T08:53:20.000Z
176000000000 12 秒 7547-03-22T00:53:20.000Z
17600000000 11 秒 2527-09-21T16:53:20.000Z
17600000000000 14 秒,超上界 — — —(拒绝)
0000000000000 13 毫秒 1970-01-01T00:00:00.000Z
第二行是这一节的问题所在。176000000000 是一个典型的毫秒值少写了一位(真实毫秒是 13 位),工具把它当秒处理,得到公元 7547 年。没有报错,表格里那行看着完全正常。
这不是位数判断难——Number.isInteger(sec) && sec >= 1e12 && sec < 1e13 就能覆盖到。位数规则的优势是零成本,代价是 12 位那行静默差 1000 倍。
最后一个值得单说:前导零照样算位数。0000000000000 是 13 个字符,匹配,按毫秒除以 1000 得到 0。日志系统里补零对齐的字段如果位数凑巧到 13 位,会被当成毫秒。
2. Number() 把什么都能收进来
位数判断之前先过 Number(line),而 Number 比你想的宽松:
输入 结果
1760000000 1760000000 正常
1760000000 1760000000 Number 自己 trim,正常
1e12 1000000000000 科学计数法 → 2001-09-09T01:46:40.000Z
0x10 16 十六进制 → 1970-01-01T00:00:16.000Z
1760000000.5 1760000000.5 小数秒,接受
1,760,000,000 NaN → null 千分位逗号 → 行显示 —
Infinity NaN → null
NaN NaN → null
0x10 变成 16 秒这一点最容易让人困惑:粘贴时你看到的是十六进制,输出里是 1970 年 1 月 1 日凌晨。
第 3 节说小数秒那一行有多别扭。
3. 小数秒让两列相差 500 毫秒
单行模式的输出分两行显示秒数和毫秒数:
{ label: 'Unix seconds', value: String(Math.round(sec)), ... },
{ label: 'Milliseconds', value: String(Math.round(sec * 1000)), ... },
两列各自 Math.round,各自独立取整。喂一个小数秒进去:
输入 1760000000.5
Unix 秒列 Math.round(sec) = 1760000001
毫秒列 Math.round(sec * 1000) = 1760000000500
两列自洽需要 = 1760000001000
差 -500 ms
1760000001 秒等于 1760000001000 毫秒,但工具显示的是 1760000000500。同一份输出里两个数字互相矛盾,差 500 毫秒。
原因在取整方向:Math.round(0.5) 向上,所以秒列取到 1760000001;而 sec * 1000 恰好是整数 1760000000500,不需要取整,小数部分原样保留。一个舍掉了小数、一个保住了小数。
批量模式更明显,因为一行的四列里同时有这两列:
输入 1760000000.5 Unix (s) 1760000001 Local Time ... UTC Time ...
表里看不出矛盾,只有拿两列回去相乘才看得出。
4. 上界 8.64e15 实际只挡 14 位以上的输入
源码注释写的是 Date 的可表示窗口:
// Date's representable window is +/-8.64e15 ms (~275760-01-01). Beyond
// it, new Date(ms) is "Invalid Date" and toISOString() throws
// RangeError - which is what a 19-digit nanosecond paste used to do.
const TS_MAX_MS = 8640000000000000;
这个注释说的是对的,也是这个常量存在的原因:挡住 19 位纳秒粘贴,否则 new Date(ms).toISOString() 会直接抛 RangeError 让整个计算中断。
但结合第 1 节的位数规则,这个上界实际能碰到的输入比注释暗示的范围小得多:
13 位最大值 9999999999999 → 按毫秒 ÷1000 → sec = 9999999999.999
→ sec × 1000 = 9999999999999 < 8.64e15 → 通过
14 位最小值 10000000000000 → 按秒,sec × 1000 = 1e16 > 8.64e15 → 拒绝
13 位的输入全部通过(除以 1000 之后再乘回去,恒等于原值,上限是 1e13),14 位及以上的输入全部拒绝(最小值 1e13 乘 1000 就已经 1e16)。
所以工具真实可达的上界不是公元 275760 年,而是 13 个 9:
9999999999999 → sec 9999999999.999 → 2286-11-20T17:46:39.999Z
99999999999999 → null,行显示 —
公元 2286 年,比注释里写的那个早了二十多万年。这不是缺陷——工具不可能也不该支持那么远——只是说明这个常量的实际作用域只有「14 位以上的输入」这一种情况。
反过来看好处:2^53 = 9007199254740992 大于 TS_MAX_MS = 8640000000000000,所以所有被接受的输入里 Math.round(sec * 1000) 都落在整数精确范围内,不会像第 4 篇里 1e15 + 1 那样静默丢精度。随机取 318 个 13 位毫秒值回代验证,失配 0 个。
5. 反向方向只认一种写法
反向转换靠一个正则:
const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{1,2}):(\d{2}))?$/;
接受的和拒绝的:
接受:
2026-09-11
2026-09-11 14:30
2026-09-11T14:30
2026-09-11 9:30 ← 小时允许 1 位
拒绝:
2026-09-11 14:30:00 有秒
2026-09-11T14:30:00Z 任何时区后缀
2026-09-11T14:30:00+08:00 任何时区后缀
2026-09-11 14:30:00.5 有小数秒
2026-09-11 14:30Z 末尾任何字符
2026/09/11 斜杠分隔
2026-9-11 14:30 月只 1 位
2026-09-1 14:30 日只 1 位
2026-09-11 14:30 两个空格
2026-09-11<tab>14:30 tab 分隔
分隔符只接受恰好一个空格或 T,分钟必须恰好两位,不接受秒、不接受时区后缀。
关键点在「拒绝」时发生什么。不匹配 DATE_RE 不是报错,是静默落到时间戳分支:2026/09/11 交给 Number() 得到 NaN,tsToSec 返回 null,批量表里那一行显示三个破折号。
输入 Unix (秒) 本地时间 UTC 时间
1760000000 1760000000 ... ...
2026/09/11 14:30 — — —
日志里两种格式混在一起时,这一行不会喊停,它只是安静地变成三个破折号。要找到它得自己扫一遍表里的 —。
6. 月、日、小时越界不报错,往前滚
匹配上 DATE_RE 之后交给 Date 的多参数构造函数:
const ms = new Date(Number(y), Number(mo) - 1, Number(d), Number(h ?? 0), Number(mi ?? 0)).getTime();
DATE_RE 只校验位数,不校验范围。Date 对越界值不报错,它会往前滚:
输入 结果
2026-01-01 2026-01-01
2026-13-01 2027-01-01 ← 第 13 个月滚到下一年
2026-00-15 2025-12-15 ← 第 0 个月滚到上一年
2026-01-32 2026-02-01
2026-01-00 2025-12-31
2026-02-30 2026-03-02 ← 平年 2 月只有 28 天
2026-02-29 2026-03-01 ← 2026 非闰年
2025-02-29 2025-03-01 ← 2025 非闰年
2024-02-29 2024-02-29 ← 2024 是真闰年,正确
2026-99-01 2034-03-01 ← 99 个月
2026-01-01 25:00 2026-01-02 01:00 ← 25 小时
2026-01-01 12:60 2026-01-01 13:00 ← 60 分钟
八个越界输入,八个不同结果,全部没有错误标记。批量表里它们和正常行长得一模一样。
DATE_RE 用 \d{2} 卡住了位数,所以 2026-1-1 这种不会进来;但它卡不住范围。闰年的判断更是完全交给 Date——2024-02-29 正确,2025-02-29 静默变成 3 月 1 日。
想卡范围得自己写:Number(mo) < 1 || Number(mo) > 12,或者更彻底地用 Date 解析之后再回读年月日比对一次。
7. 夏令时前进:不存在的时刻静默前移
这才是标题里那件事。
America/New_York 在 2026 年 3 月 8 日 02:00 把钟拨到 03:00。02:00 到 03:00 之间的时刻不存在,但 Date 不会告诉你这件:
输入 Unix 时间戳 实际墙钟
2026-03-07 23:30 1772944200 Mar 7, 23:30 EST
2026-03-08 01:30 1772951400 Mar 8, 01:30 EST
2026-03-08 02:00 1772953200 Mar 8, 03:00 EDT ← 前移 1 小时
2026-03-08 02:30 1772955000 Mar 8, 03:30 EDT ← 前移 1 小时
2026-03-08 03:00 1772953200 Mar 8, 03:00 EDT
2026-03-08 03:30 1772955000 Mar 8, 03:30 EDT
02:00 和 03:00 得到同一个时间戳。02:30 和 03:30 得到同一个时间戳。六个输入只有四个不同的输出。
V8 的归一化策略是把不存在的时刻往后推(02:30 → 03:30)。这意味着一个后果比「前移」本身更麻烦:碰撞。批量表里两行不同的输入产出同一个时间戳,做去重时会互相抵消,做排序时顺序取决于输入顺序而非时间顺序。
而且这个行为取决于跑在谁的机器上。在 Asia/Shanghai 的进程里跑同一份输入,2026-03-08 02:30 是一个存在的时刻,正常转换。所以「这个时间戳对不对」这个问题没有独立于运行环境的答案。
8. 夏令时回拨:重复的时刻静默取早的那次
回拨那一侧同样安静,但性质不同:2026-11-01 02:00 拨回 01:00,01:00 到 02:00 这段出现两次。
输入 Unix 时间戳 实际墙钟
2026-11-01 00:30 1793507400 Nov 1, 00:30 EDT
2026-11-01 01:00 1793509200 Nov 1, 01:00 EDT
2026-11-01 01:30 1793511000 Nov 1, 01:30 EDT ← 早的那次
2026-11-01 02:30 1793518200 Nov 1, 02:30 EST
晚的那次 01:30 EST 对应的时间戳是 1793514600(1793511000 + 3600)。工具给不出来——没有任何参数、没有任何写法能选到它。
前进那一侧是丢弃(02:30 不报错地变成 03:30),回拨这一侧是猜测(01:30 静默取早的那次)。两者都不出声。
回拨在日志分析里更常见也更危险:一天的日志里有两小时被贴了同一个标签,如果你的脚本拿时间戳去重,晚那一小时的记录会被当成重复行扔掉。
9. 反向结果取决于跑在谁的机器上
反向方向用的时区不是工具参数,是浏览器的:
const localTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
同一行 2026-06-15 12:00:
进程时区 Unix 时间戳 UTC
UTC 1781553600 2026-06-15T12:00:00Z
America/New_York 1781539200 2026-06-15T16:00:00Z EDT,UTC-4
Asia/Shanghai 1781496000 2026-06-15T04:00:00Z
Pacific/Auckland 1781474400 2026-06-15T00:00:00Z 已回拨到 UTC+12
Asia/Kolkata 1781500800 2026-06-15T06:30:00Z UTC+5:30
相差最多 14 小时。同一份日志,两个同事各自粘贴,得到两个不同答案,都对。
这一点上工具做对了一件事:它把用到的时区写在输出里。单行模式的第一个结果是 date → timestamp (Asia/Shanghai),批量表的表头写 共 N 项 · Asia/Shanghai。所以至少你能看清自己算的是哪个时区。
工具不提供时区选择——想固定某个时区反查,得用 daily/timezone-converter。
10. 负时间戳造得出来,却认不回来
正向方向有明确的负数检查:
if (!Number.isFinite(sec) || sec < 0) return null;
所以 -1 进不来,行显示 —。0 是允许的下界。
但反向方向没有这个检查:
dateToSec("1969-12-31 23:59") → -60 (进程时区 UTC)
dateToSec("1970-01-01 00:00") → 0
反向能把 1969-12-31 23:59 UTC 算成 -60,正向却拒绝把 -60 转回时间。
两侧不对称。想验证一个 1969 年的时间戳,只能自己 new Date(-60000).toISOString()。
顺带一条:dateToSec 用 Math.floor(ms / 1000) 而不是 Math.round,负数会向下取整。1969-12-31 23:59:30 UTC(虽然这个输入格式本身不被接受)对应的 -30 秒会被截成 -30 而非 -29——在负值区间里 floor 和 round 给出不同的整数。
11. en-US 分支漏了 hour12: false
批量表用的格式器:
function stamp(ms: number, tz: string, zh: boolean): string {
const d = new Date(ms);
if (zh)
return d.toLocaleString('zh-CN', {
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
});
return d.toLocaleString('en-US', {
timeZone: tz, year: 'numeric', month: 'long', day: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
});
}
zh-CN 那支传了 hour12: false,en-US 那支没有。en-US 的默认小时制是 12 小时,于是:
时刻(UTC) 代码里的 en-US 补上 hour12: false 代码里的 zh-CN
午夜 September 17, 2026 at 12:00:00 AM September 17, 2026 at 00:00:00 2026/09/17 00:00:00
正午 September 17, 2026 at 12:00:00 PM September 17, 2026 at 12:00:00 2026/09/17 12:00:00
16:30:05 September 17, 2026 at 04:30:05 PM September 17, 2026 at 16:30:05 2026/09/17 16:30:05
23:59:59 September 17, 2026 at 11:59:59 PM September 17, 2026 at 23:59:59 2026/09/17 23:59:59
午夜和正午都打印 12:00:00,AM/PM 是唯一的区分位。04:30:05 PM 需要读者自己做一次加法才知道是 16:30:05。
中文分支给出的 00:00:00 / 16:00:00 是这份工具里唯一的 24 小时制。
第二个后果在批量模式:那里两个分支都硬编码传 false,所以时间单元格永远是英文格式——中文视图下表头是「输入 / Unix (秒) / 本地时间 / UTC 时间」,格子里是 September 17, 2026 at 12:00:00 AM。单行模式两样都给了(value 英文、valueZh 中文),批量模式只给了英文那一支。
12. 闰秒被跳过,而且进不来
POSIX 时间戳按定义跳过闰秒。最后一次闰秒是 2016-12-31 23:59:60 UTC:
1483228799 → 2016-12-31T23:59:59.000Z
1483228800 → 2017-01-01T00:00:00.000Z
差值 = 1 秒(真时过了 2 秒)
23:59:60 这个时刻不存在于时间戳里。相邻两个时间戳差 1,但墙上钟走了 2 秒。
这个工具对闰秒有两条限制叠在一起:一是第 5 节的 DATE_RE 不接受秒字段,所以 2016-12-31 23:59:59 根本匹配不上,行显示 —;二是即使能输进去,Date 也不认 :60 的秒。
大多数用途不需要闰秒。需要的那少数(时间同步、观测台日志、金融行情)里,这两条限制都只能自己绕。
13. 这套转换覆盖什么,覆盖不了什么
覆盖的:
- 秒和毫秒双向互转,批量模式一行一个,单行模式给完整分解
- 恰好 13 位自动按毫秒算,13 位以外的按秒
- 反向方向接受
YYYY-MM-DD、YYYY-MM-DD HH:MM、YYYY-MM-DDTHH:MM,小时允许 1 位 - 反向结果里明确写出所用时区(
date → timestamp (Asia/Shanghai)) Math.round(sec * 1000)全程在整数精确范围内(TS_MAX_MS小于2^53),不存在丢精度- 上界有守卫,超界的输入返回
—而不是抛RangeError zh-CN分支是 24 小时制,中文视图下单行结果的本地时间和 UTC 时间都是 24 小时制
覆盖不了的:
- 不存在的时刻会被静默改写。夏令时前进那一小时里输入的时刻静默前移一小时,且
02:00与03:00、02:30与03:30产出同一个时间戳。做去重或排序之前先想清楚这一点。 - 重复的时刻只能取到早的那次。夏令时回拨那一小时里,晚的那一次在任何写法下都拿不到。日志去重会把晚那一小时的记录当成重复行扔掉。
- 位数规则只看位数不看语义。12 位毫秒值被当秒处理,静默差 1000 倍,结果跳到公元 7547 年。前导零凑够 13 位同样按毫秒算。
Number()的宽松输入会被当成时间戳:0x10→ 16 秒,1e12→ 公元 2001 年,1760000000.5被接受。- 小数秒让输出自相矛盾。秒列和毫秒列各自独立取整,
1760000000.5给出1760000001秒和1760000000500毫秒,两列差 500 毫秒。 - 反向方向只认一种格式。不匹配
DATE_RE不报错,静默落到时间戳分支然后变成三个破折号。斜杠分隔、1 位月日、双空格、tab、任何秒字段、任何时区后缀都收不进来。 - 月日小时越界不校验。
2026-13-01、2026-02-30、2026-02-29(平年)、2026-01-01 25:00全部静默往前滚,表格里没有错误标记。 - 反向结果不跨机器一致。用的是浏览器时区,同一行输入在不同机器上相差最多 14 小时。
- 没有时区选择。要固定某个时区反查时间戳,得用
daily/timezone-converter。 - 负时间戳单向。反向能算出
-60,正向拒绝-1。1969 年的值只能自己算。 - 批量表的时间单元格永远是 12 小时制英文。
stamp的en-US分支漏传hour12: false,午夜和正午都显示12:00:00;批量模式硬编码英文分支,中文视图下表头是中文、格子里是英文。 - 闰秒既不存在也进不来。时间戳按定义跳过闰秒,而
DATE_RE不接受秒字段。
两条实用建议。第一,拿反向方向的输出做自动化之前,先确认两件事:输出里写的那个时区是不是你想要的那个,以及输入的时刻会不会落在你那个时区的夏令时切换那一小时内——如果是,这一行的时间戳是不可靠的。第二,批量表里出现 — 就先查一下那行是格式不匹配(第 5 节)还是超出上界(第 4 节),两种原因的输入看起来完全一样。