问题与复现
设置 → 诊断页的「金融提供方(Financial providers)」区块,每个提供方卡片会渲染两行覆盖范围。这两行把 {{list}} 占位符原样打印给用户,并把真正的列表拼在自己的第二个冒号之后:
packages/ui/src/components/settings/DiagnosticsTab.tsx:210 / :213(行号基于 3a17eca6):
{t('diagnostics.markets')}: {provider.coverage.markets.join(', ') || '—'}
{t('diagnostics.capabilities')}: {provider.coverage.capabilities.join(', ') || '—'}
而这两个键的文案本身已经带了占位符与分隔符:
packages/i18n/src/locales/en-US/diagnostics.ts:40 = 'Markets: {{list}}',:41 = 'Capabilities: {{list}}'
packages/i18n/src/locales/zh-CN/diagnostics.ts:41 = '市场:{{list}}',:42 = '能力:{{list}}'
调用点没有传插值参数,i18next 因此保留原样占位符,再被调用点自己补一个 : + 列表。实测渲染结果(用一个 markets: ['US','HK']、capabilities: ['market.quote','market.kline'] 的提供方):
en-US: … Financial providers longbridge connected
Markets: {{list}}: US, HK
Capabilities: {{list}}: market.quote, market.kline
zh-CN: … 金融提供方 longbridge connected
市场:{{list}}: US, HK
能力:{{list}}: market.quote
覆盖范围为空时同样带着占位符:Markets: {{list}}: —。
根因是类型把修复路径堵死了:FinancialProviderRow 把 t 的 prop 类型收窄成 t: (key: string) => string,而父组件传进来的是真实的 TFunction(DiagnosticsTab.tsx:138 的 <FinancialProviderRow … t={t} />)。收窄后的签名不允许传第二个参数,于是调用点既没法传 { list },author 只能手工拼字符串 —— 缺陷被类型系统保护住了。
全仓枚举(不是孤例,也不是普遍问题)
用脚本把两件事对齐后枚举:
- 从
packages/i18n/src/locales/en-US/*.ts 抽出所有文案,得到 637 个键,其中 67 个含 {{…}} 占位符;
- 在
packages/{ui,shared,core,skill-hub,pi-runtime}/src + apps/electron/src 里正则找所有 t('字面量键')(不带第二个参数)的调用点。
结果:未传插值参数却命中占位符键的调用点全仓恰好 2 处,都在同一个函数里(就是上面两行);同一批键里正确传参的调用点有 61 处。这 61 处里就有 8 处在本文件内部:
DiagnosticsTab.tsx:239 diagnostics.summaryFolio
DiagnosticsTab.tsx:244 diagnostics.summaryAgentRuntime
DiagnosticsTab.tsx:248 diagnostics.summaryLlm
DiagnosticsTab.tsx:252 diagnostics.summaryBroker
DiagnosticsTab.tsx:258 diagnostics.summaryLongbridgeCli
DiagnosticsTab.tsx:259 diagnostics.summarySkills
DiagnosticsTab.tsx:263 diagnostics.summaryResources
DiagnosticsTab.tsx:267 diagnostics.summaryRecentErrors
也就是说同一份文件、同一种 {{name}} 写法,8 处对、2 处错 —— 这两行是漏网,不是口径分歧。(脚本里另有 31 行动态键 t(\…`)` 无法静态判定,本问题不涉及。)
预期与修复方向
把插值交给 i18next,并让类型允许这样做:
t: TFunction; // 原来是 t: (key: string) => string
…
{t('diagnostics.markets', { list: provider.coverage.markets.join(', ') || '—' })}
{t('diagnostics.capabilities', { list: provider.coverage.capabilities.join(', ') || '—' })}
删掉调用点自带的 : (分隔符已经写在文案里)。TFunction 从 i18next 导入,与仓库既有写法一致(components/today/TodayView.tsx:5,42 的 formatWhen(timestamp, t: TFunction))。不改任何 locale 文案、不新增键,i18n:check 的 1487 ≡ 1487 保持不变。
复现证据
- 环境:Windows(
10.0.26200.9457,x64)、Bun 1.4.2、main 3a17eca6(2026-10-03T16:16:35Z)。
- 新增守卫
packages/ui/src/components/settings/DiagnosticsTab.test.tsx(3 个用例:en-US 插值、空覆盖的 — 回退、zh-CN 插值)。用真实资源包(createSyncI18n)渲染真实组件,只把 client.diagnostics.collect 换成固定 bundle。
- 修复前(把
DiagnosticsTab.tsx 还原为 3a17eca6 的原始字节后重跑):
$ bun test packages/ui/src/components/settings/DiagnosticsTab.test.tsx
0 pass / 3 fail
Expected to contain: "Markets: US, HK"
Expected to contain: "Markets: —"
Expected to contain: "市场:US, HK"
(实际渲染里出现 `Markets: {{list}}: US, HK`、`市场:{{list}}: US, HK`)
范围
问题与复现
设置 → 诊断页的「金融提供方(Financial providers)」区块,每个提供方卡片会渲染两行覆盖范围。这两行把
{{list}}占位符原样打印给用户,并把真正的列表拼在自己的第二个冒号之后:packages/ui/src/components/settings/DiagnosticsTab.tsx:210/:213(行号基于3a17eca6):而这两个键的文案本身已经带了占位符与分隔符:
packages/i18n/src/locales/en-US/diagnostics.ts:40='Markets: {{list}}',:41='Capabilities: {{list}}'packages/i18n/src/locales/zh-CN/diagnostics.ts:41='市场:{{list}}',:42='能力:{{list}}'调用点没有传插值参数,i18next 因此保留原样占位符,再被调用点自己补一个
:+ 列表。实测渲染结果(用一个markets: ['US','HK']、capabilities: ['market.quote','market.kline']的提供方):覆盖范围为空时同样带着占位符:
Markets: {{list}}: —。根因是类型把修复路径堵死了:
FinancialProviderRow把t的 prop 类型收窄成t: (key: string) => string,而父组件传进来的是真实的TFunction(DiagnosticsTab.tsx:138的<FinancialProviderRow … t={t} />)。收窄后的签名不允许传第二个参数,于是调用点既没法传{ list },author 只能手工拼字符串 —— 缺陷被类型系统保护住了。全仓枚举(不是孤例,也不是普遍问题)
用脚本把两件事对齐后枚举:
packages/i18n/src/locales/en-US/*.ts抽出所有文案,得到 637 个键,其中 67 个含{{…}}占位符;packages/{ui,shared,core,skill-hub,pi-runtime}/src+apps/electron/src里正则找所有t('字面量键')(不带第二个参数)的调用点。结果:未传插值参数却命中占位符键的调用点全仓恰好 2 处,都在同一个函数里(就是上面两行);同一批键里正确传参的调用点有 61 处。这 61 处里就有 8 处在本文件内部:
也就是说同一份文件、同一种
{{name}}写法,8 处对、2 处错 —— 这两行是漏网,不是口径分歧。(脚本里另有 31 行动态键t(\…`)` 无法静态判定,本问题不涉及。)预期与修复方向
把插值交给 i18next,并让类型允许这样做:
删掉调用点自带的
:(分隔符已经写在文案里)。TFunction从i18next导入,与仓库既有写法一致(components/today/TodayView.tsx:5,42的formatWhen(timestamp, t: TFunction))。不改任何 locale 文案、不新增键,i18n:check的1487 ≡ 1487保持不变。复现证据
10.0.26200.9457,x64)、Bun1.4.2、main3a17eca6(2026-10-03T16:16:35Z)。packages/ui/src/components/settings/DiagnosticsTab.test.tsx(3 个用例:en-US 插值、空覆盖的—回退、zh-CN 插值)。用真实资源包(createSyncI18n)渲染真实组件,只把client.diagnostics.collect换成固定 bundle。DiagnosticsTab.tsx还原为3a17eca6的原始字节后重跑):3 pass / 0 fail。bun test packages/ui --isolate= 352 pass / 0 fail(65 个文件,无任何失败项)。其中 10 个用例是本 PR(3 个)与姊妹 PR ui: 8 个组件内联 toLocale* 渲染日期/时间,输出跟随宿主 OS 区域而非应用语言 #293(7 个)新增的守卫。tsc --noEmit(packages/ui)exit 0;bun run i18n:check⇒key parity: en-US 1487 keys ≡ zh-CN 1487 keys (0 issues)。PR checks(.github/workflows/pr.yml)当前state=disabled_manually,新建 PR 不会产生 check run,故以上均为本地等价命令结果,非 CI 结论。apps/electron的 e2e 套件(本机无法跑,见 issue e2e: 平台专属的 Electron 二进制路径与 POSIX shell 清理,让 harness 在 Windows 上无法运行 #223)。范围
:)。ErrorRow的同类 prop 类型(它调用的键都不含占位符,收窄不影响;避免无关改动)。checkKeyParity的计数与标签方向,不涉及插值;与 fix(diagnostics): 诊断包恒报券商「已连接、1 个账户」,与是否登录无关 #245 无关(诊断包券商账户事实)。packages/ui/src/client/diagnostics.ts的DiagnosticsBundle镜像缺少packages/shared/src/diagnostics/types.ts里的evaluation字段,而该镜像的 docstring 自称「keep these two in sync」。组件当前不读该字段,无可复现的用户可见影响,故未并入。