Skip to content

ui: 研究行情工作台自建 formatPrice 绕过统一金额/i18n 层,价格渲染成「812.31 USD」、更新时间跟随系统区域 #278

Description

@wxrbyte

现象

packages/ui/src/components/research/ResearchMarketWorkspace.tsx 自带一个本地 formatPrice,完全绕过了 issue #86 建立起来的统一金额层 packages/ui/src/lib/money.ts → @finagent/i18n:

// ResearchMarketWorkspace.tsx:36-39
const formatPrice = (value: number | undefined, currency = 'USD'): string => {
  if (value === undefined || !Number.isFinite(value)) return '—';
  return `${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`;
};

它的输出形如 812.31 USD。问题不在「好不好看」,而在于这个形状恰好是 lib/money.ts 专门为「无法格式化的未知币种」保留的降级通路:

// packages/ui/src/lib/money.ts:19-27
const SUPPORTED_CURRENCIES: Record<string, true> = { USD: true, HKD: true, CNY: true, SGD: true }

export function formatMoney(value: number | undefined, currency?: string): string {
  if (value === undefined || !Number.isFinite(value)) return '—'
  const code = (currency ?? '').trim().toUpperCase()
  if (code !== '' && SUPPORTED_CURRENCIES[code] === true) {
    return formatCurrency(value, code)      // ← USD/HKD/CNY/SGD 走这里
  }
  const number = formatNumber(value, undefined, { maximumFractionDigits: 2 })
  return code !== '' ? `${number} ${code}` : number   // ← 只有未知币种才走这里
}

USD 属于 SUPPORTED_CURRENCIES,本应走 formatCurrency,却被本地 helper 强行拉到了「未知币种」的降级分支上。

涉及 6 个数字(与 #258 的 4 个调用点是同一类漏迁,但文件与 helper 均不同):

位置 内容 旧输出 统一层输出
ResearchMarketWorkspace.tsx:171 最新价 812.31 USD $812.31
ResearchMarketWorkspace.tsx:245-248 开/高/低/昨收(4 个) 352.00 HKD HK$352.00

money.ts 的 docstring 把这条约束写得非常明确:

Every formatter delegates to the @finagent/i18n formatters (spec §52-59) 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 for the same currency.

本地 helper 同时违反了这句话的两个分句:

  1. toLocaleString(undefined, …) 的 undefined 就是「用 OS locale」,即「the OS locale no longer participates」被重新破坏。同一份 React 代码在英文与中文 Windows 上渲染出不同的千分位与小数点形态。
  2. 同一 App 内同一个 USD 标的会出现两种格式 —— 例如顶栏 SecurityHeader(ui: 行情价格展示层硬编码「$」,港股/A 股/新加坡标的币种显示错误(4 个调用点) #258 已修)渲染 $812.31,而研究行情工作台渲染 812.31 USD。这正是 docstring 里「one view can never mix」禁止的情形。

第 3 处更直接:更新时间用的是平台日期 API,而非 App 内选择的语言。

// ResearchMarketWorkspace.tsx:178
? `${t('research.workspace.lastUpdated')} ${new Date(quote.timestamp * 1000).toLocaleString()}`

toLocaleString() 不带 locale 参数 → 跟随 OS locale。用户把应用语言切成 English、而系统是中文 Windows 时,这一行仍然渲染中文格式的日期,与界面其它所有日期(均走 @finagent/i18n)不一致。

复现

新增 packages/ui/src/components/research/ResearchMarketWorkspace.test.tsx(4 个断言)。在未修复的 main 上运行:

(fail) formats the quote price through the unified money layer
  Expected: "$812.31"        Received: "812.31 USD"

(fail) prices a Hong Kong instrument in HKD, not "<number> HKD"
  Expected: "HK$350.00"      Received: "350.00 HKD"

(fail) formats the open/high/low/prev-close strip through the unified money layer
  Expected: ["HK$352.00","HK$355.00","HK$348.00","HK$351.50"]   Received: []

(fail) formats the last-updated line through the i18n date layer
  Expected to contain: "Nov 21, 2024, 09:20 AM"
  Received:            "Updated 2024/11/21 09:20:00"

 0 pass / 4 fail

第 4 条把 OS-locale 问题完整暴露出来:同一个 epoch 秒,i18n 层给 Nov 21, 2024, 09:20 AM,组件给 2024/11/21 09:20:00。

根因

已关闭的 issue #86「Unify currency formatting onto the i18n locale policy (lib/money vs @finagent/i18n)」把所有货币格式化收敛到 lib/money.ts → @finagent/i18n,但只迁走了显示「持仓金额」的调用点,漏掉了「只显示行情价格、不显示持仓金额」的一组。这一组各自保留了设计刷新时期的本地 helper —— ResearchMarketWorkspace 是其中之一(#258 登记的另外 4 处是 SecurityHeader / OverviewView / Watchlist / StockCard)。

区别在于:#258 那 4 处是硬编码 $(币种错),本文件是不硬编码 $、但自建了第二个格式化实现(格式与 locale 错)。同一个根因(迁移遗漏),不同的表现形式。

修复方案

  1. 删除 ResearchMarketWorkspace.tsx:35-39 的本地 formatPrice。
  2. 引入既有的统一实现:import { formatMoney } from '../../lib/money' 与 import { formatDateTime } from '@finagent/i18n'。
  3. 6 个价格调用点改用 formatMoney(value, currency);更新时间改用 formatDateTime(quote.timestamp * 1000)。

刻意不做的事(避免把 PR 扩大成两类改动,也避免与 #258/#259 抢同一块地盘):

影响面

仅「研究行情工作台」这一个组件的价格与更新时间渲染。quote.timestamp 是 epoch 秒,formatDateTime 收 epoch 毫秒,故传入时为 timestamp * 1000,与仓库既有约定一致。

未纳入:packages/ui/src/lib/screeningMetrics.ts:92-95(筛选「价格」列同样不带币种)——那是另一条数据通路与另一个签名,如需处理建议单独开 issue。

验证

TDD 顺序:先写测试 → 在未修复代码上确认 0 pass / 4 fail → 修复 → 4 pass / 0 fail。

# 修复前
 0 pass / 4 fail   (断言逐一给出 Expected/Received,见上)

# 修复后
 4 pass / 0 fail / 4 expect() calls / Ran 4 tests across 1 file.

测试覆盖:

断言 覆盖内容
1 美股 NVDA.US 最新价 → $812.31(证明走的是 formatCurrency 主通路,而非降级通路)
2 港股 0700.HK + provider 上报 HKD → HK$350.00(证明币种来自数据源而非硬编码,且未退回 350.00 HKD)
3 开/高/低/昨收 4 格 → HK$352.00 / HK$355.00 / HK$348.00 / HK$351.50(证明 strip 内 4 个货币数字全部迁移;成交量与市场状态两格非货币,不参与匹配)
4 更新时间 → formatDateTime(ts * 1000)(证明不再跟随 OS locale)

注:测试需 happy-dom 安装后再动态 import() 被测组件 —— 该组件静态依赖 klinecharts,其模块初始化会访问 window。这与同目录既有的 ResearchPanel.test.tsx 采用完全相同的写法。

回归对照:

命令 修复前 修复后
bun test packages/ui/src/components/research/ --isolate 21 pass / 0 fail 25 pass / 0 fail / 91 expect() calls
bun run typecheck 5/5 exit 0 5/5 exit 0

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

claimedClaimed by a contributor and currently in progress

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions