America/New_York 里,2026-03-08 02:301772955000

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() 得到 NaNtsToSec 返回 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:0003:00 得到同一个时间戳。02:3003:30 得到同一个时间戳。六个输入只有四个不同的输出。

V8 的归一化策略是把不存在的时刻往后推02:3003:30)。这意味着一个后果比「前移」本身更麻烦:碰撞。批量表里两行不同的输入产出同一个时间戳,做去重时会互相抵消,做排序时顺序取决于输入顺序而非时间顺序。

而且这个行为取决于跑在谁的机器上。在 Asia/Shanghai 的进程里跑同一份输入,2026-03-08 02:30 是一个存在的时刻,正常转换。所以「这个时间戳对不对」这个问题没有独立于运行环境的答案。


8. 夏令时回拨:重复的时刻静默取早的那次

回拨那一侧同样安静,但性质不同:2026-11-01 02:00 拨回 01:0001:0002: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 对应的时间戳是 17935146001793511000 + 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()

顺带一条:dateToSecMath.floor(ms / 1000) 而不是 Math.round,负数会向下取整。1969-12-31 23:59:30 UTC(虽然这个输入格式本身不被接受)对应的 -30 秒会被截成 -30 而非 -29——在负值区间里 floorround 给出不同的整数。


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: falseen-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-DDYYYY-MM-DD HH:MMYYYY-MM-DDTHH:MM,小时允许 1 位
  • 反向结果里明确写出所用时区(date → timestamp (Asia/Shanghai)
  • Math.round(sec * 1000) 全程在整数精确范围内(TS_MAX_MS 小于 2^53),不存在丢精度
  • 上界有守卫,超界的输入返回 而不是抛 RangeError
  • zh-CN 分支是 24 小时制,中文视图下单行结果的本地时间和 UTC 时间都是 24 小时制

覆盖不了的:

  • 不存在的时刻会被静默改写。夏令时前进那一小时里输入的时刻静默前移一小时,且 02:0003:0002:3003:30 产出同一个时间戳。做去重或排序之前先想清楚这一点。
  • 重复的时刻只能取到早的那次。夏令时回拨那一小时里,晚的那一次在任何写法下都拿不到。日志去重会把晚那一小时的记录当成重复行扔掉。
  • 位数规则只看位数不看语义。12 位毫秒值被当秒处理,静默差 1000 倍,结果跳到公元 7547 年。前导零凑够 13 位同样按毫秒算。
  • Number() 的宽松输入会被当成时间戳0x10 → 16 秒,1e12 → 公元 2001 年,1760000000.5 被接受。
  • 小数秒让输出自相矛盾。秒列和毫秒列各自独立取整,1760000000.5 给出 1760000001 秒和 1760000000500 毫秒,两列差 500 毫秒。
  • 反向方向只认一种格式。不匹配 DATE_RE 不报错,静默落到时间戳分支然后变成三个破折号。斜杠分隔、1 位月日、双空格、tab、任何秒字段、任何时区后缀都收不进来。
  • 月日小时越界不校验2026-13-012026-02-302026-02-29(平年)、2026-01-01 25:00 全部静默往前滚,表格里没有错误标记。
  • 反向结果不跨机器一致。用的是浏览器时区,同一行输入在不同机器上相差最多 14 小时。
  • 没有时区选择。要固定某个时区反查时间戳,得用 daily/timezone-converter
  • 负时间戳单向。反向能算出 -60,正向拒绝 -1。1969 年的值只能自己算。
  • 批量表的时间单元格永远是 12 小时制英文stampen-US 分支漏传 hour12: false,午夜和正午都显示 12:00:00;批量模式硬编码英文分支,中文视图下表头是中文、格子里是英文。
  • 闰秒既不存在也进不来。时间戳按定义跳过闰秒,而 DATE_RE 不接受秒字段。

两条实用建议。第一,拿反向方向的输出做自动化之前,先确认两件事:输出里写的那个时区是不是你想要的那个,以及输入的时刻会不会落在你那个时区的夏令时切换那一小时内——如果是,这一行的时间戳是不可靠的。第二,批量表里出现 就先查一下那行是格式不匹配(第 5 节)还是超出上界(第 4 节),两种原因的输入看起来完全一样。