Skip to content

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

Open

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

  1. wxrbyte commented on Oct 6, 2026

    @wxrbyte
    ContributorAuthor

    /claim

  2. added
    claimedClaimed by a contributor and currently in progress
    on Oct 6, 2026
  3. github-actions commented on Oct 6, 2026

    @github-actions

    @wxrbyte 已认领该 Issue,并已自动设为 Assignee。提交 PR 时请在描述中写 Closes #298。

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