问题与复现
@finagent/i18n 的 format.ts 头部把日期/时间的渲染职责写死在这一层(packages/i18n/src/format.ts:4-7):
Every money/percent/date/relative-time value must render through this layer so the UI locale controls presentation.
金额侧的同一契约写得更直白(packages/ui/src/lib/money.ts:5-7):
…so the active UI locale controls presentation (issue #86): the OS locale no longer participates, and one view can never mix US$1,234.56 and $1,234.56.
i18nCurrentLocale()(format.ts:30-32)存在的唯一目的就是让渲染方不去读宿主区域,仓库里已有按此写法的先例(components/automation/RuleCard.tsx:30,44、components/workspace/PortfolioSection.tsx:43)。
但 UI 包里有 8 处直接把区域参数留给运行时(toLocaleString() / toLocaleDateString() / toLocaleTimeString([], …)),于是输出由宿主 OS 的区域与日期模式决定,与应用语言无关。
以 WhatChangedSection 为例,取 previousReport.generatedAt = 1732180800000(2024-11-21T09:20:00Z):
date: new Date(previousReport.generatedAt).toLocaleDateString(),
应用语言为 zh-CN、宿主也是中文 Windows 时,Date.prototype.toLocaleDateString() 走宿主 yyyy/M/d 模式 ⇒ 渲染成 2024/11/21,而不是本应用统一层对该语言的形状 2024年11月21日。即宿主语言与应用语言一致时照样错,因为内联调用用的是宿主日期模式,不是本应用的格式化策略;宿主语言不同时则连语言都会串(zh-CN 应用 + en-US 宿主 ⇒ 11/21/2024)。
8 个调用点(行号基于 3a17eca6):
| # |
文件:行 |
内联写法 |
| 1 |
components/primitives/DataFreshness.tsx:15 |
new Date(updatedAtMs).toLocaleTimeString([], { hour:'2-digit', minute:'2-digit', second:'2-digit' }) |
| 2 |
components/research/EvidenceList.tsx:26 |
new Date(ref.fetchedAt).toLocaleTimeString() |
| 3 |
components/research/WhatChangedSection.tsx:53 |
new Date(previousReport.generatedAt).toLocaleDateString() |
| 4 |
components/research/ResearchReportView.tsx:86 |
new Date(report.generatedAt).toLocaleString() |
| 5 |
components/settings/ConnectionCard.tsx:323 |
new Date(lastCheck).toLocaleString() |
| 6 |
components/settings/EvaluationSettingsTab.tsx:39 |
new Date(updatedAt).toLocaleDateString(undefined, { year:'numeric', month:'short', day:'numeric' }) |
| 7 |
components/thesis/ThesisImpactList.tsx:40 |
new Date(impact.evaluatedAt).toLocaleDateString() |
| 8 |
components/discover/DiscoverView.tsx:471 |
new Date(run.createdAt).toLocaleString() |
预期与修复方向
统一改走 @finagent/i18n 的日期/时间 formatter:每个调用点一行 + 一行 import。
选 formatDate / formatDateTime / formatMarketTime 而不是在组件里补一个 i18nCurrentLocale(),是因为 8 处的 options 都能在统一层找到逐项等价的实现,不需要新增任何 API:
- 1、2 的「时钟」形状 ⇒
formatMarketTime(format.ts:136-144,其 docstring 就是 "Clock time only (market-data freshness lines)")
- 3、6、7 的「只有日期」形状 ⇒
formatDate(:114-122;其中 6 传入的 options 与该方法内部实现逐项相同)
- 4、5、8 的「日期 + 时间」形状 ⇒
formatDateTime(:124-134)
单位不需要处理:DataFreshness 的入参注释是 "Epoch ms."(DataFreshness.tsx:9 附近 docstring),ProviderHealth.lastCheck 也是 epoch ms,与 @finagent/i18n 各 formatter 的入参单位一致 ⇒ 本 PR 不涉及任何秒/毫秒改动。
复现证据
- 环境:Windows(
10.0.26200.9457,x64)、Bun 1.4.2、main 3a17eca6(2026-10-03T16:16:35Z)。
- 新增回归守卫
packages/ui/src/i18n/localeRendering.test.tsx(6 个用例,逐站点断言「应用语言决定输出」);packages/ui/src/components/discover/DiscoverView.test.tsx 追加 1 个同型用例。
- 修复前:
bun test packages/ui/src/i18n/localeRendering.test.tsx = 0 pass / 6 fail,断言对如下(左=应用语言期望,右=宿主区域实际):
DataFreshness Expected: "09:20:00 AM" Received: "Longbridge · Updated 09:20:00"
EvidenceList Expected: "09:20:00 AM" Received: "claimQuote · 09:20:00"
WhatChangedSection Expected: "2024年11月21日" Received: "What ChangedSince 2024/11/21No material changes."
ResearchReportView Expected: "2024年11月21日 09:20" Received: "… · 2024/11/21 09:20:00Evidence…"
ThesisImpactList Expected: "2024年11月21日" Received: "Weakened2024/11/21weakened by a downgrade"
ConnectionCard Expected: "2024年11月21日 09:20" Received: "…Portfolio ✓2024/11/21 09:20:00"
bun test packages/ui/src/components/discover/DiscoverView.test.tsx = 12 pass / 1 fail,唯一失败即新用例:
Expected to contain: "2023年11月14日 22:13"
Received: "…Strong Momentum2023/11/14 22:13:20 · 1 candidateReopen"
- 修复后:
localeRendering 6 pass / 0 fail;DiscoverView 13 pass / 0 fail。
- 受影响测试回归(
bun test --isolate):DataFreshness.test.tsx 3/0、WhatChangedSection.test.tsx 6/0、evaluation.test.tsx 5/0、localeRendering.test.tsx 6/0、DiscoverView.test.tsx 13/0 ⇒ 合计 33 pass / 0 fail。前三个既有套件在修复前同样为 3/0、6/0、5/0(已用「还原为 main 原始字节后重跑」做过 A/B 对照),因此无行为回归。
bun run i18n:check ⇒ key parity: en-US 1487 keys ≡ zh-CN 1487 keys (0 issues)(零新增 key)。
tsc --noEmit(packages/ui)exit 0。
- 上游
PR checks(.github/workflows/pr.yml)当前 state=disabled_manually,新建 PR 不会产生 check run,故以上均为本地等价命令的结果。
范围
问题与复现
@finagent/i18n的format.ts头部把日期/时间的渲染职责写死在这一层(packages/i18n/src/format.ts:4-7):金额侧的同一契约写得更直白(
packages/ui/src/lib/money.ts:5-7):i18nCurrentLocale()(format.ts:30-32)存在的唯一目的就是让渲染方不去读宿主区域,仓库里已有按此写法的先例(components/automation/RuleCard.tsx:30,44、components/workspace/PortfolioSection.tsx:43)。但 UI 包里有 8 处直接把区域参数留给运行时(
toLocaleString()/toLocaleDateString()/toLocaleTimeString([], …)),于是输出由宿主 OS 的区域与日期模式决定,与应用语言无关。以
WhatChangedSection为例,取previousReport.generatedAt = 1732180800000(2024-11-21T09:20:00Z):应用语言为
zh-CN、宿主也是中文 Windows 时,Date.prototype.toLocaleDateString()走宿主yyyy/M/d模式 ⇒ 渲染成2024/11/21,而不是本应用统一层对该语言的形状2024年11月21日。即宿主语言与应用语言一致时照样错,因为内联调用用的是宿主日期模式,不是本应用的格式化策略;宿主语言不同时则连语言都会串(zh-CN 应用 + en-US 宿主 ⇒11/21/2024)。8 个调用点(行号基于
3a17eca6):components/primitives/DataFreshness.tsx:15new Date(updatedAtMs).toLocaleTimeString([], { hour:'2-digit', minute:'2-digit', second:'2-digit' })components/research/EvidenceList.tsx:26new Date(ref.fetchedAt).toLocaleTimeString()components/research/WhatChangedSection.tsx:53new Date(previousReport.generatedAt).toLocaleDateString()components/research/ResearchReportView.tsx:86new Date(report.generatedAt).toLocaleString()components/settings/ConnectionCard.tsx:323new Date(lastCheck).toLocaleString()components/settings/EvaluationSettingsTab.tsx:39new Date(updatedAt).toLocaleDateString(undefined, { year:'numeric', month:'short', day:'numeric' })components/thesis/ThesisImpactList.tsx:40new Date(impact.evaluatedAt).toLocaleDateString()components/discover/DiscoverView.tsx:471new Date(run.createdAt).toLocaleString()预期与修复方向
统一改走
@finagent/i18n的日期/时间 formatter:每个调用点一行 + 一行 import。选
formatDate/formatDateTime/formatMarketTime而不是在组件里补一个i18nCurrentLocale(),是因为 8 处的 options 都能在统一层找到逐项等价的实现,不需要新增任何 API:formatMarketTime(format.ts:136-144,其 docstring 就是 "Clock time only (market-data freshness lines)")formatDate(:114-122;其中 6 传入的 options 与该方法内部实现逐项相同)formatDateTime(:124-134)单位不需要处理:
DataFreshness的入参注释是 "Epoch ms."(DataFreshness.tsx:9附近 docstring),ProviderHealth.lastCheck也是 epoch ms,与@finagent/i18n各 formatter 的入参单位一致 ⇒ 本 PR 不涉及任何秒/毫秒改动。复现证据
10.0.26200.9457,x64)、Bun1.4.2、main3a17eca6(2026-10-03T16:16:35Z)。packages/ui/src/i18n/localeRendering.test.tsx(6 个用例,逐站点断言「应用语言决定输出」);packages/ui/src/components/discover/DiscoverView.test.tsx追加 1 个同型用例。bun test packages/ui/src/i18n/localeRendering.test.tsx= 0 pass / 6 fail,断言对如下(左=应用语言期望,右=宿主区域实际):bun test packages/ui/src/components/discover/DiscoverView.test.tsx= 12 pass / 1 fail,唯一失败即新用例:localeRendering6 pass / 0 fail;DiscoverView13 pass / 0 fail。bun test --isolate):DataFreshness.test.tsx3/0、WhatChangedSection.test.tsx6/0、evaluation.test.tsx5/0、localeRendering.test.tsx6/0、DiscoverView.test.tsx13/0 ⇒ 合计 33 pass / 0 fail。前三个既有套件在修复前同样为 3/0、6/0、5/0(已用「还原为 main 原始字节后重跑」做过 A/B 对照),因此无行为回归。bun run i18n:check⇒key parity: en-US 1487 keys ≡ zh-CN 1487 keys (0 issues)(零新增 key)。tsc --noEmit(packages/ui)exit 0。PR checks(.github/workflows/pr.yml)当前state=disabled_manually,新建 PR 不会产生 check run,故以上均为本地等价命令的结果。范围
hour/minute/second、站点 6 的year/month/day都与原 options 逐项对应,只是从宿主区域改为应用语言。components/today/TodayView.tsx:48:它属于同一根因(new Date(timestamp).toLocaleDateString(undefined, { month:'short', day:'numeric' })),但其形状是短日期(Nov 21),统一层没有等价实现 ——formatDate会补上年份(Nov 21, 2024),属可见的形状变化,需要单独的形状决策,不宜混进本 PR。该形状在仓库里的既有先例是RuleCard.tsx:30的内联Intl.DateTimeFormat(i18nCurrentLocale(), { month:'short', day:'numeric' }),可据此单独处理。formatPrice渲染出错误的币种形状,其中只有 1 个toLocaleString调用点(ResearchMarketWorkspace.tsx:178);本条不涉及金额,覆盖的是另外 8 个组件。