Skip to content

ui: 组合失败态详情行硬编码英文(partial 横幅在中文界面显示英文);同源另有 failure 被同函数 catch 清空 #298

Description

@wxrbyte

问题与复现

组合快照「有总额、无持仓」时,portfolioFailureFromSnapshot 判定为 partial 并保留快照(packages/ui/src/lib/portfolioFailure.ts:46-64),界面据此渲染一条警示横幅。横幅的标题链路是本地化的(portfolio.failure.partial:en-US Holdings unavailable / zh-CN 持仓不可用),但正文行直接把 PortfolioFailure.message 渲染出来,而那个字段是主进程生成的英文单语串。

packages/ui/src/components/workspace/PortfolioSection.tsx(行号基于 3a17eca6):

:119          <div className="mt-1 text-[12px] text-foreground/54">{cache.failure.message}</div>
:193          {cache.failure?.message}

中文界面实测(packages/ui/src/components/workspace/PortfolioSection.test.tsx,把 getPortfolio 桩成返回上述 partial 快照):

Expected to contain: "账户总额已加载,但无法读取持仓。"
Received: "投资组合Longbridge导入…Account totals loaded, but holdings could not be read.
           总资产US$100.00——现金: —今日: —持仓: 0暂无持仓可展示持仓 (0)该账户暂无持仓。
           Longbridge · 更新于 00:00:00分析投资组合"

整页都是中文,只有这一句英文 —— 这是本条最直观的判据。

根因:PortfolioFailure.message 被三个渲染面当成用户文案

message 的契约是 packages/core/src/account.ts:125:

export interface PortfolioFailure {
  kind: PortfolioFailureKind;
  /** User-safe message. Raw vendor output is forbidden. */
  message: string;

「user-safe」不含「localized」。全仓渲染 failure.message 的地方有三处:

# 位置(3a17eca6) 说明
1 components/workspace/PortfolioSection.tsx:119 失败卡片正文(配合 :117 的本地化 heading)
2 components/workspace/PortfolioSection.tsx:193 partial 警示横幅正文 ← 本条的复现路径
3 components/today/TodayView.tsx:170 今日页 SectionState kind="error" 的 message

第 3 处自证了「按 kind 出本地化文案」才是既定设计 —— 它在前面专门拦下两种 kind 并给出本地化文案,只有其余 kind 回落到原始 message:

// components/today/TodayView.tsx:164-175
if (failure?.kind === 'not-connected' || failure?.kind === 'no-account-permission') {
  return <SectionState kind="empty" message={t('today.connectPortfolio')} />
}
if (failure) return <SectionState kind="error" message={failure.message} />   // ← 英文

附带发现:这份英文表有两份副本,且已经漂移

同一张「kind → 英文用户文案」表被手写了两遍:

  • packages/ui/src/lib/portfolioFailure.ts:17 KIND_MESSAGES(UI 侧,因为渲染进程只拿到 IPC 信封,docstring 里写明了这个理由)
  • packages/longbridge-tools/src/normalizer.ts:340 USER_SAFE_MESSAGES(主进程侧)

逐条比对:7 条里 6 条逐字节相同,1 条已经不一致 ——

kind USER_SAFE_MESSAGES(主进程) KIND_MESSAGES(UI)
no-account-permission This LongBridge account does not grant portfolio access. Reconnect or check permissions. This LongBridge account does not grant portfolio access.

UI 侧那句少了第二句。因为 UI 对已知 code 会用自己这张表覆盖信封里的 message,主进程那句更完整的提示被丢掉了。两份手维护的英文显示文案在 7 条上就漂了 1 条,这本身就是「显示文案不该硬编码在代码里」的证据。

同源但独立的一处:分类出的 failure 被同一个函数的 catch 清空

这一处不在本 PR 的修复范围内,因为它需要维护者先确认预期行为,但它与上面同源(同一个失败态链路),且是本条更大的问题,故一并报告。

packages/ui/src/atoms/portfolioAtoms.ts:36-46 与 :74-88:

try {
  const result = await client.market.getPortfolio();
  if (!result.ok) {
    const failure = portfolioFailureFromError(result.error);
    set(portfolioCacheAtom, (cache) => ({ ...cache, loading: false, error: failure.message, failure }));
    throw new Error(failure.message);          // ← 被下面同一个 try 的 catch 接住
  }
  ...
} catch (error) {
  const demo = demoPortfolioSnapshot();
  set(portfolioCacheAtom, { data: demo, failure: null, isDemo: true, ..., error: message });  // ← failure 被重置为 null
  return demo;
}

!result.ok 分支刚把分类结果写进 cache,紧接着的 throw 又被同一个 try 的 catch 接住,而那个 catch 把 failure 重置为 null、把数据换成样例数据。

实测(getPortfolio 返回 { ok:false, error:{ code:'LONGBRIDGE_PARSE_FAILURE', message:'raw vendor {{{' }},调用 fetchPortfolioAtom 后读 portfolioCacheAtom):

PROBE data= snapshot          ← 样例数据
PROBE failure= null           ← 分类结果被丢弃
PROBE error= "Portfolio data could not be read in the expected format."
PROBE isDemo= true
PROBE loading= false

后果:

  1. PortfolioSection.tsx:112-124 的失败卡片(!view && cache.failure)对 IPC 错误路径不可达 —— 它 :117 用的 FAILURE_HEADINGS 里 7 条本地化 heading 随之全部失效(该表全仓仅此一处引用);
  2. TodayView.tsx:170 同理不可达;
  3. 用户看到的是「带 demo 徽标的样例组合」,而不是分类后的失败态:packages/core/src/account.ts:110-112 的 docstring 明确要求「The UI maps these to distinct empty/error states — never one "Portfolio unavailable: " for everything」。

需要确认的点:catch 上的注释写的是「Provider unavailable (not connected / offline): fall back to the badged sample portfolio instead of an empty state」,而 not-connected / no-account-permission 恰好也是走 !result.ok 的 code。所以「样例数据兜底」对这两种 kind 很可能是有意为之,对 parse-error / timeout / provider-error 则未必。这是产品口径问题,我不擅自改:本 PR 只修文案链路,不动任何可达性行为;确认后我可以另开 PR。

预期与修复方向

让失败详情按「有真诊断 → 显示诊断;否则 → 按 kind 出本地化文案」解析,并把 failureDetail.* 收进语言包:

  • 新增 portfolio.failureDetail.*(7 键 × 2 语言),与既有 portfolio.failure.*(短标题)成对;no-account-permission 采用主进程那句更完整的措辞,顺带修正上文那条漂移;
  • packages/ui/src/lib/portfolioFailure.ts 导出 portfolioFailureDetail(t, failure) 与 portfolioFailureDiagnostic(failure);KIND_MESSAGES 保留为安全网(保证 message 永不携带原始 vendor 输出),不再作为显示文案;
  • 三个渲染面统一走 portfolioFailureDetail;
  • provider-error 是唯一可能携带真实诊断的 kind,原样保留(不因为修文案而丢掉诊断信息)。

复现证据

环境:Windows 10.0.26200.9457(x64)、Bun 1.4.2、基线 3a17eca6。

新增 packages/ui/src/components/workspace/PortfolioSection.test.tsx(2 个用例,用真实组件渲染 + createSyncI18n 真实语言包,getPortfolio 桩成返回 partial 快照);packages/ui/src/lib/portfolioFailure.test.ts 追加 5 个用例(全 7 种 kind 的文案解析、英文表不泄漏、provider-error 诊断透传)。

  • 修复前:PortfolioSection.test.tsx = 1 pass / 1 fail(zh-CN 用例即上文 Expected "账户总额已加载,但无法读取持仓。";en-US 用例本来就通过,说明英文路径无需改动、修复不会造成回归)。portfolioFailure.test.ts 因 portfolioFailureDetail 尚不存在而导入失败(预期)。
  • 修复后:两文件合计 17 pass / 0 fail。
  • 工作区回归:bun test packages/ui --isolate = 352 pass / 0 fail(64 个文件,0 失败)。
  • tsc --noEmit exit 0。
  • bun run i18n:check = en-US 1494 keys ≡ zh-CN 1494 keys (0 issues)(新增 7 键,语言齐全性与插值齐全性均通过)。
  • 上游 PR checks(.github/workflows/pr.yml)当前 state=disabled_manually,新建 PR 不会产生 check run,故以上均为本地等价命令结果,非 CI 结论。

范围

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