问题与复现
组合快照「有总额、无持仓」时,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
后果:
PortfolioSection.tsx:112-124 的失败卡片(!view && cache.failure)对 IPC 错误路径不可达 —— 它 :117 用的 FAILURE_HEADINGS 里 7 条本地化 heading 随之全部失效(该表全仓仅此一处引用);
TodayView.tsx:170 同理不可达;
- 用户看到的是「带 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 结论。
范围
问题与复现
组合快照「有总额、无持仓」时,
portfolioFailureFromSnapshot判定为partial并保留快照(packages/ui/src/lib/portfolioFailure.ts:46-64),界面据此渲染一条警示横幅。横幅的标题链路是本地化的(portfolio.failure.partial:en-USHoldings unavailable/ zh-CN持仓不可用),但正文行直接把PortfolioFailure.message渲染出来,而那个字段是主进程生成的英文单语串。packages/ui/src/components/workspace/PortfolioSection.tsx(行号基于3a17eca6):中文界面实测(
packages/ui/src/components/workspace/PortfolioSection.test.tsx,把getPortfolio桩成返回上述 partial 快照):整页都是中文,只有这一句英文 —— 这是本条最直观的判据。
根因:
PortfolioFailure.message被三个渲染面当成用户文案message的契约是packages/core/src/account.ts:125:「user-safe」不含「localized」。全仓渲染
failure.message的地方有三处:3a17eca6)components/workspace/PortfolioSection.tsx:119:117的本地化 heading)components/workspace/PortfolioSection.tsx:193partial警示横幅正文 ← 本条的复现路径components/today/TodayView.tsx:170SectionState kind="error"的 message第 3 处自证了「按 kind 出本地化文案」才是既定设计 —— 它在前面专门拦下两种 kind 并给出本地化文案,只有其余 kind 回落到原始 message:
附带发现:这份英文表有两份副本,且已经漂移
同一张「kind → 英文用户文案」表被手写了两遍:
packages/ui/src/lib/portfolioFailure.ts:17KIND_MESSAGES(UI 侧,因为渲染进程只拿到 IPC 信封,docstring 里写明了这个理由)packages/longbridge-tools/src/normalizer.ts:340USER_SAFE_MESSAGES(主进程侧)逐条比对:7 条里 6 条逐字节相同,1 条已经不一致 ——
USER_SAFE_MESSAGES(主进程)KIND_MESSAGES(UI)no-account-permissionThis 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:!result.ok分支刚把分类结果写进 cache,紧接着的throw又被同一个 try 的 catch 接住,而那个 catch 把failure重置为null、把数据换成样例数据。实测(
getPortfolio返回{ ok:false, error:{ code:'LONGBRIDGE_PARSE_FAILURE', message:'raw vendor {{{' }},调用fetchPortfolioAtom后读portfolioCacheAtom):后果:
PortfolioSection.tsx:112-124的失败卡片(!view && cache.failure)对 IPC 错误路径不可达 —— 它:117用的FAILURE_HEADINGS里 7 条本地化 heading 随之全部失效(该表全仓仅此一处引用);TodayView.tsx:170同理不可达;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)、Bun1.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尚不存在而导入失败(预期)。bun test packages/ui --isolate= 352 pass / 0 fail(64 个文件,0 失败)。tsc --noEmitexit 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 结论。范围
PortfolioFailure核心类型、不改任何既有文案键、不改任何可达性行为。TodayView.tsx:170)一并接入同一解析器,避免只修一半;它当前与失败卡片一样不可达,这一点已在上文说明,因此没有为它加渲染测试(解析器本身由portfolioFailure.test.ts覆盖)。portfolioCache.error(:174)不做本地化:那是任意自由文本(传输层异常的Error.message),不是固定表文案,保留原样更有利于诊断。errors.*解析器无人调用」「{{list}}占位符未插值」「内联toLocale*跟随宿主区域」;本条是「主进程英文表被当显示文案」+「枚举表双副本漂移」。ACCESS_DENIED)。packages/i18n/src/locales/{en-US,zh-CN}/portfolio.ts的riskSummary附近新增 3 个键(@@ -12,12 +12,15 @@),本 PR 在同文件failure块之后新增failureDetail块,两处相距约 20 行、无重叠上下文,可独立合并。