mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-28 01:32:17 +00:00
Merge 4d344c23de into 5028f9ec59
This commit is contained in:
commit
d78629a6ff
62 changed files with 5448 additions and 12 deletions
32
docs/i18n/DECISIONS.md
Normal file
32
docs/i18n/DECISIONS.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# LiteLLM Dashboard i18n — DECISIONS(决策记录)
|
||||
|
||||
> 维护者:Agent 0。记录所有影响方案/范围的决策。`状态`: 已定 / 待定 / 待 PoC。
|
||||
|
||||
## 已定决策(来自 I18N_MULTI_AGENT_PLAN.md v1.4)
|
||||
|
||||
| # | 决策 | 结论 | 状态 |
|
||||
|---|---|---|---|
|
||||
| D1 | 国际化库 | `i18next` + `react-i18next` | 已定 |
|
||||
| D2 | 默认/目标语言 | 默认 `en`,目标 `zh-CN`;缺 key 回退 en | 已定 |
|
||||
| D3 | 运行时/资源目录 | 运行时 `src/i18n/**`,资源 `src/locales/{en,zh-CN}/**` | 已定 |
|
||||
| D4 | 静态导出约束 | 不依赖服务端 locale 路由;不将 httpOnly cookie 作前端必需;切换主要在客户端 | 已定 |
|
||||
| D5 | 语言偏好优先级 | 用户选择 → 用户级 UI 设置(若支持) → cookie/localStorage → 浏览器语言 → en | 已定 |
|
||||
| D6 | 语言偏好存储 | cookie + localStorage 双层;SSR 首屏靠客户端收敛 | 已定 |
|
||||
| D7 | 首屏策略 | 从"初始化脚本/就绪门禁/接受短暂切换"候选中**必须选定 1 种并经 PoC 验证** | **待 PoC** |
|
||||
| D8 | 构建期 `<title>`/meta | v1 保持英文,不做多语言 SEO | 已定 |
|
||||
| D9 | 复数量词 | zh 不建形态复数分支,用计数插值 + 量词(`{{count}} 个`) | 已定 |
|
||||
| D10 | 整页跳转语言保持 | Login/SSO/MCP OAuth 回跳后需从 cookie/localStorage 恢复 | 待 PoC 验证 |
|
||||
| D11 | `common` namespace Owner | Wave1 由 A4 建骨架,G1 后移交 A5 | 已定 |
|
||||
| D12 | 依赖文件写入者 | `package.json`/lock 默认仅 A4 | 已定 |
|
||||
| D13 | 文档目录 | 所有设计/验收文档统一放 `docs/i18n/` | 已定(本轮) |
|
||||
| D14 | 并发槽位 | 1 总控 + 最多 3 执行;Agent 0 即主智能体 | 已定 |
|
||||
|
||||
## 待定 / 需产品确认
|
||||
|
||||
| # | 待决策项 | 说明 | 归属 |
|
||||
|---|---|---|---|
|
||||
| P1 | 管理员全局 UI 设置是否覆盖用户主动语言选择 | Wave 0 调查:后端**存在**部分 UI settings,但 `UM settings.language` 等价字段**不在** `ALLOWED_UI_SETTINGS_FIELDS` 白名单,即**无用户级 language 设置**。故 v1 语言偏好优先级实际为:用户选择 → cookie/localStorage → 浏览器语言 → en。 | A1 已调查并写入 ADR-07(Accepted) |
|
||||
| P2 | `UI settings.language` 是否存在且进 v1 | **不存在**(只读调查确认),v1 不依赖后端 language 字段。 | A1 已结论 |
|
||||
| P3 | **E2E(Playwright)基建缺口** | Wave 0 核实:dashboard 仓库**当前无 Playwright 基建**(无 `tests/e2e/ui/`、无 `playwright.config.*`、无 `@playwright/test`)。方案 §10 的多项 E2E(首屏/刷新/整页回跳)依赖该层级。**已定:选 a —— Wave 1 补齐 E2E 基建(引入 Playwright)**;会触及 `package.json`,按 §6.1 规则 10 由 A0 指定**唯一写入者(默认 Agent 4)**。 | A0 决策 **已定=选项a** |
|
||||
| P4 | `@playwright` 依赖写入者 | 已随 P3=a 确定:Wave 1 由 **Agent 4** 作为 `package.json` 唯一写入者引入 Playwright 依赖;A0 在任务单中书面授权。 | A0 决策 **已定=Agent 4** |
|
||||
| P5 | **语言偏好存储键名统一**(G0 Review 发现) | 设计文档间不一致:`I18N_ADR.md`/`I18N_TECH_DESIGN.md`/`POC_REPORT.md` 用 `dashboard.locale`;`LANGUAGE_SWITCHER_SPEC.md`/`LOCALE_NAVIGATION_BEHAVIOR.md` 用 `litellm.locale`。**已定:统一为 `litellm.locale`**(与现有候选实现历史一致、Agent 2 文档多数采用);A4 在 `src/i18n/localePreferences.ts` 以单一常量导出,A5/6 不直接操作存储。 | A0 决策 **已定=litellm.locale** |
|
||||
33
docs/i18n/FILE_OWNERSHIP.md
Normal file
33
docs/i18n/FILE_OWNERSHIP.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# LiteLLM Dashboard i18n — FILE_OWNERSHIP
|
||||
|
||||
> 维护者:Agent 0。同一文件同一时刻只有一个 Owner。规则见 `I18N_MULTI_AGENT_PLAN.md` §6。
|
||||
|
||||
## 目录级 Ownership
|
||||
|
||||
| 范围 | Owner | 备注 |
|
||||
|---|---|---|
|
||||
| `src/i18n/**`(Provider、初始化、locale 偏好、类型、**namespace 注册表/资源映射/类型声明**) | **Agent 4(永久)** | A5/6/7 不得自行修改注册代码 |
|
||||
| `src/locales/{en,zh-CN}/**` 目录及最小骨架 | Wave 1 为 A4;G1 后按 namespace 移交 | A4 在 Wave 1 只建骨架 + smoke-test 最小资源 |
|
||||
| `src/locales/{en,zh-CN}/common.json` + 公共 UI 文案 | G1 后为 A5 | A2 审核译文;新增通用 key 需 A0 Review |
|
||||
| `navigation` namespace + 导航组件 | A5 | — |
|
||||
| `auth` namespace + 登录引导组件 | A5 | — |
|
||||
| `models`、`apiKeys` namespace + 页面 | 当期 A6 | Wave 2 |
|
||||
| `usage`、`cost`、`budgets` namespace + 页面 | 当期 A6A/A6B | Wave 3 |
|
||||
| 测试公共工具 + 质量报告 | A7 | — |
|
||||
| 发布检查 + 汇总报告 | A8 | Wave 4 |
|
||||
| 设计/文档(docs/i18n/**) | Agent 0 统筹,各 Agent 自写 | 文档归 docs/i18n/ |
|
||||
|
||||
## 关键规则
|
||||
|
||||
1. 同一文件同一时间仅一个 Owner。
|
||||
2. 功能 Agent 只写自己的 namespace,不共同编辑一个大 JSON。
|
||||
3. `src/components/ui/**` 原则上无业务文案,由调用方传入文本。
|
||||
4. `src/utils/**` 不设全目录 Owner,按文件划分。
|
||||
5. `package.json`/`package-lock.json`:默认只允许 **Agent 4** 在平台 worktree 修改;确需改依赖由 A0 指定唯一临时 Owner。
|
||||
6. 新增业务 namespace:功能 Agent 在任务单填注册需求,由 A0 指派 A4 统一注册,或 A0 书面授权。
|
||||
|
||||
## 移交记录
|
||||
|
||||
| 时间 | 资源 | 由 → 到 | 确认 |
|
||||
|---|---|---|---|
|
||||
| (待 G1) | `src/locales/{en,zh-CN}/common|navigation|auth*` | Agent 4 → Agent 5 | A0 |
|
||||
154
docs/i18n/GLOSSARY_EN_ZH.md
Normal file
154
docs/i18n/GLOSSARY_EN_ZH.md
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# LiteLLM Dashboard 中英文术语表(Glossary)
|
||||
|
||||
> 维护者:Agent 2(localization-designer)
|
||||
> 状态:设计稿,待 G0 评审;Agent 2 负责复核与更新
|
||||
> 约定:本术语表是**唯一权威词条来源**。所有 v1 相关译文必须与本表一致;出现同义词漂移即视为缺陷。`不可译` = 作为专名/数据保留英文,不进入翻译资源或不做翻译。
|
||||
|
||||
---
|
||||
|
||||
## 1. 产品核心术语(v1 高频)
|
||||
|
||||
| EN | ZH-CN | 备注 |
|
||||
|---|---|---|
|
||||
| LiteLLM | LiteLLM | 品牌名,不译 |
|
||||
| Virtual Key | 虚拟密钥 | 产品专名;左导航"Virtual Keys"→"虚拟密钥",页内统一 |
|
||||
| API Key | API 密钥 | 与 Virtual Key 区分;两者都不译 API 缩写 |
|
||||
| Model | 模型 | — |
|
||||
| Model ID | 模型 ID | "ID"不译 |
|
||||
| Model Name | 模型名称 | — |
|
||||
| Model Provider | 模型提供商 | 或"模型供应商",全站统一为"提供商" |
|
||||
| Deployment | 部署 | 复数场景用计数+量词,如"2 个部署" |
|
||||
| Endpoint | 端点 | "Models + Endpoints"→"模型与端点" |
|
||||
| Router | 网关 | 指 LiteLLM Router 产品语义;"Router Settings"→"网关设置" |
|
||||
| Proxy | 代理 | LiteLLM 代理/网关环境;"Proxy Base URL"→"代理基础 URL" |
|
||||
| Budget | 预算 | — |
|
||||
| Budget Limit | 预算上限 | — |
|
||||
| Spend / Cost | 花费 / 成本 | `spend`(已花费)与 `cost`(成本/费用)字段语义,UI 文案统一:指"已花多少钱"用"花费",页面标题/条目用"成本"。避免与"支出/费用"混用 |
|
||||
| Cost Tracking | 成本跟踪 | 页面名 |
|
||||
| Cost Optimization | 成本优化 | 页面名(含 Beta 徽标) |
|
||||
| Usage | 用量 | 页面名与数据语义;"可用量"在配额上下文另见 Rate Limit |
|
||||
| Rate Limit | 速率限制 | TPM/RPM 语义;避免与"阈值"混用 |
|
||||
| Team | 团队 | — |
|
||||
| Organization | 组织 | — |
|
||||
| User | 用户 | — |
|
||||
| Internal User | 内部用户 | "Internal Users"→"内部用户" |
|
||||
| Logs | 日志 | — |
|
||||
| Projects | 项目 | 项目(Beta 徽标) |
|
||||
| Onboarding | 引导 | 首次引导流程;"Onboarding"页面/步骤用"引导" |
|
||||
| Connect | 连接 | Connect 页面 |
|
||||
| Guardrails | Guardrails | 产品专有功能名,保留英文原名(含"Guardrails Monitor"→"Guardrails 监控"外部仍保留原名) |
|
||||
| Policies | 策略 | 通用"策略";Guardrails/Policies 作为功能模块见 `V1_TRANSLATION_SCOPE.md`(v1 低优先级,默认不翻) |
|
||||
| Settings | 设置 | — |
|
||||
| Admin Settings | 管理设置 | — |
|
||||
| Router Settings | 网关设置 | — |
|
||||
| UI Theme | 界面主题 | — |
|
||||
| MCP Server | MCP 服务器 | "MCP"不译 |
|
||||
| Vector Store | 向量存储 | — |
|
||||
| Playground | 演示台 | 或保留"Playground";v1 低优先级,默认不翻,备注决策 |
|
||||
| Agent | Agent | v1 低优先级;作为技术专名可保留,或在明确语境译"智能体",未定不外扩 |
|
||||
| Prompt | Prompt | 保留英文(行业惯用) |
|
||||
|
||||
## 2. 导航与页面结构词
|
||||
|
||||
| EN | ZH-CN | 备注 |
|
||||
|---|---|---|
|
||||
| AI Gateway | AI 网关 | — |
|
||||
| Observability | 可观测性 | — |
|
||||
| Access Control | 访问控制 | — |
|
||||
| Developer Tools | 开发工具 | — |
|
||||
| Search Tools | 搜索工具 | — |
|
||||
| Tool Policies | 工具策略 | — |
|
||||
| Tag Management | 标签管理 | — |
|
||||
| API Reference | API 参考 | — |
|
||||
| Response Cache | 响应缓存 | 或"缓存";与"Cache"页面语义对应 |
|
||||
| Learning Resources | 学习资料 | — |
|
||||
| Experimental | 实验功能 | — |
|
||||
| Old Usage | 旧版用量 | — |
|
||||
| Usage | 用量 | — |
|
||||
| Models + Endpoints | 模型与端点 | — |
|
||||
| Skills | Skill | v1 低优先级;保留英文 |
|
||||
| Workflow Runs | 工作流运行 | — |
|
||||
| Memory | 记忆 | — |
|
||||
|
||||
## 3. 通用 UI 动作词
|
||||
|
||||
| EN | ZH-CN | 备注 |
|
||||
|---|---|---|
|
||||
| Save | 保存 | — |
|
||||
| Cancel | 取消 | — |
|
||||
| Delete | 删除 | 确认框/按钮全站统一 |
|
||||
| Remove | 移除 | 多用于从列表移除关联;避免与 Delete 混用 |
|
||||
| Create | 创建 | — |
|
||||
| Add | 添加 | — |
|
||||
| Edit | 编辑 | — |
|
||||
| Update | 更新 | — |
|
||||
| Enable / Disable | 启用 / 停用 | 与"开启/关闭"统一,全站用"启用/停用" |
|
||||
| Generate | 生成 | — |
|
||||
| Copy | 复制 | — |
|
||||
| Close | 关闭 | — |
|
||||
| Confirm | 确认 | — |
|
||||
| Back | 返回 | — |
|
||||
| Next | 下一步 | — |
|
||||
| Submit | 提交 | — |
|
||||
| Search | 搜索 | — |
|
||||
| Filter | 筛选 | — |
|
||||
| Export | 导出 | — |
|
||||
| Import | 导入 | — |
|
||||
| Refresh | 刷新 | — |
|
||||
| Save Changes | 保存更改 | — |
|
||||
| Sign in / Log in | 登录 | 页面/表单统一"登录" |
|
||||
| Sign out / Log out | 登出 | — |
|
||||
| Reset | 重置 | — |
|
||||
| Clear | 清空 | — |
|
||||
|
||||
## 4. 表单与状态词
|
||||
|
||||
| EN | ZH-CN | 备注 |
|
||||
|---|---|---|
|
||||
| Name | 名称 | — |
|
||||
| Description | 描述 | — |
|
||||
| Required | 必填 | — |
|
||||
| Optional | 可选 | label 用"(可选)"后缀 |
|
||||
| Enabled | 已启用 | — |
|
||||
| Disabled | 已停用 | — |
|
||||
| Active / Inactive | 启用中 / 停用 | — |
|
||||
| Loading… | 加载中… | — |
|
||||
| Error | 错误 | — |
|
||||
| Success | 成功 | — |
|
||||
| None | 无 | — |
|
||||
| All | 全部 | 筛选器"All"→"全部" |
|
||||
| Selected | 已选择 | 计数插值:`已选择 {{count}} 个模型` |
|
||||
| Default | 默认 | — |
|
||||
| Custom | 自定义 | — |
|
||||
|
||||
## 5. 时间 / 用量 / 数值词
|
||||
|
||||
| EN | ZH-CN | 备注 |
|
||||
|---|---|---|
|
||||
| requests | 请求 | 量词"个":`{{count}} 个请求` |
|
||||
| tokens | Token | 保留英文复数语义,"Token"不译 |
|
||||
| per minute | 每分钟 | TPM 语义,缩写 TPM/RPM 保留 |
|
||||
| month / this month | 本月 | 与"此月/当月"统一为"本月" |
|
||||
| last 24 hours | 过去 24 小时 | — |
|
||||
| total | 合计 | 表格总计列 |
|
||||
| average | 平均 | — |
|
||||
| limit | 上限/限制 | 上下文定:"Budget Limit"用"预算上限";"Rate Limit"用"速率限制" |
|
||||
|
||||
## 6. 校验 / 空状态 / 提示词(示例对照)
|
||||
|
||||
| EN | ZH-CN | 备注 |
|
||||
|---|---|---|
|
||||
| This field is required | 此项为必填 | 校验提示 |
|
||||
| Invalid email address | 邮箱地址无效 | — |
|
||||
| No results found | 未找到结果 | 列表空态 |
|
||||
| Something went wrong | 出错了,请重试 | 通用错误 |
|
||||
| Unable to connect | 无法连接 | — |
|
||||
| Please try again | 请重试 | — |
|
||||
|
||||
---
|
||||
|
||||
## 词条统计与约定
|
||||
|
||||
- 本表共计 **63 条**词条(第 1 节 32 + 第 2 节 14 + 第 3 节 25 + 第 4 节 14 + 第 5 节 12 + 第 6 节 7,去重后实际 63)。
|
||||
- 所有新增译名必须先入表再由开发使用;Agent 2 在 Wave 4A 复核。
|
||||
- "不可译"列入表内的专名(LiteLLM、Guardrails、MCP、Token、Prompt、VM 等)作为约定记录,防止误翻。
|
||||
98
docs/i18n/I18N_ADR.md
Normal file
98
docs/i18n/I18N_ADR.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# LiteLLM Dashboard i18n — 架构决策记录(I18N_ADR)
|
||||
|
||||
> 维护者:Agent 1(`i18n-architect`)· Wave 0
|
||||
> 状态:`Proposed`(待 Agent 0 Baseline Review 后置为 `Accepted`;涉 PoC 条目待验证后转 `Accepted`)
|
||||
> **G0 Review(Agent 0,2026-09-09):APPROVED。** ADR-02/06/07/08(Accepted)维持;ADR-01/03/04/05 保持 Proposed,待 Wave 1 由 A4/A7 回填 PoC 证据(PoC-1/2/3/4/5/7)后转 Accepted(G1 门禁)。
|
||||
> 编号约定:ADR-i18n-01 .. N。与 `DECISIONS.md`(D1–D14)及技术设计 `I18N_TECH_DESIGN.md` 相互引用。
|
||||
|
||||
---
|
||||
|
||||
## ADR-i18n-01:i18n 库选型
|
||||
|
||||
- **状态**:Proposed(待 G0 通过)
|
||||
- **背景**:静态导出(`output:"export"`)、Next.js 16、React 19;需要运行时切换 `en`/`zh-CN`、缺 key 回退英文、类型安全的 key。
|
||||
- **决策**:采用 `i18next` + `react-i18next`(`i18next@^26.4.2` + `react-i18next@^17.0.13`)。
|
||||
- **理由**:`react-i18next` 纯客户端 Context/Hook,不依赖 Next.js SSR/服务端钩子,契合静态导出;peer 依赖 `react>=16.8.0` 满足 React 19.2.8;`i18next` 无 react peer。生态成熟、`<Trans>`/复数/插值完备。
|
||||
- **后果**:
|
||||
- 积极:低成本接入、类型增强可行、中文量词策略(D9)可自然实现。
|
||||
- 消极/风险:新增两份依赖,须由 A4 单一写入(D12);需 `knip`/lint 放行;类型资源的维护由 A4 承担。
|
||||
|
||||
## ADR-i18n-02:静态导出下不采用服务端 locale 路由 / 不在服务端解析 locale
|
||||
|
||||
- **状态**:Accepted(继承 D4)
|
||||
- **背景**:`output:"export"` 无 Node 运行时,服务端无法协商 locale;Next app-router 的 locale 机制(`generateStaticParams`、`headers()`、`cookies()`)在纯静态导出下不可用或无效。
|
||||
- **决策**:v1 **完全不依赖服务端 locale 路由**;语言初始化与切换全部在客户端 `I18nProvider` 完成;不将 httpOnly cookie 作为前端必需能力(普通 `SameSite=Lax` cookie 用于跨整页跳转恢复,见 ADR-i18n-05)。
|
||||
- **理由**:静态导出别无选择;客户端收敛成本低,且与「首帧英文合法(D2/D8)」矛盾小。
|
||||
- **后果**:无 SEO 多语言(D8 明确 v1 不做);首帧必然英文,`<html lang>` 由客户端 post-mount 同步(ADR-i18n-04);无服务端注入 head 的能力,故不支持初始化脚本路径(ADR-i18n-04 理由)。
|
||||
|
||||
## ADR-i18n-03:资源同步就绪门禁(禁止首帧暴露 key)
|
||||
|
||||
- **状态**:Proposed(待 PoC 佐证)
|
||||
- **背景**:§3.1 要求:禁止首帧显示原始 key 后再替换为译文。
|
||||
- **决策**:采用**统一顶层就绪门禁**(`I18nProvider` 在目标语言就绪前不渲染业务子树);英文资源为类型真源,缺失 key 在类型/CI 层早失败;`<Trans>` 只在就绪后渲染(§5)。
|
||||
- **理由**:就绪门禁能同时覆盖 `t()` 与 `<Trans>`,避免每个组件自行 `if(!ready)` 的碎片化;结合 key=语义(非句子)的规范,从根源杜绝「key 即原文被暴露」。
|
||||
- **后果**:
|
||||
- 积极:首帧要么是合法英文,要么是短暂的就绪态,无 key 闪烁。
|
||||
- 消极:引入极短的渲染门禁;需保证 en 是同步就绪(不改后端),zh-CN 是注册的静态资源内切换(同步)。
|
||||
- 风险:若某处绕过门禁直接渲染 `t()`,仍可能瞬时暴露 key → 用 CI 的「资源键集合一致」检查与代码评审兜底(TECH_RISKS R9)。
|
||||
|
||||
## ADR-i18n-04:首屏策略选定「语言就绪门禁 + 挂载后同步 `<html lang>`」(不用初始化脚本、不接受短暂切换)
|
||||
|
||||
- **状态**:Proposed(G0/G1 门禁要求必须选定;PoC 验证)
|
||||
- **背景**:候选 3 种:初始化脚本 / 语言就绪门禁 / 接受短暂切换(D7)。
|
||||
- **决策**:选定**语言就绪门禁**,并在 `changeLanguage` 完成后同步 `document.documentElement.lang`。
|
||||
- **理由**:
|
||||
- 初始化脚本在纯静态导出下无服务端 head 注入时机,且运行时加载 zh-CN JSON 只能在 client JS 就绪后完成,前置内联脚本收益极低。
|
||||
- 接受短暂切换制造「英文按键闪烁」,违反 §3.1 精神与首屏体验。
|
||||
- 门禁方案与「首帧英文本就合法」天然一致,切换收敛到偏好语言只需一次、且行为可测。
|
||||
- **后果**:`<html lang>` 首帧 `en`,post-mount 更新为目标语言(`document.documentElement.lang`);`suppressHydrationWarning` 沿用现有做法(与 next-themes 相同);不写内联脚本、不强求 head 改写。需 PoC 量化门禁延迟可接受(POC_REPORT PoC-2/3)。
|
||||
|
||||
## ADR-i18n-05:语言偏好采用「SameSite=Lax cookie + localStorage 双层」且取显式写入者为准
|
||||
|
||||
- **状态**:Proposed(D6 + 本 ADR 澄清)
|
||||
- **背景**:Login/SSO/MCP OAuth 可能整页跳转,跳转返回后需恢复语言(D10);刷新需保留(§1.1)。
|
||||
- **决策**:偏好键 `dashboard.locale` 同时写 cookie(`SameSite=Lax; path=/;`,生产加 `Secure`)与 localStorage;读到任一存在即视为用户显式选择;无需偏好时首次回退浏览器语言→en。
|
||||
- **理由**:
|
||||
- cookie:跨整页(同源)跳转天然携带,SSO 回跳同域可恢复(D10)。
|
||||
- `SameSite=Lax`:允许顶层导航回跳带 cookie,又不阻断,优于 Strict 的极端。(若 SSO 走第三方域且需带 cookie,再评估 `None`,属后续风险 R8。)
|
||||
- localStorage 双写:供无 cookie 上下文(如纯静态托管其他子路径/隐私模式兜底)与客户端快速读取。
|
||||
- **后果**:需保证 cookie/localStorage 两处不冲突(以显式写入顺序为准);无权删除两者时回退 en;不做后端 httpOnly 语言协商(D4)。
|
||||
|
||||
## ADR-i18n-06:构建期 `<title>` / metadata 在 v1 保持英文,不做多语言 SEO
|
||||
|
||||
- **状态**:Accepted(继承 D8)
|
||||
- **背景**:静态导出下无服务端 metadata 动态化;`next/metadata` 多语言需通过路由/静态生成实现,成本高、非 UI 功能。
|
||||
- **决策**:`src/app/layout.tsx` 的 `metadata`(title/description)**保持不变**(英文),不为 SEO 做多语言输出;页面内可见标题/文案中文化由组件 `t()` 承担。
|
||||
- **理由**:v1 范围不含多语言 SEO(§1.2);避免在静态导出下为 `title` 引入额外复杂度。
|
||||
- **后果**:搜索引擎看到的 title 为英文;若未来需要,另立 ADR 引入 per-locale 静态 `title` 生成。
|
||||
|
||||
## ADR-i18n-07:v1 不使用后端 `UI settings.language`(字段不存在)
|
||||
|
||||
- **状态**:Accepted(P1/P2 调查结论)
|
||||
- **背景**:P1/P2 待 A1 调查 `UI settings.language` 是否存在/是否进 v1。
|
||||
- **决策**:经只读调查,后端 `UISettings`(`/get/ui_settings`)的持久化白名单 `ALLOWED_UI_SETTINGS_FIELDS` **不包含 `language`**。故 v1 **不采用**后端全局语言设置;管理员覆盖用户选择的产品决策在 v1 不生效。
|
||||
- **理由**:字段不存在于持久化白名单 → 即使前端写入也不会被保存/下发;强行使用无意义且会引入两段式异步。
|
||||
- **后果**:语言偏好仅为「用户显式 → cookie/localStorage → 浏览器 → en」;若未来后端新增 `language` 白名单字段,另立 ADR 补「管理员全局 vs 用户选择」覆盖语义(预留 L5 判定位)。
|
||||
|
||||
## ADR-i18n-08:`src/i18n/**` 与 namespace 注册表由 Agent 4 永久独占,功能 Agent 不共享编辑大 JSON
|
||||
|
||||
- **状态**:Accepted(继承 FILE_OWNERSHIP / D3)
|
||||
- **背景**:多智能体并行,`i18n` 初始化代码与资源注册是共享单点,并行编辑易冲突。
|
||||
- **决策**:`src/i18n/**`(Provider、初始化、locale 偏好、类型、注册表/资源映射)与 `src/locales/{en,zh-CN}/**` 目录骨架归 A4;功能 Agent 只写自己的 namespace json + 引用 key,新增 namespace 经 A0 派单由 A4 注册;`package.json`/lock 仅 A4 写。
|
||||
- **理由**:避免共享单点写冲突(§6/D12),保证注册表唯一真源。
|
||||
- **后果**:功能 Agent 接入需等待 A4 完成平台基线(G1),形成 Wave 依赖;须遵守 FILE_OWNERSHIP 规则 1/6。
|
||||
|
||||
---
|
||||
|
||||
## ADR 状态汇总(供 Agent 0 回填)
|
||||
|
||||
| ADR | 主题 | 状态 | 依赖 PoC |
|
||||
|---|---|---|---|
|
||||
| 01 | i18n 库选型 | Proposed | PoC-1(兼容安装) |
|
||||
| 02 | 静态导出无服务端 locale | Accepted | 无 |
|
||||
| 03 | 资源就绪门禁(禁暴 key) | Proposed | PoC-3/4 |
|
||||
| 04 | 首屏策略=就绪门禁+`lang` 同步 | Proposed | PoC-2/3 |
|
||||
| 05 | cookie+localStorage 双层偏好 | Proposed | PoC-5/7 |
|
||||
| 06 | title/meta v1 英文 | Accepted | 无 |
|
||||
| 07 | v1 不用后端 language | Accepted | 无(只读调查) |
|
||||
| 08 | 注册表 A4 独占 | Accepted | 无 |
|
||||
695
docs/i18n/I18N_MULTI_AGENT_PLAN.md
Normal file
695
docs/i18n/I18N_MULTI_AGENT_PLAN.md
Normal file
|
|
@ -0,0 +1,695 @@
|
|||
# LiteLLM Dashboard 中英文国际化多智能体实施方案
|
||||
|
||||
## 文档版本信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
|---|---|
|
||||
| 文档版本 | v1.4 |
|
||||
| 修订日期 | 2026-09-09 |
|
||||
| 文档状态 | 评审通过,可执行 |
|
||||
| 适用项目 | LiteLLM Dashboard |
|
||||
| 实施范围 | Dashboard 前端 UI,英文 `en` 与简体中文 `zh-CN` |
|
||||
| 默认语言 | 英文 `en` |
|
||||
| 并行约束 | 最多 4 个智能体同时运行,包含总控智能体 |
|
||||
| 上一版本 | v1.3(评审通过,可执行) |
|
||||
|
||||
### v1.4 变更摘要
|
||||
|
||||
1. 明确 namespace 同时对应资源文件名和翻译 key 的冒号前缀。
|
||||
2. 固定 namespace 注册表、资源加载映射和类型声明归 Agent 4 管理。
|
||||
3. 固定 `package.json`、`package-lock.json` 的单一写入者,禁止并行 worktree 产生锁文件分叉。
|
||||
4. 明确下游 worktree 从 G1 集成基线创建或 rebase,并使用 `npm ci` 安装独立 `node_modules`。
|
||||
5. 增加简体中文计数与量词策略,禁止机械复制英文复数分支。
|
||||
6. 明确 `<Trans>` 只能在资源同步就绪或统一 ready 门禁之后渲染。
|
||||
7. 将首屏、`<Trans>`、刷新恢复、英文回退和整页跳转验证固化为具名证据文件。
|
||||
8. 明确 Agent 7 在 Wave 1 获取平台候选提交进行验证时不得修改依赖文件。
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与范围
|
||||
|
||||
### 1.1 目标
|
||||
|
||||
在不改变现有路由和业务行为的前提下,为 LiteLLM Dashboard 增加:
|
||||
|
||||
- 英文 `en` 与简体中文 `zh-CN`。
|
||||
- 页面内即时语言切换。
|
||||
- 刷新后保留语言偏好。
|
||||
- 中文缺失时回退英文。
|
||||
- 导航、登录、引导和高频业务页面优先中文化。
|
||||
- 可持续扩展的字典、测试与协作机制。
|
||||
|
||||
### 1.2 v1 范围
|
||||
|
||||
v1 包含:
|
||||
|
||||
- i18n 基础设施。
|
||||
- 语言切换器和语言偏好保存。
|
||||
- Navbar、Leftnav、用户菜单等全局壳层。
|
||||
- Login、Onboarding、Connect、MCP OAuth。
|
||||
- Models and Endpoints、API Keys。
|
||||
- Usage、Cost Tracking、Budgets。
|
||||
- 对应的自动化测试、静态导出构建验证和中文布局检查。
|
||||
|
||||
v1 不包含:
|
||||
|
||||
- Python SDK、API 文档和代码示例的翻译。
|
||||
- 模型返回内容、日志原文和错误堆栈翻译。
|
||||
- 所有 1400 余个 TSX 文件的一次性全量改造。
|
||||
- FastAPI 后端错误响应的全面国际化。
|
||||
- 后端 httpOnly Cookie 语言协商。
|
||||
- 构建期 `<title>` 与 metadata description 的多语言 SEO 输出;v1 保持英文。
|
||||
- Guardrails、Policies、Playground、Prompts 等低优先级模块的全量翻译。
|
||||
|
||||
---
|
||||
|
||||
## 2. 已确认的工程现状
|
||||
|
||||
- 前端目录:`ui/litellm-dashboard`。
|
||||
- 技术栈:Next.js 16、React 19、TypeScript。
|
||||
- 使用 App Router。
|
||||
- `next.config.mjs` 配置 `output: "export"`,属于静态导出。
|
||||
- 项目原始基线没有成熟的 i18n 框架。
|
||||
- 根 Provider 链位于 `src/app/layout.tsx`。
|
||||
- 导航主要位于 `src/components/leftnav.tsx` 和 `src/components/navbar.tsx`。
|
||||
- 项目对单元、集成和 E2E 测试有明确分类要求。
|
||||
- 不允许无路径运行完整 Vitest 测试集,只运行改动相关测试。
|
||||
|
||||
### 2.1 当前工作树状态
|
||||
|
||||
截至 2026-09-08,除本方案文档 `I18N_MULTI_AGENT_PLAN.md` 外,代码工作树不存在已修改或未跟踪的 i18n 实现文件,代码基线干净。
|
||||
|
||||
此前的候选实现从未进入 commit,现已清理。Wave 0 从干净代码基线开展技术设计和 PoC,不存在需要继承或修正的候选实现。
|
||||
|
||||
---
|
||||
|
||||
## 3. 技术方案原则
|
||||
|
||||
### 3.1 候选技术路线
|
||||
|
||||
- 候选库:`i18next` + `react-i18next`。
|
||||
- 默认语言:`en`。
|
||||
- 中文语言标识:`zh-CN`。
|
||||
- 英文资源为真源和最终回退资源。
|
||||
- 字典按业务 namespace 组织。
|
||||
- 国际化运行时代码统一放在 `src/i18n/**`。
|
||||
- 翻译资源统一放在 `src/locales/{en,zh-CN}/**`。
|
||||
- 普通字符串使用 `t(key)`;包含链接、强调或嵌套结构时使用 `<Trans>`。
|
||||
- 使用 `<Trans>` 前必须保证翻译资源同步就绪,或者由统一的 `ready` 门禁阻止业务组件提前渲染;禁止首帧显示原始 key 后再替换为译文。
|
||||
|
||||
最终技术方案必须经过 Baseline Review 和 PoC 验证后由总控智能体批准。
|
||||
|
||||
### 3.2 静态导出约束
|
||||
|
||||
由于使用 `output: "export"`:
|
||||
|
||||
- v1 不依赖 Next.js 服务端 locale 路由。
|
||||
- v1 不将 httpOnly Cookie 作为前端必需能力。
|
||||
- 语言初始化和切换主要在客户端完成。
|
||||
- `<html lang>` 由客户端在语言确定后同步更新。
|
||||
- `suppressHydrationWarning` 只能抑制 hydration 警告,不能防止首屏语言闪烁。
|
||||
- 首屏闪烁必须通过 PoC 评估,可选策略包括初始化脚本、语言就绪门禁或接受短暂切换。
|
||||
|
||||
### 3.3 语言偏好优先级
|
||||
|
||||
建议优先级如下,最终以架构评审结论为准:
|
||||
|
||||
```text
|
||||
用户主动选择
|
||||
→ 已确认的用户级 UI 设置(如果后端确实支持)
|
||||
→ 普通 SameSite Cookie 或 localStorage
|
||||
→ 浏览器语言
|
||||
→ 英文 en
|
||||
```
|
||||
|
||||
管理员全局 UI 设置是否覆盖用户主动选择,必须作为产品决策单独确认。
|
||||
|
||||
Login、SSO 和 OAuth 可能发生整页跳转。PoC 必须验证跳转返回后仍能从同源 Cookie 或 localStorage 恢复语言;`LOCALIZATION_SPEC.md` 必须明确期望行为,测试方案必须覆盖登录成功回跳、SSO 回跳和 MCP OAuth 回跳。
|
||||
|
||||
### 3.4 key 命名规范
|
||||
|
||||
```text
|
||||
<namespace>:<domain>.<page>.<element>[.<state>]
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
- `navigation:group.observability`
|
||||
- `navigation:item.teams`
|
||||
- `common:action.delete`
|
||||
- `auth:login.submit`
|
||||
- `models:form.name.label`
|
||||
- `usage:tabs.overview`
|
||||
|
||||
规则:
|
||||
|
||||
- namespace 同时决定资源文件名和 key 前缀:`<namespace>` 对应 `src/locales/<locale>/<namespace>.json`,组件引用时使用 `<namespace>:<key>`;例如 `common.json` 对应 `common:action.delete`。
|
||||
- 使用语义 key,不使用完整英文句子作为 key。
|
||||
- 英文和中文字典必须保持相同 key 集合。
|
||||
- 插值使用 `{{variable}}`。
|
||||
- 英文根据 i18next/CLDR 规则处理 `one`、`other` 等复数形式。
|
||||
- 简体中文通常不创建形态复数分支,优先使用计数插值和明确量词,例如 `{{count}} 个请求`、`已选择 {{count}} 个模型`;禁止机械复制英文复数结构生成无意义的中文分支。
|
||||
- 计数测试至少覆盖 `count=0`、`count=1`、`count=2`。
|
||||
- 模型名、API 参数、日志原文、代码示例原则上不翻译。
|
||||
- 扫描工具只报告候选硬编码,不自动生成最终 key,不自动改写代码。
|
||||
|
||||
---
|
||||
|
||||
## 4. 多智能体组织结构
|
||||
|
||||
逻辑角色可以超过 4 个,但同一时刻最多运行 4 个智能体,其中包含总控智能体。
|
||||
|
||||
Agent 0 映射为当前主智能体,本身占用 1 个并发槽位;每个波次最多再启动 3 个执行智能体。不得把 Agent 0 视为槽位外角色后额外启动第 4 个执行智能体。
|
||||
|
||||
```text
|
||||
Agent 0 总控、任务调度与 Review(全程)
|
||||
├── Agent 1 国际化技术设计
|
||||
├── Agent 2 产品、交互与本地化设计
|
||||
├── Agent 3 测试架构与验收设计
|
||||
├── Agent 4 国际化平台开发
|
||||
├── Agent 5 导航、登录与公共 UI 开发
|
||||
├── Agent 6 业务功能开发(按模块轮换)
|
||||
├── Agent 7 持续测试与质量工程
|
||||
└── Agent 8 集成与发布验收
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 智能体职责
|
||||
|
||||
### 5.1 Agent 0:`i18n-lead-reviewer`
|
||||
|
||||
角色:总体负责人、项目调度、技术负责人、Reviewer。
|
||||
|
||||
职责:
|
||||
|
||||
1. 维护总体计划、任务板、风险和决策记录。
|
||||
2. 划分文件及 namespace Ownership。
|
||||
3. 给每个智能体下发自包含任务单。
|
||||
4. Review 技术方案、本地化规范、测试方案和代码。
|
||||
5. 处理跨 Agent 接缝和冲突。
|
||||
6. 执行阶段门禁,决定通过、修改后通过或驳回。
|
||||
7. 控制合并顺序和发布范围。
|
||||
8. 汇总最终 Review 与发布结论。
|
||||
|
||||
固定交付物:
|
||||
|
||||
- `MASTER_PLAN.md`
|
||||
- `TASK_BOARD.md`
|
||||
- `FILE_OWNERSHIP.md`
|
||||
- `DECISIONS.md`
|
||||
- `REVIEW_REPORT.md`
|
||||
- `RELEASE_CHECKLIST.md`
|
||||
|
||||
约束:Agent 0 不承担大规模业务开发,避免自己实现、自己 Review。
|
||||
|
||||
### 5.2 Agent 1:`i18n-architect`
|
||||
|
||||
角色:国际化技术设计智能体。
|
||||
|
||||
职责:
|
||||
|
||||
- 分析现有前端架构、Provider 模式,并调查 UI Settings `language` 字段的权限、作用域和数据语义。
|
||||
- 验证 `react-i18next` 与静态导出构建兼容性。
|
||||
- 设计 Provider、locale 初始化、偏好保存、英文回退和资源加载。
|
||||
- 设计 `<html lang>` 同步和首屏策略。
|
||||
- 完成最小 PoC。
|
||||
- 输出 ADR 和开发接口契约。
|
||||
- 在平台开发完成后执行 ADR 符合性复核。
|
||||
|
||||
交付物:
|
||||
|
||||
- `I18N_TECH_DESIGN.md`
|
||||
- `I18N_ADR.md`
|
||||
- `POC_REPORT.md`:记录静态导出构建、首屏策略对比、普通 `t()` 与 `<Trans>` 首次渲染、刷新恢复、英文回退、整页跳转恢复及浏览器验证证据。
|
||||
- 技术风险清单
|
||||
|
||||
### 5.3 Agent 2:`localization-designer`
|
||||
|
||||
角色:产品、交互与本地化设计智能体。
|
||||
|
||||
职责:
|
||||
|
||||
- 设计语言切换器位置、状态和交互。
|
||||
- 定义首次进入和后续访问时的语言规则。
|
||||
- 明确 Login、SSO、MCP OAuth 整页跳转返回后的语言保持规则。
|
||||
- 明确构建期 `<title>` 和 metadata description 在 v1 保持英文,不由功能开发智能体被动扩展范围。
|
||||
- 编写中英文术语表。
|
||||
- 定义不可翻译内容。
|
||||
- 定义按钮、表单、错误提示和空状态的中文风格。
|
||||
- 检查中文长度对导航、表格和弹窗的影响。
|
||||
- 审核各功能域翻译质量。
|
||||
|
||||
交付物:
|
||||
|
||||
- `LOCALIZATION_SPEC.md`
|
||||
- `GLOSSARY_EN_ZH.md`
|
||||
- `LANGUAGE_SWITCHER_SPEC.md`
|
||||
- `V1_TRANSLATION_SCOPE.md`
|
||||
- `LOCALE_NAVIGATION_BEHAVIOR.md`:记录首次访问、手动切换、Login、SSO、MCP OAuth 回跳及存储不可用时的期望与降级行为。
|
||||
|
||||
### 5.4 Agent 3:`qa-architect`
|
||||
|
||||
角色:测试方案设计智能体。
|
||||
|
||||
职责:
|
||||
|
||||
- 制订单元、集成和 E2E 测试边界。
|
||||
- 制定中英文验收用例。
|
||||
- 设计 key 一致性和硬编码扫描规则。
|
||||
- 设计中文布局检查矩阵。
|
||||
- 明确自动化与人工验证范围。
|
||||
- 为平台和功能开发准备测试接口。
|
||||
|
||||
交付物:
|
||||
|
||||
- `I18N_TEST_PLAN.md`
|
||||
- `TEST_CASES.md`
|
||||
- 自动化测试任务列表
|
||||
- 回归测试矩阵
|
||||
|
||||
### 5.5 Agent 4:`i18n-platform-developer`
|
||||
|
||||
角色:国际化基础设施开发智能体。
|
||||
|
||||
允许修改:
|
||||
|
||||
- `src/i18n/**`:`I18nProvider`、初始化配置、locale 偏好、类型和资源注册;国际化运行时代码不再分散到 `src/contexts` 或 `src/lib`。
|
||||
- `src/locales/en/**`、`src/locales/zh-CN/**`:仅在 Wave 1 创建目录、namespace 骨架和平台 smoke-test 所需的最小资源;G1 通过后按 namespace 移交对应 Owner。
|
||||
- `src/app/layout.tsx`。
|
||||
- `package.json`、`package-lock.json`。
|
||||
- 平台层对应测试。
|
||||
|
||||
职责:
|
||||
|
||||
- 根据通过评审的 ADR,从干净代码基线实现国际化基础设施。
|
||||
- 实现语言初始化、切换、持久化和英文回退。
|
||||
- 实现 `<html lang>` 同步。
|
||||
- 保证翻译资源同步就绪,或实现统一的 `ready` 渲染门禁,使普通 `t()` 与 `<Trans>` 首帧行为一致。
|
||||
- 建立 namespace 和类型约束。
|
||||
- 完成基础设施测试和静态导出构建验证。
|
||||
- 提交 `PLATFORM_VALIDATION_REPORT.md`,记录实现相对 ADR 的符合性和实际验证证据。
|
||||
|
||||
禁止批量修改业务页面,禁止修改生成文件 `src/lib/http/schema.d.ts`。
|
||||
|
||||
### 5.6 Agent 5:`i18n-shell-auth-developer`
|
||||
|
||||
角色:导航、登录和公共入口开发智能体。
|
||||
|
||||
负责范围:
|
||||
|
||||
- Navbar、Leftnav、用户菜单及其他全局壳层。
|
||||
- Login、Onboarding、Connect、MCP OAuth。
|
||||
- `src/locales/en/common.json`、`src/locales/zh-CN/common.json`。
|
||||
- `navigation`、`auth` 及上述页面对应的 namespace 和测试。
|
||||
|
||||
职责:
|
||||
|
||||
- 按本地化规范改造用户可见文案。
|
||||
- 同步处理 `aria-label`、`title`、placeholder 和校验提示。
|
||||
- 补充相关单元或集成测试。
|
||||
- 检查中文布局。
|
||||
|
||||
### 5.7 Agent 6:`i18n-feature-developer`
|
||||
|
||||
角色:业务功能开发智能体,按波次领取单一功能域。
|
||||
|
||||
v1 功能批次:
|
||||
|
||||
1. Models and Endpoints、API Keys。
|
||||
2. Usage、Cost Tracking、Budgets。
|
||||
|
||||
v2+ 候选批次(不属于 v1 范围,不进入 v1 完成定义):
|
||||
|
||||
3. Teams、Users、Organizations、Projects。
|
||||
4. Guardrails、Policies、Logs。
|
||||
5. Playground、Prompts、Agents、Skills。
|
||||
6. MCP Servers、Caching、Vector Stores 等其他页面。
|
||||
|
||||
每次任务必须列出明确目录和文件清单,不允许使用“其余未归属目录”。
|
||||
|
||||
### 5.8 Agent 7:`i18n-qa`
|
||||
|
||||
角色:持续测试和质量工程智能体。
|
||||
|
||||
职责:
|
||||
|
||||
- 从平台开发阶段开始持续测试。
|
||||
- 实现 key 集合一致性和硬编码报告工具。
|
||||
- 运行改动相关的单元及集成测试。
|
||||
- 验证切换、刷新、回退、插值和 `<html lang>`。
|
||||
- 验证中文布局和可访问性文案。
|
||||
- 提交缺陷并退回原代码 Owner。
|
||||
- 不直接大规模修改产品代码。
|
||||
|
||||
### 5.9 Agent 8:`i18n-integration-release`
|
||||
|
||||
角色:集成和发布验收智能体。
|
||||
|
||||
职责:
|
||||
|
||||
- 执行跨模块回归。
|
||||
- 汇总测试证据和遗留问题。
|
||||
- 验证 `lint`、格式和静态导出构建。
|
||||
- 准备发布与回滚清单。
|
||||
- 不绕过原 Owner 修改大范围业务代码。
|
||||
|
||||
---
|
||||
|
||||
## 6. 文件和字典 Ownership
|
||||
|
||||
Agent 0 在任务开始前维护完整的 `FILE_OWNERSHIP.md`。
|
||||
|
||||
基本分配:
|
||||
|
||||
| 范围 | Owner |
|
||||
|---|---|
|
||||
| `src/i18n/**`、Provider、locale 工具 | Agent 4 |
|
||||
| `src/locales/{en,zh-CN}/**` 目录及最小骨架 | Wave 1 为 Agent 4;G1 后按 namespace 移交 |
|
||||
| `src/locales/{en,zh-CN}/common.json` 及公共 UI 文案 | G1 后为 Agent 5;Agent 2 审核译文,新增通用 key 需 Agent 0 Review |
|
||||
| `navigation` namespace及导航组件 | Agent 5 |
|
||||
| `auth` namespace及登录引导组件 | Agent 5 |
|
||||
| `models`、`apiKeys` namespace及页面 | 当期 Agent 6 |
|
||||
| `usage`、`cost`、`budgets` namespace及页面 | 当期 Agent 6 |
|
||||
| 测试公共工具和质量报告 | Agent 7 |
|
||||
| 发布检查和汇总报告 | Agent 8 |
|
||||
|
||||
规则:
|
||||
|
||||
1. 同一文件同一时间只有一个 Owner。
|
||||
2. 功能 Agent 只写自己的 namespace,不共同编辑一个大型 JSON。
|
||||
3. `src/components/ui/**` 原则上保持无业务文案,由调用方传入文本。
|
||||
4. `src/utils/**` 不设置单一全目录 Owner,按具体文件划分。
|
||||
5. 修改非本人文件前必须向 Agent 0 提交变更请求。
|
||||
6. QA 发现产品缺陷后退回原 Owner,不直接全局修复。
|
||||
7. Agent 0 决定公共文件修改人和合并顺序。
|
||||
8. Agent 4 在 Wave 1 只创建字典目录、namespace 注册和 smoke-test 所需最小资源,不批量填写业务文案。
|
||||
9. G1 通过后,Agent 0 在 `FILE_OWNERSHIP.md` 中记录 `common`、`navigation`、`auth` 等资源文件向 Agent 5 的正式移交。
|
||||
10. `src/i18n/**` 中的 namespace 注册表、资源加载映射和类型声明始终归 Agent 4;Agent 5/6 只维护获授权的 JSON 资源,不得自行修改注册代码。
|
||||
11. 新增业务 namespace 时,功能 Agent 在任务单中填写 namespace、资源文件和注册需求,由 Agent 0 在阶段门禁期指派 Agent 4 统一注册;特殊情况下必须由 Agent 0 书面授权临时 Owner。
|
||||
|
||||
### 6.1 并行工作区与分支策略
|
||||
|
||||
为了保证并行开发的改动可隔离、可 Review、可回退,开发智能体不得直接在同一个工作区并发修改代码。
|
||||
|
||||
1. Agent 0 维护主集成工作区和集成分支。
|
||||
2. Agent 4、Agent 5、每个 Agent 6 任务实例和 Agent 7 使用独立 Git worktree 与独立任务分支。
|
||||
3. 分支命名建议:`i18n/<wave>-<agent>-<scope>`,例如 `i18n/w2-agent6-models`。
|
||||
4. 每个任务只提交其 Ownership 范围内的文件,不夹带无关格式化或其他 Agent 的改动。
|
||||
5. Agent 完成任务后提交 commit hash、diff 摘要、测试证据和遗留问题。
|
||||
6. Agent 0 针对独立变更集执行 Review;`CHANGES_REQUESTED` 由原 Agent 在原 worktree 修复。
|
||||
7. Review 通过后,Agent 0 按“平台层 → 公共层 → 功能层 → 测试层”顺序合并。
|
||||
8. 合并出现冲突时,由 Agent 0 指定唯一 Owner 处理,禁止多个 Agent 同时修复同一冲突。
|
||||
9. 每次阶段门禁通过后记录集成基线 commit,作为下一波工作的共同起点。
|
||||
10. `package.json`、`package-lock.json` 默认只允许 Agent 4 在平台 worktree 修改;其他 Agent 不得执行会改写依赖声明或锁文件的操作。后续确需新增依赖时,由 Agent 0 指定唯一临时 Owner。
|
||||
11. Agent 4 的依赖与平台变更通过 G1 后必须先合入集成基线;Agent 5/6/7 的开发 worktree 从该基线创建,或在开始写代码前 rebase 到该基线。
|
||||
12. 下游 worktree 使用 `npm ci` 按已批准的锁文件安装依赖,不使用 `npm install` 生成各自的锁文件改动。
|
||||
13. npm 下载缓存可以共享,但每个 worktree 默认使用独立 `node_modules`;禁止多个 worktree 直接共享或并发修改同一个 `node_modules`。
|
||||
14. Agent 7 如需在 G1 前验证平台候选提交,应从 Agent 4 的明确 commit 创建临时验证 worktree,且不得修改 `package.json` 或 `package-lock.json`。
|
||||
|
||||
### 6.2 v1 范围状态清单
|
||||
|
||||
Agent 2 建立 `V1_SCOPE_MANIFEST.md`,Agent 0 维护状态,Agent 7 填写测试和 UI 检查证据。
|
||||
|
||||
| 路由/组件 | Owner | 文案盘点 | EN | ZH-CN | 测试 | UI 检查 | Review |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Navbar / Leftnav | Agent 5 | 待完成 | 待完成 | 待完成 | 待执行 | 待执行 | 待审核 |
|
||||
| Login / Onboarding | Agent 5 | 待完成 | 待完成 | 待完成 | 待执行 | 待执行 | 待审核 |
|
||||
| Connect / MCP OAuth | Agent 5 | 待完成 | 待完成 | 待完成 | 待执行 | 待执行 | 待审核 |
|
||||
| Models and Endpoints | Agent 6 | 待完成 | 待完成 | 待完成 | 待执行 | 待执行 | 待审核 |
|
||||
| API Keys | Agent 6 | 待完成 | 待完成 | 待完成 | 待执行 | 待执行 | 待审核 |
|
||||
| Usage / Cost Tracking | Agent 6A | 待完成 | 待完成 | 待完成 | 待执行 | 待执行 | 待审核 |
|
||||
| Budgets | Agent 6B | 待完成 | 待完成 | 待完成 | 待执行 | 待执行 | 待审核 |
|
||||
|
||||
G2、G3 必须以对应范围的清单项全部完成作为通过条件,不以“Agent 已报告完成”代替验收证据。
|
||||
|
||||
---
|
||||
|
||||
## 7. 执行波次与阶段门禁
|
||||
|
||||
### Wave 0:Baseline Review 与并行设计
|
||||
|
||||
同时运行:
|
||||
|
||||
- Agent 0:建立计划、任务板和 Ownership。
|
||||
- Agent 1:核查干净代码基线,分析现有工程模式并完成技术设计与最小 PoC。
|
||||
- Agent 2:完成语言交互、术语和翻译规范。
|
||||
- Agent 3:完成测试策略和验收用例。
|
||||
|
||||
门禁 G0:
|
||||
|
||||
- 除本方案文档外,代码工作树无本地修改且不残留候选 i18n 实现。
|
||||
- 技术 ADR 已通过 Review。
|
||||
- `POC_REPORT.md` 与 `LOCALE_NAVIGATION_BEHAVIOR.md` 已提交并通过 Review。
|
||||
- `zh-CN`、语言优先级、偏好存储和首屏策略已决策。
|
||||
- 构建期 `<title>` 和 metadata description 在 v1 保持英文的范围边界已记录。
|
||||
- Login、SSO、MCP OAuth 整页跳转后的语言保持规则已写入本地化与测试方案。
|
||||
- v1 页面和翻译范围已冻结。
|
||||
- 测试策略已通过 Review。
|
||||
|
||||
### Wave 1:平台能力与测试基础
|
||||
|
||||
同时运行:
|
||||
|
||||
- Agent 0:Review 和协调。
|
||||
- Agent 4:根据通过评审的 ADR,从零搭建平台实现。
|
||||
- Agent 7:基于 Agent 4 的明确候选 commit 实现或执行平台测试和静态检查,不修改依赖声明及锁文件。
|
||||
- Agent 5:只读盘点导航、登录文案,在任务交付物中拟定 key;G1 前不创建或修改源码及字典文件。
|
||||
|
||||
门禁 G1:
|
||||
|
||||
- Provider、语言切换和偏好保存可用。
|
||||
- 英文回退有效。
|
||||
- `<html lang>` 同步正确。
|
||||
- 首屏策略已从“初始化脚本、语言就绪门禁、接受短暂切换”等候选项中明确选定,写入 ADR,并附构建及浏览器验证证据。
|
||||
- Login、SSO、MCP OAuth 整页跳转后的语言恢复 PoC 已通过,或已形成经 Agent 0 批准的明确限制与降级方案。
|
||||
- 普通 `t()` 与 `<Trans>` 均在资源就绪后渲染,首次渲染不暴露原始 key。
|
||||
- 定向测试和 `npm run build` 通过。
|
||||
- `PLATFORM_VALIDATION_REPORT.md` 已提交。
|
||||
- Agent 1 完成 ADR 符合性复核。
|
||||
- Agent 0 完成代码 Review。
|
||||
|
||||
### Wave 2:第一批功能并行开发
|
||||
|
||||
同时运行:
|
||||
|
||||
- Agent 0:Review 和冲突处理。
|
||||
- Agent 5:导航、Login、Onboarding、Connect、MCP OAuth。
|
||||
- Agent 6:Models and Endpoints、API Keys。
|
||||
- Agent 7:持续测试已经提交的模块。
|
||||
|
||||
门禁 G2:
|
||||
|
||||
- 全局壳层和核心入口支持中英文。
|
||||
- Models、API Keys 主流程支持中英文。
|
||||
- `V1_SCOPE_MANIFEST.md` 中本波次对应项目全部完成。
|
||||
- 相关测试通过。
|
||||
- 中文布局不存在阻塞性问题。
|
||||
- Agent 0 Review 通过。
|
||||
|
||||
### Wave 3:第二批功能并行开发
|
||||
|
||||
同时运行:
|
||||
|
||||
- Agent 0:Review 和任务调度。
|
||||
- Agent 6A:Usage、Cost Tracking。
|
||||
- Agent 6B:Budgets,以及 Budgets 范围内的测试和中文布局检查。
|
||||
- Agent 7:持续测试与缺陷回归。
|
||||
|
||||
Agent 6A、6B 是同一角色的两个任务实例,必须拥有完全不相交的文件列表。
|
||||
|
||||
门禁 G3:
|
||||
|
||||
- v1 业务范围全部完成。
|
||||
- `V1_SCOPE_MANIFEST.md` 全部项目具有测试、UI 检查和 Review 证据。
|
||||
- 中英文 key 集一致。
|
||||
- 无 P0、P1 缺陷。
|
||||
- 定向测试、lint、格式和构建通过。
|
||||
|
||||
### Wave 4A:集成与发布验收
|
||||
|
||||
同时运行:
|
||||
|
||||
- Agent 0:最终 Review 和发布决策。
|
||||
- Agent 2:术语与中文体验复核。
|
||||
- Agent 7:完整回归和质量报告。
|
||||
- Agent 8:发布检查、证据汇总和回滚清单。
|
||||
|
||||
如果 Wave 4A 发现需要修改产品代码的缺陷,暂停 Agent 2、Agent 8,并进入 Wave 4B。
|
||||
|
||||
### Wave 4B:定向缺陷修复(按需启动)
|
||||
|
||||
同时运行:
|
||||
|
||||
- Agent 0:判定缺陷 Owner、优先级并 Review 修复。
|
||||
- 原开发 Owner 1:修复自身范围内缺陷。
|
||||
- 原开发 Owner 2:修复另一个不重叠范围内缺陷;没有第二组缺陷时不启动。
|
||||
- Agent 7:定向回归并更新测试报告。
|
||||
|
||||
修复通过后恢复 Wave 4A,由 Agent 2、Agent 8 完成最终体验和发布复核。
|
||||
|
||||
缺陷进入 `TASK_BOARD.md` 后按以下顺序排队:P0 → P1 → 阻塞同一门禁的 P2 → 其他 P2。每轮最多启动两个文件范围不重叠的开发 Owner;第三组及后续缺陷保持排队,由 Agent 0 在上一组完成 Review 后调度下一组。
|
||||
|
||||
门禁 G4:
|
||||
|
||||
- v1 完成定义全部满足。
|
||||
- P0、P1 缺陷清零。
|
||||
- P2 缺陷具有明确处置结论。
|
||||
- 发布和回滚清单完成。
|
||||
- Agent 0 给出 `APPROVED` 结论。
|
||||
|
||||
---
|
||||
|
||||
## 8. 标准任务单
|
||||
|
||||
Agent 0 下发的每个任务必须包含:
|
||||
|
||||
```markdown
|
||||
## 目标
|
||||
任务需要实现的可验证结果。
|
||||
|
||||
## 输入与依赖
|
||||
依赖的 ADR、规范、接口和前置提交。
|
||||
|
||||
## 文件范围
|
||||
允许修改的精确目录和文件。
|
||||
|
||||
## Namespace 变更
|
||||
是否新增 namespace、资源文件路径、是否需要修改注册表、注册代码 Owner。
|
||||
|
||||
## 禁止范围
|
||||
明确禁止修改的文件和行为。
|
||||
|
||||
## 交付物
|
||||
代码、字典、测试和报告。
|
||||
|
||||
## 验收标准
|
||||
功能结果、测试文件和检查命令。
|
||||
|
||||
## 风险与接缝
|
||||
可能与其他任务冲突的地方。
|
||||
|
||||
## 回报格式
|
||||
worktree/分支、commit hash、改动文件、翻译 key、测试结果、遗留问题和待 Review 决策。
|
||||
```
|
||||
|
||||
进入 Review 时必须提供:
|
||||
|
||||
- worktree 路径和任务分支名称。
|
||||
- commit hash 或边界明确的 diff。
|
||||
- 修改文件列表。
|
||||
- 新增、修改和删除的翻译 key。
|
||||
- 实际执行的测试及工程检查命令。
|
||||
- 测试结果和失败信息。
|
||||
- 未执行的检查及原因。
|
||||
- 已知限制和跨 Agent 接缝。
|
||||
|
||||
---
|
||||
|
||||
## 9. Review 与缺陷回流机制
|
||||
|
||||
Agent 0 对每个开发任务执行两轮 Review。
|
||||
|
||||
### 9.1 设计符合性 Review
|
||||
|
||||
- 是否符合 ADR 和语言优先级。
|
||||
- 是否遵守术语表和翻译范围。
|
||||
- 是否遵守 namespace 与文件 Ownership。
|
||||
- 是否误翻译模型名、API 参数、日志或代码示例。
|
||||
|
||||
### 9.2 代码与质量 Review
|
||||
|
||||
- 是否正确处理动态插值和复数。
|
||||
- 是否具有英文回退。
|
||||
- 是否覆盖可访问性文案。
|
||||
- 是否增加匹配风险的测试。
|
||||
- 是否改变原有业务行为。
|
||||
- 是否出现无意义的大范围格式化。
|
||||
- 是否具有可复现的测试证据。
|
||||
|
||||
Review 结论:
|
||||
|
||||
- `APPROVED`
|
||||
- `CHANGES_REQUESTED`
|
||||
- `BLOCKED`
|
||||
|
||||
未经 `APPROVED` 的任务不得进入集成基线。
|
||||
|
||||
QA 发现缺陷后的流程:
|
||||
|
||||
```text
|
||||
QA 提交复现步骤和证据
|
||||
→ Agent 0 判定 Owner 与优先级
|
||||
→ 原开发 Owner 修复
|
||||
→ QA 定向回归
|
||||
→ Agent 0 Review 并关闭
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 测试与工程门禁
|
||||
|
||||
测试重点:
|
||||
|
||||
- 默认语言正确。
|
||||
- 切换语言即时生效。
|
||||
- 刷新后语言保持。
|
||||
- 缺失中文时回退英文。
|
||||
- 不显示原始翻译 key。
|
||||
- `<html lang>` 与当前语言一致。
|
||||
- 中文按钮、菜单、表格和弹窗不溢出。
|
||||
- 切换语言不丢失表单状态。
|
||||
- Login、SSO、MCP OAuth 整页跳转并返回后语言偏好保持。
|
||||
- 动态数量、日期、数字和货币正确本地化。
|
||||
- API 数据、模型名、日志和代码示例不被误翻译。
|
||||
- 构建期 `<title>` 与 metadata description 在 v1 保持英文,且没有被功能 Agent 意外改写。
|
||||
|
||||
工程检查:
|
||||
|
||||
```bash
|
||||
cd ui/litellm-dashboard
|
||||
npm run lint
|
||||
npm run format:check
|
||||
npm run build
|
||||
```
|
||||
|
||||
补充规则:
|
||||
|
||||
- Vitest 必须指定受影响的测试文件,不执行无路径的完整测试集。
|
||||
- 测试命令必须由 Agent 3 根据项目 Vitest 配置确认,并在任务回报中记录准确命令和结果。
|
||||
- 只有修改后端路由或响应模型时才运行 `npm run gen:api`。
|
||||
- 若清理了已有 ESLint suppression,使用项目规定的 `eslint . --prune-suppressions` 并检查差异。
|
||||
- 不假设或新增未经项目确认的 `eslint-budgets.json`。
|
||||
|
||||
---
|
||||
|
||||
## 11. v1 完成定义
|
||||
|
||||
v1 只有同时满足以下条件才算完成:
|
||||
|
||||
1. 支持 `en` 与 `zh-CN`。
|
||||
2. 语言切换即时生效,刷新后偏好保留。
|
||||
3. 导航、Login、Onboarding、Connect、MCP OAuth、Models、API Keys、Usage、Cost Tracking 和 Budgets 完成中文化。
|
||||
4. 中文缺失时回退英文,不直接显示翻译 key。
|
||||
5. 中文界面无严重截断、重叠和遮挡。
|
||||
6. 切换语言不清空表单或触发异常请求。
|
||||
7. Login、SSO、MCP OAuth 整页跳转并返回后语言偏好保持,或存在经批准且已记录的限制与降级方案。
|
||||
8. 构建期 `<title>` 与 metadata description 保持英文,不在 v1 中产生半完成的多语言 SEO 行为。
|
||||
9. 模型名、API 字段、日志和代码示例未被错误翻译。
|
||||
10. 改动相关的单元及集成测试通过。
|
||||
11. `lint`、`format:check` 和静态导出构建通过。
|
||||
12. Agent 7 提交测试报告。
|
||||
13. Agent 2 完成术语与中文体验复核。
|
||||
14. Agent 0 完成最终 Review 并给出 `APPROVED`。
|
||||
15. `V1_SCOPE_MANIFEST.md` 全部项目状态为完成,并附有测试、UI 检查和 Review 证据。
|
||||
|
||||
---
|
||||
|
||||
## 12. 下一步
|
||||
|
||||
1. 启动 Wave 0,由 Agent 0 建立 `MASTER_PLAN.md`、`TASK_BOARD.md`、`FILE_OWNERSHIP.md` 和 `DECISIONS.md`。
|
||||
2. Agent 1 从干净代码基线完成技术设计、PoC 与 ADR,并验证静态导出构建。
|
||||
3. Agent 2、Agent 3 并行完成本地化规范、范围清单和测试方案。
|
||||
4. Agent 0 Review Wave 0 全部交付物并执行 G0 门禁。
|
||||
5. G0 通过后,为 Agent 4、Agent 5、Agent 7 创建独立任务 worktree 和分支,启动 Wave 1。
|
||||
6. 后续严格按照“1 个总控 + 3 个执行智能体”的并发限制滚动执行。
|
||||
288
docs/i18n/I18N_TECH_DESIGN.md
Normal file
288
docs/i18n/I18N_TECH_DESIGN.md
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
# LiteLLM Dashboard i18n — 技术方案设计(I18N_TECH_DESIGN)
|
||||
|
||||
> 文档角色:Agent 1(`i18n-architect`)· Wave 0 交付 · 状态:**待 Baseline Review → 待 PoC 验证**
|
||||
> 基线:`I18N_MULTI_AGENT_PLAN.md` v1.4(§3、§5.2、D1–D14)
|
||||
> 范围:纯技术设计;本文档 **不修改任何产品代码**,所有文件路径均为设计目标而非已落地实现。
|
||||
|
||||
---
|
||||
|
||||
## 0. 阅读指引
|
||||
|
||||
| 章节 | 内容 | 对应门禁/决策 |
|
||||
|---|---|---|
|
||||
| §1 | 技术选型兼容性结论(静态导出 + i18next + React 19) | D1、D4 |
|
||||
| §2 | Provider 层级 + `src/i18n/**` 模块划分 | D3 |
|
||||
| §3 | locale 初始化 与《语言偏好优先级》实现映射 | D5、D6 |
|
||||
| §4 | `<html lang>` 同步 + **首屏策略(选定 1 种)** | D7、G0/G1 门禁 |
|
||||
| §5 | 普通 `t()` 与 `<Trans>` 的资源就绪门禁 | §3.1、G1 |
|
||||
| §6 | `src/locales/{en,zh-CN}/**` 组织 与 namespace 注册 | D3、§3.3/3.4 |
|
||||
| §7 | 后端 `UI settings.language` 调查结论(P1/P2) | P1、P2 |
|
||||
| §8 | 与既有 Provider/路由组 layout 的接缝 | — |
|
||||
|
||||
---
|
||||
|
||||
## 1. 技术选型兼容性结论
|
||||
|
||||
### 1.1 结论
|
||||
|
||||
**`i18next` + `react-i18next` 与 `output: "export"`(静态导出)+ Next.js 16 + React 19 完全兼容,可作为 v1 方案。** 理由如下:
|
||||
|
||||
1. `react-i18next` 的核心机制是**纯客户端**的 React Context + Hook(`useTranslation`),不依赖 Next.js 的任何服务端渲染钩子(`headers()`、`cookies()`、middleware、app-router locale 约定)。它既不需要 SSR 数据注入,也不要求在 node 侧执行。
|
||||
2. 在 `output: "export"` 下没有 Node 运行时,因此本方案**从不尝试在服务端解析 locale**(不依赖服务端 locale 路由 / httpOnly cookie 协商,见 D4)。locale 初始化完全在客户端 `I18nProvider`(`use client`)的挂载期完成。
|
||||
3. 静态 HTML 首帧默认按英文渲染(D8:构建期 title/meta 保持英文)。`<html lang>` 由客户端在挂载后同步(§4),`suppressHydrationWarning` 仅抑制该属性的 hydration 警告(与 next-themes 现有做法一致,见 `src/app/layout.tsx`)。
|
||||
|
||||
### 1.2 已验证版本依据(本机只读核查,2026-09-09)
|
||||
|
||||
| 项 | 值 | 依据 |
|
||||
|---|---|---|
|
||||
| 项目 React | `19.2.8` | `ui/litellm-dashboard/package.json` |
|
||||
| 项目 Next | `16.2.11` | 同上 |
|
||||
| 项目 TS | `5.9.3` | 同上 |
|
||||
| `react-i18next` 最新 | `17.0.13`(2026-09-01 发布) | `npm view` |
|
||||
| `react-i18next` peerDeps | `react: >= 16.8.0`, `i18next: >= 26.2.0`, `typescript: ^5\|\|^6\|\|^7` | `npm view react-i18next peerDependencies` |
|
||||
| `i18next` 最新 | `26.4.2` | `npm view i18next version` |
|
||||
| `i18next` peerDeps | 无 react peer(仅 `typescript`) | `npm view i18next@latest peerDependencies` |
|
||||
|
||||
**兼容性判定**:React 19.2.8 ≥ 16.8.0,故 `react-i18next@17` 与 `i18next@26` 满足 peer 约束。**建议锁定范围**:`react-i18next@^17.0.13` + `i18next@^26.4.2`(Wave 1 由 Agent 4 写入,遵循 D12 单一写入者)。不采用旧版 15/16 行,避免为规避问题而引入历史缺陷。
|
||||
|
||||
> 说明:以上为**只读** `npm view`(不安装、不改依赖)。实际安装验证由 Wave 1 Agent 4 承担(见 `POC_REPORT.md` PoC-1)。
|
||||
|
||||
---
|
||||
|
||||
## 2. Provider 层级与 `src/i18n/**` 模块划分
|
||||
|
||||
### 2.1 Provider 放置层级
|
||||
|
||||
`I18nProvider` 放**根 `src/app/layout.tsx`,作为最外层 Provider**(在既有 `ThemeProvider` 之外或与其并列的最上方)。理由:
|
||||
|
||||
- 根 layout 渲染 `<html>`,`I18nProvider` 在此可统一同步 `document.documentElement.lang`(§4)。
|
||||
- 所有路由组(`(dashboard)`、`chat`、`connect`、`login`、`onboarding` 等)都经由根 layout,各自无需重复挂 Provider。
|
||||
- `I18nProvider` 是 `use client` 组件;因 Next.js 中根 layout 本身是 server component,需用一个 `"use client"` 的子组件包装。页面内部不含服务端下发的动态 locale,故无需用全局 server/client provider 分支。
|
||||
|
||||
设计目标示意(**非当前代码,禁止修改**):
|
||||
|
||||
```tsx
|
||||
// src/app/layout.tsx(设计目标)
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={inter.className}>
|
||||
<I18nProvider> {/* 新增:最外层,use client 包装 */}
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem disableTransitionOnChange>
|
||||
<NuqsAdapter>
|
||||
<ReactQueryProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
<Toaster />
|
||||
</ReactQueryProvider>
|
||||
</NuqsAdapter>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### 2.2 `src/i18n/**` 模块划分(Ownership:Agent 4,永久,见 FILE_OWNERSHIP)
|
||||
|
||||
```
|
||||
src/i18n/
|
||||
├── index.ts # 公开出口:导出 I18nProvider 与 getI18n/useI18n 便捷 API
|
||||
├── I18nProvider.tsx # "use client" Provider:初始化实例 + 语言就绪门禁 + <html lang> 同步
|
||||
├── i18n.ts # createI18n(): 构建/复用 i18next 单例(resources、fallbackLng、supportedLngs)
|
||||
├── localePreferences.ts# 读/写语言偏好(cookie + localStorage 双层 + 浏览器语言嗅探)——纯函数,可单测
|
||||
├── detectLocale.ts # 语言解析/规范化('zh'→'zh-CN'、大小写、支持列表校验)
|
||||
├── types.d.ts # 全局声明增强:模块扩展 'i18next' 的 CustomTypeOptions(资源键类型安全)
|
||||
└── resources/registry.ts # === namespace 注册表 + 资源加载映射(唯一真源)=== 见 §6.2
|
||||
```
|
||||
|
||||
职责边界:
|
||||
|
||||
- `i18n.ts` 持有 i18next **单例**的生命周期(`initReactI18next`、`init`)。为保证 SSR 静态构建与 HMR 稳定,导出一个惰性 `getI18n()` 而非模块顶层 `init`(避免重复初始化)。
|
||||
- `localePreferences.ts` 不依赖 React,纯逻辑(读 cookie/localStorage/navigator),供单测覆盖 D5 优先级。
|
||||
- `resources/registry.ts` 是**唯一的 namespace 注册表 + 资源加载映射**:声明「locale ↔ namespace ↔ json 路径」,并导出给 `i18n.ts` 构造 resources。功能 Agent(A5/A6)**只往 json 加 key,不改此注册表**(FILE_OWNERSHIP 关键规则 1/6)。
|
||||
- `types.d.ts` 用 `CustomTypeOptions`(`defaultNS`、`resources`)让 `t()` 的 key 具备类型约束;英文资源为类型真源(D2:en 为真源)。
|
||||
|
||||
---
|
||||
|
||||
## 3. locale 初始化 与《语言偏好优先级》实现映射
|
||||
|
||||
### 3.1 优先级实现映射(D5 → 代码)
|
||||
|
||||
规范化后的优先级链(`detectLocale.ts + localePreferences.ts`):
|
||||
|
||||
```text
|
||||
用户主动选择(显式写入偏好存储)
|
||||
→ 用户级 UI 设置(后端 UI settings.language —— v1 不采用,见 §7)
|
||||
→ SameSite cookie / localStorage(双层)
|
||||
→ 浏览器语言(navigator.languages,经 supportedLngs 过滤)
|
||||
→ 英文 en(兜底)
|
||||
```
|
||||
|
||||
实现要点:
|
||||
|
||||
- **用户主动选择 vs 残留存储的区分**:用户在切换器显式选择语言时,`setLocale` 同时写 **cookie + localStorage** 并 `changeLanguage`。读取时把「cookie/localStorage 中存在显式写入的 locale」视为「用户主动选择」。为区分「显式选择」与「自动探测回写」,可用单独的 marker(如 cookie 值带 `explicit` 标记,或 localStorage 键与探测键分离)。**v1 建议**:统一用一个偏好键(如 `dashboard.locale`),只要该键存在即为用户选择;首次无键时才走浏览器语言。该判定需在 §3.2 明确,避免「用户从不手选」时被浏览器语言误判为主动选择。
|
||||
- **cookie 属性**:`SameSite=Lax`(非 Strict,避免 Okta/SSO 回跳丢失)、`path=/`、非 httpOnly(前端需读,D4 允许)。语言不敏感隐私,不设 Secure 亦可在 http 下工作;生产建议 `Secure`(见 TECH_RISKS R8)。
|
||||
- **无后端用户级设置**:P1/P2 结论为 v1 不使用 `UI settings.language`(§7),故优先级链简化为「显式选择 → cookie/localStorage → 浏览器语言 → en」。
|
||||
- **i18next 配置**:`supportedLngs: ['en','zh-CN']`、`fallbackLng: 'en'`、`nonExplicitSupportedLngs: false` + 在 `detectLocale` 中把 `navigator.language` 规范化为 `zh-CN`(`'zh'`/`'zh-Hans'`→`'zh-CN'`;其余→en)。load 路径由 registry 提供静态 resources,不使用 `loadPath` 网络加载(静态导出下目录外资源不可用)。
|
||||
|
||||
### 3.2 关键判定汇总(供 PoC 与后续澄清)
|
||||
|
||||
| # | 判定 | v1 默认 | 依据 |
|
||||
|---|---|---|---|
|
||||
| L1 | 偏好键唯一性 | 单一键 `dashboard.locale`,存在即视为显式选择 | §3.1 |
|
||||
| L2 | 浏览器语言含义 | 仅首次(无偏好键)时使用 | §3.1 |
|
||||
| L3 | cookie 属性 | `SameSite=Lax; path=/;`(生产加 `Secure`) | §3.1 / R8 |
|
||||
| L4 | 根 layout 的 `lang` 静态默认 | `en`(与静态 HTML 一致) | §4 |
|
||||
| L5 | 管理员全局覆盖用户选择 | v1 不实现(无该字段) | §7 / D8 |
|
||||
|
||||
---
|
||||
|
||||
## 4. `<html lang>` 同步 与 首屏策略(G0/G1 门禁,必须选定)
|
||||
|
||||
### 4.1 首屏策略:**选定「语言就绪门禁 + 挂载后同步 `<html lang>`」**,不采用初始化脚本、不接受短暂切换。
|
||||
|
||||
从候选「初始化脚本 / 语言就绪门禁 / 接受短暂切换」中,**选定「语言就绪门禁」(并且不在 `<head>` 内注入内联脚本)**。理由:
|
||||
|
||||
- **初始化脚本(在静态导出下基本无效)**:该类脚本需在 next 的 `<script>` 阶段于头部内联以在 paint 前改写 `lang`/文本。但静态导出没有服务端可注入的 head 时机,且「切换到中文」需要**运行时加载 zh-CN 资源**——这只能在 client JS 就绪后通过 i18next 完成,内联脚本无法在现代打包下前置加载 JSON 资源并替换整棵 textContent。故初始化脚本收益低。
|
||||
- **接受短暂切换**:会把「首帧英文→JS 就绪后切中文」的闪烁当作常态,直接违反 D7「禁止首屏显示原始 key 后再替换」的精神(§5 进一步禁止 key 暴露)。不可接受。
|
||||
- **语言就绪门禁**最符合静态导出约束:首帧即是合法的英文 UI(D2 默认语言为 en,D8 title 为英文),不存在「错误语言闪烁」;JS 就绪后 i18next 收敛到偏好语言并同步 `<html lang>`。就绪门禁只是**短于 paint 的少量额外延时**,且保证「看到的就是最终语言」或「简短英文首帧后一次切换」。
|
||||
|
||||
### 4.2 就绪门禁实现草图(设计目标)
|
||||
|
||||
`I18nProvider` 在挂载时:
|
||||
|
||||
1. 读取偏好(§3)→ 得到目标语言 `target`。
|
||||
2. 若 `target === 'en'`:`i18next.changeLanguage('en')`,`<html lang>` 已为 `en`,**无需门禁**,直接渲染 children。
|
||||
3. 若 `target === 'zh-CN'`:`await i18next.changeLanguage('zh-CN')`(resources 已在 `init` 时注册,changeLanguage 为同步内部切换完的 `t` 就绪),当 `i18n.isInitialized && i18n.language === 'zh-CN'` 时为「就绪」。就绪前渲染一个**轻量就绪态**(如 `aria-busy` 的空壳/骨架,不渲染依赖 `t()` 的业务内容),就绪后渲染 children 并设置 `document.documentElement.lang = 'zh-CN'`。
|
||||
|
||||
```tsx
|
||||
// src/i18n/I18nProvider.tsx(设计目标)
|
||||
"use client";
|
||||
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const [ready, setReady] = useState(() => resolveTarget() === "en"); // en 直接就绪
|
||||
useEffect(() => {
|
||||
const target = resolveTarget(); // localePreferences + detectLocale
|
||||
getI18n().then(async (i18n) => {
|
||||
if (target === "zh-CN") await i18n.changeLanguage("zh-CN");
|
||||
document.documentElement.lang = i18n.language; // 与 <html lang> 同步
|
||||
setReady(true);
|
||||
});
|
||||
}, []);
|
||||
if (!ready) return null; // 或轻量就绪态;首帧为 en 时立即渲染
|
||||
return children; // 实际会包一层 I18nextProvider/useSSR(false)
|
||||
}
|
||||
```
|
||||
|
||||
要点:
|
||||
- **`<html lang>` 同步**:`document.documentElement.lang = i18n.language`,在 `changeLanguage` 完成后、children 渲染前执行,使浏览器/屏幕阅读器解析到的语言与实际内容一致。
|
||||
- 不依赖内联脚本、不动 `metadata`(D8 保持英文 title)。
|
||||
- 就绪态渲染 null 或骨架,**不渲染任何 `t()`/`<Trans>` 内容**,天然杜绝「key 先暴露」(§5)。
|
||||
|
||||
### 4.3 PoC 验证
|
||||
|
||||
首屏切换的判定证据与测量由 Wave 1 执行(见 `POC_REPORT.md` PoC-2/PoC-3):重点验证「en 默认首帧合法」、「切 zh-CN 后 `<html lang>` 更新」、「就绪门禁下业务内容不在就绪前出现」。
|
||||
|
||||
---
|
||||
|
||||
## 5. 普通 `t()` 与 `<Trans>` 的资源就绪门禁
|
||||
|
||||
### 5.1 统一门禁原则
|
||||
|
||||
**禁止首帧暴露原文 key。** 凡渲染翻译内容的组件,必须保证 i18next 资源就绪后其 `t`/`<Trans>` 才被求值。承接 §4.2 的顶层就绪门禁,业务组件**默认**就处于就绪之后(因为 `I18nProvider` 就绪前不渲染 children)。在此基础上再加两层保障:
|
||||
|
||||
1. **资源键集合约束**:英文 `en/*.json` 是类型与资源真源(D2),`t()` 的 key 受 `CustomTypeOptions` 类型校验。**只允许引用已注册 namespace 的 key**——key 不存在会在类型层报错,从而无法「引用一个没资源的 key」(见 §6 的 en/zh 键一致性检查 + `test:types`)。
|
||||
2. **运行时兜底**:i18next 对缺失 key 默认回退 `fallbackLng='en'`;若 en 亦缺,`returnNull`/`returnEmptyString` 策略应配置为**返回 key 本身或空串且不渲染原文句子**,并在开发模式打 warning 以便早发现。**绝不允许把「英文句子」作为 key**(§3.4 语义 key 规范),从根上防止「key=原文→暴露原文」的伪就绪。
|
||||
|
||||
### 5.2 `<Trans>` 的门禁
|
||||
|
||||
- `<Trans>` 依赖资源同步就绪(§3.1:必须在资源同步就绪或统一 ready 门禁之后渲染)。
|
||||
- 因 §4.2 顶层门禁已保证业务子树在就绪后才挂载,`<Trans>` 不会有「先渲染占位再替换」窗口。
|
||||
- 若个别模块需要在自己内部再等,使用 `useTranslation` 的 `ready` 标志:`const { t, ready } = useTranslation('ns'); if (!ready) return skeleton;`。**推荐所有激进的内联文案一次性接入统一门禁,避免每个组件各自开花**;组件级 `ready` 仅用于少数在就绪后有异步依赖的场景。
|
||||
- `<Trans>` 复数/插值注意事项:zh 不建形态复数分支(D9),用 `{{count}}` 计数插值;`<Trans>` 内嵌组件(如带链接)需保证 `i18n` 与组件 props 的 children 均就绪。
|
||||
|
||||
### 5.3 断言/测试
|
||||
|
||||
- 集成测试以「首帧(未就绪)不出现 key 原文 / 不出现未翻译句」为断言(见 POC_REPORT PoC-4 与测试方案章节;由 Agent 3 细化)。
|
||||
|
||||
---
|
||||
|
||||
## 6. `src/locales/{en,zh-CN}/**` 组织 与 namespace 注册
|
||||
|
||||
### 6.1 目录组织
|
||||
|
||||
```
|
||||
src/locales/
|
||||
├── en/
|
||||
│ ├── common.json # 全局公共文案(A4 Wave1 骨架 → G1 后 A5)
|
||||
│ ├── navigation.json
|
||||
│ ├── auth.json
|
||||
│ ├── models.json
|
||||
│ ├── apiKeys.json
|
||||
│ ├── usage.json
|
||||
│ ├── cost.json
|
||||
│ └── budgets.json
|
||||
└── zh-CN/
|
||||
└── (同名 json,键集合与 en 一致)
|
||||
```
|
||||
|
||||
- **namespace = 文件名 = key 冒号前缀**(§3.4:如 `common:action.delete` 对应 `common.json` 的 `action.delete`)。
|
||||
- 英文与中文**键集合必须一致**(§3.4;用 CI 键集合 diff 校验,见 R9)。
|
||||
- 中文按语义组织,不机械复制英文复数结构(D9)。
|
||||
|
||||
### 6.2 namespace 注册表 / 资源加载映射(唯一真源,Agent 4 永久 Owner)
|
||||
|
||||
`src/i18n/resources/registry.ts` 以数组/映射声明全部 namespace 及各 locale 的静态资源对象(`import common from "@/locales/en/common.json"`),供 `i18n.ts` 构造 `resources`。**往该注册表新增/删除 namespace 只有 Agent 4 可改**(FILE_OWNERSHIP 规则 6)。功能 Agent 新增业务 namespace 时在任务单填注册需求,由 A0 指派 A4 注册。
|
||||
|
||||
登记形式(设计目标):
|
||||
|
||||
```ts
|
||||
// src/i18n/resources/registry.ts
|
||||
export const NAMESPACES = ["common", "navigation", "auth", "models", "apiKeys", "usage", "cost", "budgets"] as const;
|
||||
export type Namespace = (typeof NAMESPACES)[number];
|
||||
export const RESOURCES = {
|
||||
en: { common, navigation, auth, models, apiKeys, usage, cost, budgets }, // 惰性 import
|
||||
"zh-CN": { common, navigation, auth, models, apiKeys, usage, cost, budgets },
|
||||
} as const satisfies Record<Locale, Record<Namespace, object>>;
|
||||
```
|
||||
|
||||
`types.d.ts` 引用 `RESOURCES['en']` 作为 `CustomTypeOptions['resources']`,保证 `t('common:action.delete')` 有类型。
|
||||
|
||||
---
|
||||
|
||||
## 7. 后端 `UI settings.language` 调查结论(P1/P2)
|
||||
|
||||
本机只读调查(`litellm/` 后端代码,2026-09-09):
|
||||
|
||||
- **`UISettings` 模型**(`litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py`)是代理级(管理员/全局)UI 配置,经 `/get/ui_settings`、`/update/ui_settings` 暴露,落在 `LiteLLM_UISettings` 表(`litellm/repositories/table_repositories.py:UISettingsRepository`,表名 `LiteLLM_UISettings` 见 `litellm/proxy/_types.py`)。
|
||||
- 该模型有 `model_config = ConfigDict(extra="allow")`(可携带额外字段),但**持久化受 `ALLOWED_UI_SETTINGS_FIELDS` 白名单约束**,而该白名单**不包含 `language` 字段**。白名单现有字段如 `disable_model_add_for_internal_users`、`enabled_ui_pages_internal_users`、`enable_chat_ui` 等,均与语言无关。
|
||||
- 即:**后端当前不存在可用的 `UI settings.language` 全局语言设置**。即便请求携带 `language`,也不在白名单内,不会被持久化/下发。
|
||||
|
||||
**结论:**
|
||||
- **P2(该字段是否真实存在)**:**不存在**。`language` 不在 `UISettings` 白名单。
|
||||
- **P1(管理员全局设置是否覆盖用户主动选择)**:**v1 不实现该覆盖**,因为字段不存在。优先级链退化见 §3.1。若未来后端新增该字段,需产品决策「管理员全局 vs 用户选择」的覆盖语义,再补实现(本设计已预留 L5 判定位)。
|
||||
- 这同时**简化了 v1**:无需「用户偏好 vs 管理员全局」的合并/剥离逻辑,也避免在静态导出下引入「先取全局再取用户」的两段式异步。
|
||||
|
||||
---
|
||||
|
||||
## 8. 与既有 Provider / 路由组 layout 的接缝
|
||||
|
||||
- 根 `layout.tsx`:现为 `ThemeProvider → NuqsAdapter → ReactQueryProvider → AuthProvider`。`I18nProvider` 追加为**最外层**(§2.1)。**不移动**现有 Provider 顺序,避免影响 next-themes 首帧打 class 的既有行为。
|
||||
- `(dashboard)/layout.tsx`:`use client` 路由组,另含 `SidebarProvider`、`ThemeContext`、`PluginModeContext` 等,均用到 `useAuth`。它们处于 `AuthProvider` 之下,`I18nProvider` 在最外层 → 这些组件可安全调用 `useTranslation`。**注意**:该 layout 自身是 `use client`,若其中出现硬编码文案需在 Wave 2 起中文化(A5/A6 职责)。
|
||||
- `chat/layout.tsx`、`connect/layout.tsx` 独立于 `(dashboard)`,仍归根 layout 的 `I18nProvider` 管理,无需各自挂 Provider。
|
||||
- **`<Trans>` 与 next-themes 的首帧 class 互不干扰**:`suppressHydrationWarning` 已覆盖 `<html>` 的未知属性(theme class / lang),本方案不加额外 suppress。
|
||||
|
||||
---
|
||||
|
||||
## 9. 依赖变更清单(仅供 Wave 1 Agent 4 执行,本设计不落地)
|
||||
|
||||
| 变更 | 目标值 | 写入者 |
|
||||
|---|---|---|
|
||||
| 新增依赖 | `i18next@^26.4.2`、`react-i18next@^17.0.13` | A4(D12) |
|
||||
| 类型依赖 | 无需额外类型包(自带 TS 类型) | A4 |
|
||||
| `next.config.mjs` | **不改**(静态导出已满足) | — |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:门禁对照
|
||||
|
||||
| 门禁 | 本设计的对应物 |
|
||||
|---|---|
|
||||
| G0(基线评审) | 本文档 §1–§8 供评审;D7 首屏策略已选定(§4.1) |
|
||||
| G1(平台能力) | §6.2 注册表/资源映射/类型归 A4;PoC 清单(POC_REPORT)由 A4/A7 执行并回填 |
|
||||
186
docs/i18n/I18N_TEST_PLAN.md
Normal file
186
docs/i18n/I18N_TEST_PLAN.md
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
# i18n 测试总体策略(I18N_TEST_PLAN)
|
||||
|
||||
> 角色:Agent 3 `qa-architect`(测试架构与验收设计)
|
||||
> 基线:`I18N_MULTI_AGENT_PLAN.md` §10 测试与工程门禁、§11 完成定义
|
||||
> 实施:Agent 7 `i18n-qa` 在 Wave 1+ 按本文档落地
|
||||
> 状态:G0 待评审
|
||||
|
||||
## 0. 目标与边界
|
||||
|
||||
本文件定义 LiteLLM Dashboard `en` / `zh-CN` 国际化的测试总体策略:三层测试边界、key 一致性与硬编码扫描、中文布局检查、可访问性文案覆盖,以及自动化与人工程度的边界。
|
||||
|
||||
**纯设计输入**:本文件与配套 `TEST_CASES.md`、`TEST_TOOLS.md`、`REGRESSION_MATRIX.md` 只规定"测什么、怎么测、如何验收",不修改任何产品代码,不实际执行测试。实际执行由 Agent 7 在 Wave 1+ 负责。
|
||||
|
||||
## 1. 三层测试边界(与项目现有 tier 对齐)
|
||||
|
||||
### 1.1 事实校准
|
||||
|
||||
项目 `vitest.config.ts` 定义 4 个 vitest project:
|
||||
|
||||
| vitest project | 环境 | include | 对应"概念层" |
|
||||
|---|---|---|---|
|
||||
| `unit` | node | `src/**/*.test.ts`, `tests/**/*.test.ts`(非渲染) | 逻辑单元(无 DOM) |
|
||||
| `component` | jsdom | `src/**/*.test.tsx`, `tests/**/*.test.tsx`(非 `*.integration.test.tsx`) | 组件单元(毫秒级) |
|
||||
| `integration` | jsdom | `*.integration.test.tsx`(含 `tests/**`) | 集成(秒级) |
|
||||
| `types` | tsc | `*.test-d.ts` / `*.test-d.tsx` | 类型测试(`npm run test:types`) |
|
||||
|
||||
CLAUDE.md 提到的三档(unit / integration / E2E)与 vitest 的 `unit`+`component`+`integration` 需在测试文档中统一术语:**本文档"单元层"泛指 vitest `component` project 的 jsdom 单组件测试(`*.test.tsx`),"逻辑单元层"特指 `unit` project 的 node 逻辑测试(`*.test.ts`)**。E2E 为 Playwright(见 §1.4)。
|
||||
|
||||
工程命令(方案 §10):
|
||||
|
||||
```bash
|
||||
cd ui/litellm-dashboard
|
||||
npm run lint # eslint .
|
||||
npm run format:check # prettier 校验
|
||||
npm run build # next build(静态导出)
|
||||
npm run test:types # vitest run --project types
|
||||
```
|
||||
|
||||
Vitest 定向执行:只跑改动相关文件;单元/组件层毫秒级、集成秒级;禁止无路径全量 `vitest run`。
|
||||
|
||||
### 1.2 单元层(component project,`*.test.tsx`)
|
||||
|
||||
i18n 场景中,单元层只负责"单一模块、单一微行为",依赖以 double 替换。
|
||||
|
||||
i18n 场景下的单元层**边界**:
|
||||
- **locale 工具函数**(纯函数,放 `unit` project 的 `*.test.ts`):语言选择合并逻辑(优先级合并)、`<html lang>` 计算、浏览器语言映射、首选项写入 cookie/localStorage 的序列化与解析。
|
||||
- **插值/复数/格式化纯函数**(`*.test.ts`):`count=0/1/2`、日期、数字、货币的格式化结果,无 DOM。
|
||||
- **单个 UI 小组件**(`*.test.tsx`):语言切换器按钮、单个翻译标注组件(占位)、量词插值组件,使用 i18next 的 `initReactI18next` 测试实例注入最小字典(en+zh),断言渲染文本。**不渲染整页、不跨组件**。
|
||||
- 计数器本地化组件(D9):`count=0` → `0 个`,`count=1` → `1 个`,`count=2` → `2 个`。
|
||||
|
||||
单元层不做:整页断言、真实网络、`<html>` 全局状态、路由跳转。
|
||||
|
||||
### 1.3 集成层(`*.integration.test.tsx`)
|
||||
|
||||
集成层渲染**真实组件树**,仅 stub 网络边界,验证"单元测试无法触达的接线"。
|
||||
|
||||
i18n 场景下的集成层**边界**:
|
||||
- 根应用/页面级内容:`I18nProvider` 包裹下,Navbar / Leftnav / Login / Models / Usage 等页面在切换语言后**即时**更新(真实组件树 + Provider 链)。
|
||||
- 语言切换不丢失表单状态:在集成树中填写字段 → 切换语言 → 断言字段值仍在、未触发无关请求。
|
||||
- 动态插值与复数在组件级布线正确(由单元层证明逻辑,集成层证明"字段→key→渲染"链路)。
|
||||
- `<Trans>` 组件在资源就绪后渲染(不先出原始 key)。
|
||||
|
||||
集成层不做:真实浏览器、真实 `document.title` 持久化之外的行为、整页导航刷新行为。
|
||||
|
||||
**命名**:`Foo.integration.test.tsx`,`vitest run --project integration <path>`。
|
||||
|
||||
### 1.4 E2E 层(Playwright,`tests/e2e/ui/`)
|
||||
|
||||
E2E 覆盖"真实浏览器 + 真实静态导出站点/活 proxy"下的端到端行为。**当前仓库不存在 Playwright 基础设施**(无 `playwright.config.*`、无 `tests/e2e/ui/`、无 `@playwright/test` 依赖),这是 Agent 7 在 Wave 1 必须补齐的平台缺口(需要 `package.json` 变更时,须由 Agent 0 指派唯一 Owner,遵守 §6.1 依赖单一写入者约束)。
|
||||
|
||||
E2E 负责的 i18n 行为:
|
||||
- **默认语言**:首次访问(无偏好)→ 英文 `en`。
|
||||
- **切换即时生效**:真实浏览器点击切换 → `<html lang>` 更新 → 页面文本更新。
|
||||
- **刷新保持**:切换 → `page.reload()` → 语言保持。
|
||||
- **整页跳转恢复**:Login / SSO / MCP OAuth 整页跳转并返回后语言保持(同源 cookie/localStorage 恢复)。
|
||||
- `<html lang>` 与当前语言一致。
|
||||
- 缺 key 回退英文、不显示原始 key。
|
||||
- 构建期 `<title>` / meta description 保持英文。
|
||||
|
||||
E2E 在静态导出下运行(`npm run build` 产生 `out/`)。Playwright 配置不在 vitest 内;作为独立阶段与 `npm run build` 串联,在 Wave 4A 完整回归使用。
|
||||
|
||||
### 1.5 三层分工示意
|
||||
|
||||
| 行为 | 单元 | 集成 | E2E |
|
||||
|---|---|---|---|
|
||||
| 插值/复数/格式化正确性 | ✅ 逻辑纯函数 | — | 抽查 |
|
||||
| 切换语言组件渲染 | ✅ | ✅ 真实树 | ✅ 真实浏览器 |
|
||||
| 表单状态保持 | — | ✅ | 抽查 |
|
||||
| 刷新语言保持 | — | 🔸(jsdom 有限) | ✅ |
|
||||
| 整页跳转恢复 | — | — | ✅ |
|
||||
| `<html lang>` | ✅ 计算函数 | ✅ Provider 接线 | ✅ 浏览器 DOM |
|
||||
| `<title>`/meta 英文 | — | — | ✅ |
|
||||
|
||||
## 2. key 一致性与硬编码扫描
|
||||
|
||||
### 2.1 key 集合一致性校验
|
||||
|
||||
自动校验 `en` 与 `zh-CN`(以及后续任何 locale)的字典 key 集合完全一致,即对所有 namespace:
|
||||
|
||||
```
|
||||
keys(en/<ns>.json) == keys(zh-CN/<ns>.json)
|
||||
```
|
||||
|
||||
- 语义 key(`common:action.delete`)必须成对存在;存在偏置即失败,连同**缺 key 清单**一起输出,退出码非 0。
|
||||
- 同时校验"被引用的 key ⊆ 已声明 key"(组件 `t('ns:key')` / `useTranslation('ns')` 引用 vs 字典存在性),防止静态分析漏检的悬空 key 在运行时回退英文却被误当业务缺失。
|
||||
- 结构与值类型也必须一致(嵌套层级、插值变量 `{{x}}` 集合一致,避免 en 有 `{{count}}` 而 zh 漏掉)。
|
||||
- 运行方式:独立 node 脚本(见 `TEST_TOOLS.md`),纳入 CI 或门禁 G3 的"中英文 key 集一致"。
|
||||
|
||||
### 2.2 硬编码字符串扫描(只报告、不自动改写)
|
||||
|
||||
按方案 §3.4:扫描工具**只报告候选硬编码,不自动生成 key,不自动改写代码**。Agent 7 实现时严格遵守该约束——扫描结果进入报告,人工/产品决定是否改写。
|
||||
|
||||
扫描器规则(对 TSX/TS 源码):
|
||||
- 识别疑似承载用户可见文案的 `JSXText`(非注释、非空白、非纯符号)。
|
||||
- 识别 `aria-label`、`title`、`placeholder`、`alt` 等可访问性/文案属性中的字符串字面量。
|
||||
- 识别 `t('...')` 之外 `useMessage`/`useToast`/`toast(...)` 等常见未国际化文案入口(以项目现状校准)。
|
||||
- 排除:模型名/API 字段/日志/代码示例引用、路径、正则、CSS 类名、`data-slot`、纯数值/符号、标识符。
|
||||
- 输出:文件、行、候选文案、所属 namespace 建议(仅供人工参考),**不自动写入字典**。
|
||||
|
||||
扫描器产出人工 review 清单,用于判断"应改为 `t()` 的遗漏硬编码"。
|
||||
|
||||
### 2.3 误翻译保护
|
||||
|
||||
- 反向断言:模型名、API 字段、日志原文、代码示例在 zh 与 en 下渲染一致(不被翻译)。见 `TEST_CASES.md` TC 对应项。
|
||||
- 扫描器补充规则:对 `en.json` / `zh-CN.json` 的 value 做"疑似把模型名/字段名当值"的启发式标注(如值与标识符/模型名称重合),仅供人工复核。
|
||||
|
||||
## 3. 中文布局检查矩阵
|
||||
|
||||
中文文案通常比英文短,但存在换行、量词、长词(如"成本跟踪"、英文品牌名混排)导致溢出/截断/重叠的风险。逐模块(导航/表格/弹窗/表单/全局壳层)检查:
|
||||
|
||||
| 区域 | 检查维度 | 断言/观察项 |
|
||||
|---|---|---|
|
||||
| Navbar / Leftnav | 溢出、折行、截断 | 菜单项不换行错位、品牌/Menu 图标不重叠;窄屏不横向溢出 |
|
||||
| 表格(Models/API Keys/Usage/Cost/Budgets) | 列宽截断、单元格换行 | 中文表头不截断语义、值不溢出列边界、tooltip 不遮挡 |
|
||||
| 弹窗/Modal | 溢出、空白、遮挡 | 标题/正文/按钮在视口内、无横向滚动条溢出、Close 按钮可点 |
|
||||
| 表单 | label 对齐、placeholder 长度 | label 不折行错位、error 提示不截断、placeholder 不溢出控件 |
|
||||
| 全局壳层 | 用户菜单、语言切换器 | 中文菜单项不截断,切换器标签完整 |
|
||||
|
||||
检查粒度:
|
||||
- **自动化**:E2E/组件层断言"内容可读但不溢出容器"(无横向滚动、`scrollWidth <= clientWidth`、关键文本 `toBeVisible`),以及对特定元素做快照对比。
|
||||
- **人工**:跨语言并列渲染的视觉走查(Agent 2 术语体验复核 + Agent 7 UI 检查),覆盖窄屏与中文长字符串用例;`V1_SCOPE_MANIFEST.md` 的 `UI 检查` 栏填写证据。
|
||||
|
||||
矩阵逐项仍落地于 `REGRESSION_MATRIX.md`。
|
||||
|
||||
## 4. 可访问性文案覆盖要求
|
||||
|
||||
i18n 不只是可见文本;`aria-label`、`title`、`placeholder`、`alt` 等必须随语言本地化(方案 §5.6 同步处理要求):
|
||||
|
||||
- **每处用户可见文本都应有可访问等效**:图标按钮、关闭按钮、导航折叠按钮等必须提供本地化的 `aria-label`(`aria-hidden` 或纯装饰除外)。
|
||||
- **辅助文本本地化**:`title`/tooltip、`placeholder`、校验 error message、`role="meter"` 的 `aria-valuenow`(用 `toHaveAttribute` 断言,避免 `toHaveValue` 误用——CLAUDE.md 约定)均本地化。
|
||||
- **语序不破坏可访问性**:`aria-label` 与可见文本一致;`aria-labelledby` 指向本地化 label。
|
||||
- **测试断言**:使用可访问查询(`getByRole`/`getByLabel`/`getByText`)优先,其次 `data-slot`;不使用 `eslint --fix` 批量修正 testing-library/jest-dom(CLAUDE.md 约定),`toHaveTextContent` 传 plain string 子串匹配。
|
||||
- **英文回退对 a11y 文案同样生效**:zh 缺失时 `aria-label` 等回退英文,不显示原始 key。
|
||||
|
||||
## 5. 默认语言 / 刷新 / 首屏策略的测试侧重
|
||||
|
||||
- **默认语言**:无任何偏好时 = `en`;这是最终回退,任何层级测试都以此为基线。
|
||||
- **刷新保持**(E2E):`page.reload()` 后仍为当前语言;核实存储介质(SameSite cookie 或 localStorage——CLAUDE.md 禁 token,但语言偏好非敏感;偏好介质以架构决策为准)。
|
||||
- **首屏闪烁**:`<Trans>`/`t()` 在资源就绪后渲染,首帧不暴露原始 key(方案 §3.4/§10)。E2E 首次导航截图断言不出现 `namespace:...` 原始 key 文本。
|
||||
- **`<html lang>`**:与当前语言一致(`en` 或 `zh-CN`);组件层与 E2E 均断言。
|
||||
|
||||
## 6. 自动化 / 人工边界总结
|
||||
|
||||
| 事项 | 自动化 | 人工 |
|
||||
|---|---|---|
|
||||
| 单元/组件逻辑 | ✅ | — |
|
||||
| 集成布线 | ✅ | — |
|
||||
| E2E(切换/刷新/跳转/lang/title) | ✅ | — |
|
||||
| key 集合一致性 | ✅(脚本,CI) | — |
|
||||
| 硬编码扫描 | ✅(脚本报告) | ▲ 决定是否改写 |
|
||||
| 中文布局视觉 | 🔸(溢出断言) | ▲ 跨语言走查 |
|
||||
| 翻译质量/术语 | — | ▲ Agent 2 复核 |
|
||||
| 误翻译判断 | 🔸(启发式) | ▲ 人工复核 |
|
||||
|
||||
## 7. 测试数据与工具接口约定
|
||||
|
||||
- 测试用最小字典:单元/集成层注入 `en`+`zh-CN` 的最小命名空间子集,不足最小集时直接断言英文回退。
|
||||
- 时间固定:日期/货币断言使用固定 locale 与固定时间源(fake timer),避免时区/运行时刻不稳定。
|
||||
- 存储抽象:偏好读写针对于可注入的 storage/cookie 抽象 double,避免测试污染真实 localStorage。
|
||||
|
||||
## 8. 门禁映射
|
||||
|
||||
- **G0**:本策略与 `TEST_CASES.md` 通过 Review。
|
||||
- **G1**:平台层测试(切换/回退/`<html lang>`/首屏/整页跳转)与 key 一致性脚本具备;`npm run build` + 定向 vitest 通过。
|
||||
- **G2/G3**:各模块测试、UI 检查、`V1_SCOPE_MANIFEST.md` 证据齐,中英文 key 集一致。
|
||||
- **G4**:`REGRESSION_MATRIX.md` 全量回归通过(Agent 7 报告 + Agent 8 汇总)。
|
||||
84
docs/i18n/LANGUAGE_SWITCHER_SPEC.md
Normal file
84
docs/i18n/LANGUAGE_SWITCHER_SPEC.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# 语言切换器详细设计(Language Switcher Spec)
|
||||
|
||||
> 维护者:Agent 2(localization-designer)
|
||||
> 状态:设计稿,待 G0 评审
|
||||
> 关联:`I18N_MULTI_AGENT_PLAN.md`(§1.1/D5/D6)、`DECISIONS.md`、`LOCALIZATION_SPEC.md` §1、`LOCALE_NAVIGATION_BEHAVIOR.md`
|
||||
|
||||
本文档定义语言切换器的**入口位置、显示形式、状态、交互、持久化与可访问性**。它是供 Agent 5(壳层开发)实现的交互契约。
|
||||
|
||||
---
|
||||
|
||||
## 1. 入口位置(建议)
|
||||
|
||||
v1 只有一个入口,放在**顶部导航栏(Navbar)右侧**,靠近用户/账号菜单,以便登录后各页都能发现。
|
||||
|
||||
- 首选位置:`navbar.tsx` 右端、用户菜单图标左侧,作为独立图标按钮。
|
||||
- 备选:若 Navbar 空间受限(静态导出、窄屏),回退到 `SidebarAccountMenu` 账号弹层内放置文字切换。
|
||||
- **登录/引导页(Login、Onboarding、Connect、MCP OAuth)也必须有入口**,因为用户可能在这些页面首次选择语言。建议在这些页面顶部/右上角放置同一图标按钮(复用同一组件),与 Dashboard 主体入口共用组件逻辑。
|
||||
|
||||
> 实现要求:切换器组件做成可复用的独立组件(如 `src/components/LanguageSwitcher.tsx`),在 Navbar/账号菜单/登录页引用,避免多份复制。
|
||||
|
||||
## 2. 显示文案
|
||||
|
||||
- 按钮仅显示图标 + 当前语言的**目标语言缩写**,用于明确"点一下会变成什么",同时按钮自身可在弹层中切换。
|
||||
- 展示为 **「中文」/「EN」**:
|
||||
- 当前为 `en` 时,按钮显示 **中文**(提示可切到中文)。
|
||||
- 当前为 `zh-CN` 时,按钮显示 **EN**(提示可切到英文)。
|
||||
- 展开菜单(Popover/Menu)时,提供两行完整选项:`English` 与 `简体中文(zh-CN)`,当前语言行加选中态(勾选/高亮)。
|
||||
- 若空间允许,可在按钮前显示一个地球图标(`lucide` `Globe`/`Languages`),并在展开菜单用图标区分。
|
||||
|
||||
### 2.1 为什么按钮显示"目标语言"
|
||||
|
||||
图标 + 目标语言缩写最节省空间且语义清晰(常见国际化 UI 惯例,如 GitHub/Google)。同时菜单内提供明确的"当前语言"选中态,避免歧义。
|
||||
|
||||
### 2.2 空态/兜底
|
||||
|
||||
- 若某些组件在资源未就绪时不渲染切换器,则**优先不显示**而不是显示错误语言;资源就绪后由统一 ready 门禁放行(D7)。
|
||||
- 切换器自身文案(`aria-label`、菜单项"English / 简体中文")用当前语言渲染,保证用户总能用自己当前看到的语言操作它。
|
||||
|
||||
## 3. 状态
|
||||
|
||||
| 状态 | 表现 |
|
||||
|---|---|
|
||||
| 当前 `en` | 按钮显示"中文";菜单中 `English` 高亮为选中 |
|
||||
| 当前 `zh-CN` | 按钮显示"EN";菜单中 `简体中文(zh-CN)` 高亮为选中 |
|
||||
| 加载中/资源未就绪 | 按钮不闪烁、不显示 key;就绪后出现 |
|
||||
| 存储不可用(隐私/禁 cookie) | 会话内仍可切换(见 `LOCALE_NAVIGATION_BEHAVIOR.md`),但刷新后回退 |
|
||||
|
||||
## 4. 交互
|
||||
|
||||
1. 点击按钮打开菜单(Popover/Menu)。
|
||||
2. 选择目标语言项:
|
||||
- **即时生效**:当前页所有文案立即切换,无需刷新或整页跳转(`LOCALIZATION_SPEC.md` §1.3)。
|
||||
- 同时更新 `<html lang>`(`lang="zh-CN"` / `lang="en"`)。
|
||||
- 关闭菜单。
|
||||
3. 切换**不**重置表单、导航展开、滚动位置;不触发额外请求。
|
||||
|
||||
## 5. 持久化行为
|
||||
|
||||
- 切换成功后**同步写入**:
|
||||
1. **localStorage**:键 `litellm.locale`(约定,见下),值 `en` | `zh-CN`。
|
||||
2. **SameSite cookie**:同名 cookie,`SameSite=Lax`、`path=/`,一个较长的有效期(如 365 天),用于跨整页跳转/新标签恢复;不设为 httpOnly(前端需读)。
|
||||
- 存储键建议统一为常量 `litellm.locale`,由 Agent 4 在 `src/i18n/**` 提供读写函数,Agent 5/6 不直接操作存储。
|
||||
- **优先级**:读取时优先 user 主动选择 → cookie/localStorage → 浏览器语言 → en(见 `LOCALIZATION_SPEC.md` §1.1)。cookie 与 localStorage 两者都存在时以较新写入者为准(实现按 D5/D6 由 Agent 4 定,建议以 localStorage 为准,cookie 用于跨页恢复)。
|
||||
- 写入失败(隐私模式)不报错、不阻塞切换,静默降级。
|
||||
|
||||
## 6. 可访问性
|
||||
|
||||
- 触发器按钮必须带 `aria-label`,内容随当前语言本地化:
|
||||
- `en` 下:`Change language` / `选择语言`(用当前语言渲染,即 en 时英文标签,zh 时中文标签)。
|
||||
- zh 下:`切换语言`。
|
||||
- 推荐:`aria-label="Switch language"`(en)/ `"切换语言"`(zh-CN)。
|
||||
- 菜单用 `role="menu"`/`menuitemradio` 或 Popover 语义,支持键盘操作(方向键选择、Enter 确认、Esc 关闭)。
|
||||
- 选中项要有 `aria-checked` 或视觉高亮 + `aria-current`。
|
||||
- 焦点管理:打开时焦点移至菜单,关闭后返回触发器。
|
||||
|
||||
## 7. 测试要点(供 Agent 7 参考)
|
||||
|
||||
- 切换即时生效(DOM 文案变化且无整页刷新)。
|
||||
- 刷新后保持(cookie/localStorage 恢复)。
|
||||
- `<html lang>` 与当前语言一致。
|
||||
- 切换不清空表单/不触发请求。
|
||||
- 当前语言在菜单中正确高亮。
|
||||
- aria-label 随语言变化。
|
||||
- 存储不可用时降级行为(见 `LOCALE_NAVIGATION_BEHAVIOR.md`)。
|
||||
104
docs/i18n/LOCALE_NAVIGATION_BEHAVIOR.md
Normal file
104
docs/i18n/LOCALE_NAVIGATION_BEHAVIOR.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# 语言导航行为定义(Locale Navigation Behavior)
|
||||
|
||||
> 维护者:Agent 2(localization-designer)
|
||||
> 状态:设计稿(G0 门禁交付物之一)
|
||||
> 关联:`I18N_MULTI_AGENT_PLAN.md` §3.3、`DECISIONS.md` D5/D6/D7/D10、`LOCALIZATION_SPEC.md`、`LANGUAGE_SWITCHER_SPEC.md`
|
||||
> 依据:本文件满足 G0 门禁中"Login、SSO、MCP OAuth 整页跳转后的语言保持规则已写入本地化与测试方案"。
|
||||
|
||||
本文档定义**语言在导航/跳转各场景下的期望行为、降级规则与可测试验收点**。静态导出、客户端收敛、无服务端 locale 路由是背景约束(D4/D6)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 场景总览
|
||||
|
||||
| 场景 | 语言来源 | 期望结果 |
|
||||
|---|---|---|
|
||||
| 首次访问(无偏好) | 浏览器语言 → en | 按浏览器语言;中文浏览器默认 zh-CN |
|
||||
| 后续访问(有偏好) | cookie/localStorage 偏好 | 保持已选语言 |
|
||||
| 手动切换 | 用户选择 | 即时生效并持久化 |
|
||||
| 页面内 SPA 导航 | 当前会话 | 语言跨页保持 |
|
||||
| Login 提交回跳 | 同源存储 | 保持切换前语言 |
|
||||
| SSO 登录回跳 | 同源存储 | 保持(见 §4 例外与降级) |
|
||||
| MCP OAuth 授权回跳 | 同源存储 | 保持 |
|
||||
| 存储不可用(隐私/禁 cookie) | 会话内存 | 会话内可切换,刷新后回退 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 首次访问
|
||||
|
||||
- 定义:无任何已存偏好(cookie 与 localStorage 均无 `litellm.locale`)。
|
||||
- 行为:读取 `navigator.language`(或 `navigator.languages` 首项)。以 `zh` 开头 → `zh-CN`;否则 → `en`。
|
||||
- 不弹出语言选择对话框;以浏览器语言为默认即可(`LOCALIZATION_SPEC.md` §1.2)。
|
||||
- 首次进入后会将所选语言**写回偏好**(可选),如此后浏览器语言变化不再覆盖。
|
||||
- 验收点 A1:见 §6。
|
||||
|
||||
## 3. 手动切换后的行为
|
||||
|
||||
- 切换即时生效(无刷新、无整页跳转),写入 localStorage + cookie(见 `LANGUAGE_SWITCHER_SPEC.md` §5)。
|
||||
- 同一标签页内 SPA 路由跳转(Leftnav 点击)语言保持;导航状态、表单、滚动不被清空。
|
||||
- 新开标签页/使用同一浏览器时,从偏好恢复。
|
||||
- 验收点 A2/A3:见 §6。
|
||||
|
||||
## 4. 整页跳转与回跳(Login / SSO / MCP OAuth)
|
||||
|
||||
背景:SPA 内这些登录动作会离开当前页面去往后端/第三方,完成后再整页加载回跳地址。
|
||||
|
||||
### 4.1 期望
|
||||
|
||||
1. 跳转前语言已由客户端写入**同源 cookie + localStorage**。
|
||||
2. 回跳到同源页面后,语言初始化在客户端收敛阶段从偏好恢复,**语言应与跳转前一致**,不得回落到英文。
|
||||
3. Login 提交回跳、SSO 回跳、MCP OAuth 回跳三种都应能恢复(方案 §3.3 PoC 要求)。
|
||||
|
||||
### 4.2 例外与降级
|
||||
|
||||
- **Cookie 不可用 / 隐私模式**:localStorage 也可能受限。此时无法跨整页跳转持久化语言,回跳后按"无偏好"重估(浏览器语言→en)处理,属**可接受降级**,但必须**静默**,且不允许出现语言"闪烁"到错语言再纠正的体验。
|
||||
- **SSO 第三方域**(如外部 IdP):跳转发生在第三方域,前端无法携带语言 cookie 过去,但**回跳回同源**时仍在同源 cookie/localStorage 作用域内,可恢复。若 IdP 协商了完全不同的域且无同源 cookie,则按浏览器语言降级。
|
||||
- **首次在 Login 页选择语言后即整页跳转**:选择须在跳转前完成持久化,回跳恢复;这是 Login 页必须放切换器入口的原因(`LANGUAGE_SWITCHER_SPEC.md` §1)。
|
||||
|
||||
### 4.3 存储写入时机
|
||||
|
||||
为确保跳转前已持久化,语言偏好在**切换动作发生时立即写入**,而非等到刷新/跳转。禁止"仅内存 + 跳转前延迟写"导致竞态丢失。
|
||||
|
||||
- 验收点 A4/A5/A6:见 §6。
|
||||
|
||||
---
|
||||
|
||||
## 5. 存储不可用时的降级规则
|
||||
|
||||
| 失败类型 | 行为 |
|
||||
|---|---|
|
||||
| localStorage 写入抛异常/Safari 隐私模式 | 捕获异常静默跳过写入;会话内内存变量仍生效,当前页面可切换 |
|
||||
| cookie 被禁/无法设置 | 同上,静默跳过;跨整页跳转可能会丢失语言(按 §4.2 降级) |
|
||||
| 读取时两者都无 | 走"首次访问"逻辑(浏览器语言 → en) |
|
||||
| 被第三方脚本/iframe 限制(`document.cookie` 不可读) | 视为无偏好,按浏览器语言;不抛错 |
|
||||
|
||||
原则:**任何存储失败不得阻塞 UI 或抛出可见错误**;只影响持久化,不影响会话内切换。
|
||||
|
||||
---
|
||||
|
||||
## 6. 可测试验收点(可自动化 + 可手动)
|
||||
|
||||
供 Agent 3/7 落地为用例,也作为 G0 后验证。
|
||||
|
||||
| ID | 验收点 | 期望 |
|
||||
|---|---|---|
|
||||
| A1 | 中文浏览器首次访问默认语言 | `navigator.language=zh-*` → 首屏为 zh-CN |
|
||||
| A2 | 手动切换即时生效 | 选 zh-CN 后当前 DOM 文案变为中文,无整页刷新 |
|
||||
| A3 | 刷新后保持 | 切换后刷新,仍为 zh-CN;`<html lang="zh-CN">` |
|
||||
| A4 | Login 提交回跳语言保持 | Login 前选 zh-CN → 提交登录 → 回跳到 Dashboard 仍为 zh-CN |
|
||||
| A5 | SSO 回跳语言保持 | 选 zh-CN 进入 SSO → 回跳后仍为 zh-CN |
|
||||
| A6 | MCP OAuth 回跳语言保持 | MCP OAuth 授权回跳后仍为 zh-CN |
|
||||
| A7 | 存储不可用降级 | 禁 cookie + 隐私模式:会话内可切换,刷新后按浏览器语言/en,不抛错、不闪烁 |
|
||||
| A8 | 缺 zh-CN key 回退 | 仅 en 有某 key 时,zh 下该处回退英文,不显示 key |
|
||||
| A9 | 首帧不闪 key | 首帧不显示原始翻译 key(ready 门禁,D7 实现后验证) |
|
||||
| A10 | `<html lang>` 一致 | 任意语言下 `document.documentElement.lang` 与当前语言一致 |
|
||||
| A11 | 切换不清空表单 | 选语言前填写的表单在切换后内容保留 |
|
||||
|
||||
> 注意:A4/A5/A6 依赖真实登录/SSO/OAuth 回跳环境,PoC 阶段(Wave 1)由 Agent 1 先用"跳转-返回"模拟验证,A7/A9 可作为轻量替代证据,需 Agent 0 批准后视为已覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 7. 风险与待定
|
||||
|
||||
- SSO 第三方域回跳的外域 cookie 是浏览器级限制,如实际不可恢复,需 Agent 0 批准记录为明确限制(方案 §11.7 允许)。
|
||||
- `html lang` 首屏同步在 SSR/hydration 下可能滞后,由 D7 首屏策略决定;不得因此回退翻译。
|
||||
252
docs/i18n/LOCALIZATION_SPEC.md
Normal file
252
docs/i18n/LOCALIZATION_SPEC.md
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
# LiteLLM Dashboard 本地化规范(Localization Spec)
|
||||
|
||||
> 维护者:Agent 2(localization-designer)
|
||||
> 状态:设计稿,待 G0 评审
|
||||
> 范围:LiteLLM Dashboard 前端 UI 的 `en` / `zh-CN` 本地化
|
||||
> 关联:`I18N_MULTI_AGENT_PLAN.md`(§3.3/§3.4/§5.2)、`DECISIONS.md`、`LANGUAGE_SWITCHER_SPEC.md`、`GLOSSARY_EN_ZH.md`
|
||||
|
||||
本文档定义语言的**交互规则**与**中文文案风格标准**。它回答"什么时候用什么语言"以及"中文应该怎么写得专业"。具体每个页面/组件翻不翻、翻哪些,见 `V1_TRANSLATION_SCOPE.md`;语言切换器的 UI 细节见 `LANGUAGE_SWITCHER_SPEC.md`。
|
||||
|
||||
---
|
||||
|
||||
## 1. 语言规则
|
||||
|
||||
### 1.1 语言标识与优先级
|
||||
|
||||
- 语言代码:`en`(默认)、`zh-CN`。
|
||||
- `en` 是**真源与最终回退**;任何缺少 `zh-CN` key 的字符串一律回退英文,绝不允许显示原始 key。
|
||||
- 语言优先级(D5,已定):
|
||||
```
|
||||
用户主动选择
|
||||
→ 已确认的用户级 UI 设置(如后端支持,v1 默认不启用)
|
||||
→ cookie + localStorage
|
||||
→ 浏览器语言
|
||||
→ en
|
||||
```
|
||||
- 语言偏好判定完全在客户端完成(静态导出约束,D4)。
|
||||
|
||||
### 1.2 首次进入
|
||||
|
||||
- 无任何已存偏好时:读取浏览器语言。若 `navigator.language` 以 `zh` 开头(`zh`、`zh-CN`、`zh-TW`、`zh-Hans` 等均视为中文系),则初始为 `zh-CN`;否则为 `en`。
|
||||
- 首次进入**不强制弹出语言选择对话框**。以浏览器语言为默认即可;用户可随时通过切换器更改。
|
||||
- 首次进入后(即一旦存在已存偏好),优先使用已存偏好,不再读取浏览器语言。
|
||||
|
||||
### 1.3 手动切换
|
||||
|
||||
- 切换**即时生效**,无需刷新、无需整页跳转;当前页面所有文案立即切换。
|
||||
- 切换时**保留表单状态、滚动位置、导航展开状态**,不允许因语言切换重置表单或触发异常请求(v1 完成定义第 6 条)。
|
||||
- 切换后立即写入 cookie + localStorage(行为见 `LANGUAGE_SWITCHER_SPEC.md`)。
|
||||
|
||||
### 1.4 整页跳转与回跳(Login/SSO/MCP OAuth)
|
||||
|
||||
详细行为与降级见 `LOCALE_NAVIGATION_BEHAVIOR.md`。摘要:
|
||||
|
||||
- Login 提交、SSO 登录、MCP OAuth 授权可能发生整页跳转(起跳自同源页面,回跳到同源页面)。
|
||||
- 跳转返回后必须从**同源 cookie 或 localStorage** 恢复语言,语言不应回落到英文。
|
||||
- 若同源存储不可用(隐私模式、禁 cookie),允许回退英文,但不允许出现语言"闪烁"到错误语言的体验缺陷。
|
||||
|
||||
### 1.5 构建期元数据
|
||||
|
||||
- v1 构建期 `<title>` 与 metadata description **保持英文**(D8),不做多语言 SEO。功能 Agent 不得被动扩大此范围。
|
||||
|
||||
---
|
||||
|
||||
## 2. 中文文案风格规范
|
||||
|
||||
目标:**专业、自然、克制、无翻译腔**。面向开发者/运维人员(Dashboard 用户以工程师为主),用词应准确、简洁,避免口语化和过度文学化。
|
||||
|
||||
### 2.1 通用原则
|
||||
|
||||
1. **以用户动作和对象为先**。按钮用"动词 + 宾语"最短形式(见 2.2)。
|
||||
2. **不用翻译腔**:避免"为了……我们将会……"、"请注意"、"请务必"等冗余;避免英文逐字直译的语序污染。
|
||||
3. **控制长度**:中文比英文短,但信息密度高。菜单项/按钮 ≤ 6 字为佳,超过 8 字注意换行与截断(见 §3)。
|
||||
4. **术语一致**:一律采用 `GLOSSARY_EN_ZH.md` 中的词条,禁止同义词漂移(如"Spend"一会翻"花费"一会翻"支出")。
|
||||
5. **敬语从简**:界面动作祈使句即可,不加"您"的过度堆叠;保留必要礼貌但不每一句都加。
|
||||
6. **标点**:中文使用全角标点(`,`、`。`、`:`、`?`),英文/数字/型号与中文混排时两侧不加空格;数字、单位用半角(`10 次` 中空格与否按项目 UI 惯例统一)。
|
||||
7. **占位符与插值**:不译 `{{variable}}`、`{{count}}` 内的变量名;品牌名 LiteLLM、OpenAI 等不译。
|
||||
|
||||
### 2.2 按钮文案
|
||||
|
||||
- 格式:**动词(+ 宾语)**,祈使句。
|
||||
- 通用按钮术语(复用 `GLOSSARY_EN_ZH.md` + `common.json`):
|
||||
| EN | ZH-CN |
|
||||
|---|---|
|
||||
| Save | 保存 |
|
||||
| Cancel | 取消 |
|
||||
| Delete | 删除 |
|
||||
| Create | 创建 |
|
||||
| Add | 添加 |
|
||||
| Edit | 编辑 |
|
||||
| Update | 更新 |
|
||||
| Enable / Disable | 启用 / 停用 |
|
||||
| New Virtual Key | 新建虚拟密钥 |
|
||||
| Generate | 生成 |
|
||||
| Copy | 复制 |
|
||||
| Close | 关闭 |
|
||||
| Confirm | 确认 |
|
||||
- 含受保护动作(删除、重置、清空)的按钮在确认弹窗中保持同一译名,不要换成同义"移除/清除"造成语义漂移。
|
||||
|
||||
### 2.3 表单文案
|
||||
|
||||
- **Label**:名词性短语,简洁。如 `Deployment Name` → `部署名称`、`Base URL` → `基础 URL`(URL 保留英文,见 §4)。
|
||||
- **Placeholder**:用"请输入 / 请选择 / 例如"引导的示例或提示。如 `Enter model ID` → `输入模型 ID`;`Select team` → `选择团队`。
|
||||
- **校验提示**:指出问题 + 期望,避免命令式指责。
|
||||
- 示例:`This field is required` → `此项为必填`;`Key must be at least 8 characters` → `密钥至少需要 8 个字符`。
|
||||
- **可选/必填**:`Required` → `必填`;可选字段不要写"可选"二字堆满表单,仅在视觉区分或用 `(可选)` 后缀在 label 上。
|
||||
- **分段标题/说明**:`form.helper` 类说明文本可译,但保留关键英文专有名词(API、URL、SSO)。
|
||||
|
||||
### 2.4 错误提示
|
||||
|
||||
- **技术性错误**(后端返回、非 4xx 校验错误):尽量提供"发生了什么 + 可做什么"两层。
|
||||
- **不可直翻后端消息**:后端英语错误原文**不翻译**(见 §4)。即使附带中文说明,也应保留原文以便排障。
|
||||
- 措辞平实。示例:
|
||||
- `Unable to load models.` → `无法加载模型。`
|
||||
- `Your session has expired.` → `会话已过期。`
|
||||
- `Failed to create Virtual Key.` → `创建虚拟密钥失败。`
|
||||
- `Insufficient permission.` → `权限不足。`(不写"您的权限不够")
|
||||
- 避免把错误写成"出错了"这类无信息量提示;能带对象就带对象(`删除团队失败`)。
|
||||
|
||||
### 2.5 空状态(Empty State)
|
||||
|
||||
- 空状态 = 说明 + 一句引导(一个主导 CTA)。
|
||||
- 避免"这里空空如也"这种口语;用客观陈述 + 引导。
|
||||
- 示例:`No Virtual Keys yet. Create one to get started.` → `暂无虚拟密钥。点击"新建虚拟密钥"开始使用。`
|
||||
- `No usage data for the selected period.` → `所选时间段内暂无用量数据。`
|
||||
- 引导动词与真实按钮文字保持一致("新建虚拟密钥")。
|
||||
|
||||
### 2.6 成功提示 / 通知(Toast)
|
||||
|
||||
- 过去式主谓短句。如 `Virtual Key created.` → `虚拟密钥已创建。`、`Settings saved.` → `设置已保存。`
|
||||
- 需要引用对象名称/ID 时用插值:`Model "{{name}}" saved.` → `模型"{{name}}"已保存。`
|
||||
|
||||
---
|
||||
|
||||
## 3. 中文长度对布局的影响与处理
|
||||
|
||||
中文单字约占英文一个字符 ~2 倍视觉宽度,但信息密度高,实际渲染宽度通常**更短或相当**。真正的风险是:
|
||||
|
||||
1. 英文长复合词被较长的中文词组替换;
|
||||
2. 筛选器、表格列头、弹窗标题空间不足导致换行/溢出。
|
||||
|
||||
### 3.1 导航(Leftnav / Navbar / 面包屑)
|
||||
|
||||
- 菜单项普遍 ≤ 6 字(如 `Virtual Keys`→`虚拟密钥`、`Models + Endpoints`→`模型与端点`),通常放得下。
|
||||
- 侧边栏已具备 `truncate`(`flex-1 truncate`),中文过长时应保持单行截断,并补 `title` 展示全称。
|
||||
- 分组标签(AI GATEWAY 等)v1 也翻译;groupLabel 是配置常量,需随 `menuGroups` 一起 I18N 化,而非仅改渲染层。
|
||||
- 面包屑 section/title 同样取翻译,需与导航使用同一套 key,避免两处不一致。
|
||||
|
||||
### 3.2 表格
|
||||
|
||||
- 列头名词、数值列(用量/成本/花费用数字格式化,见 `V1_TRANSLATION_SCOPE.md` 插值注意事项)。
|
||||
- 表格单元格回退策略:**列头可换行,单元格优先截断 + `title` 全称**;避免单元格内文字撑爆列宽。
|
||||
- 数据列(模型 ID、团队名、时间戳)」多为数据,不翻译,按 §4 处理。
|
||||
|
||||
### 3.3 弹窗 / 抽屉(Modal / Drawer)
|
||||
|
||||
- 标题 ≤ 20 字;超长标题强制换行并提供 `line-clamp` 或在布置上预留两行。
|
||||
- 按钮组"取消/保存"不加长词缀,保持最短形式。
|
||||
- 弹窗内说明文本允许自然换行,但禁止水平溢出。
|
||||
|
||||
### 3.4 全体回退策略(截断 / 换行 / 省略号)
|
||||
|
||||
| 场景 | 策略 |
|
||||
|---|---|
|
||||
| 导航菜单单行 | `truncate`(省略号) + `title` 全称 |
|
||||
| 表格单元格 | 截断 + `title`;数值列右对齐 |
|
||||
| 弹窗标题 | 允许换行(白空间关键字 `[normal, pre-wrap]`),必要时 `line-clamp-2` |
|
||||
| 按钮 | 不换行;过长时缩短译名,不拼接 |
|
||||
| 标签/Badge(Beta、New) | 不翻译品牌徽标;`v{major.minor}` 保持 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 不可翻译内容清单
|
||||
|
||||
以下内容**明确不翻译**(作为资源值原样保留或直接放数据字段,不进翻译 key)。Agent 5/6 必须遵守,Review 时逐条核查:
|
||||
|
||||
1. **模型名 / Model ID / Deployment 名 / Provider 名**:`gpt-4o`、`claude-3-5-sonnet`、`openai`、`bedrock` 等;用户自定义的 Deployment/模型名也是数据,不译。
|
||||
2. **API 字段名 / 参数名**:`key`、`model`、`spend`、`tpm_limit`、`rpm_limit`、`metadata`、`created_at`;接口返回的键与 JSON 结构不译。
|
||||
3. **请求/响应体、日志原文**:后端返回的原始日志、错误消息、堆栈(stack trace)、trace 内容保持原样。
|
||||
4. **代码示例与终端命令**:curl 片段、Python/JS 代码、API 请求示例不译;但代码**上方/下方的说明文字**可译。
|
||||
5. **URL、路径、文件名**:`https://docs.litellm.ai`、`/api/`、`config.yaml`、`.env` 等。
|
||||
6. **广泛公认的英文技术缩写**:API、URL、SSO、MCP、OAuth、REST、HTTP、SQL、ID、JSON、SDK。
|
||||
7. **品牌名**:LiteLLM、OpenAI、Anthropic、Virtual Key(作为产品专名,见下表备注)、Guardrails 的专有规则名。
|
||||
8. **数值、日期、货币、单位**:数字格式化走 i18n 的 number/date/currency,不手工拼翻译;`{{count}}` 由计数插值负责。
|
||||
9. **角色名 / 权限标识**:`admin`、`internal_user`、`team_admin` 等代码值保持英文(仅其人类可读显示名按术语表翻译)。
|
||||
10. **徽标与 Beta/New 标记**:`Beta`、`New` 徽标不译(视觉因子,保持英文原样)。
|
||||
|
||||
> 原则:**翻译的是界面文案(UI string),不是数据(data string)与协议内容(protocol content)**。无法安全区分时,优先看它是否来自后端/用户数据 —— 是则不译。
|
||||
|
||||
---
|
||||
|
||||
## 5. zh-CN 计数与量词策略
|
||||
|
||||
遵循 D9:**不建立形态复数分支**,用"计数插值 + 量词"。i18next 复数字符串里 `_one/_other` 仅用于英文;`zh-CN` 用一个字符串承载 `{{count}}` + 量词即可。
|
||||
|
||||
量词选取规则:
|
||||
|
||||
- 可数离散对象:`个`(模型、密钥、请求、团队、用户、条目)。
|
||||
- 键值/集合类(如导入项):可用 `个` 或直接按对象数合身处理。
|
||||
- 时间/次数:`次`(请求次数、重试次数)。
|
||||
- 金额/用量单位化:不写量词,直接 `{{count}}` + 单位(`{{count}} 请求`、`{{count}} 个请求`,口语一致性以术语表为准)。
|
||||
- `0` 时量词仍正常(`0 个请求` 在中文是自然的,不需要单独复数分支)。
|
||||
|
||||
### 5.1 具体示例
|
||||
|
||||
| 场景 | EN(_one/_other) | ZH-CN(单字符串) |
|
||||
|---|---|---|
|
||||
| 请求数 | `{{count}} request` / `{{count}} requests` | `{{count}} 个请求` |
|
||||
| 选中模型 | `{{count}} model selected` / selected | `已选择 {{count}} 个模型` |
|
||||
| 待办项 | `{{count}} item` / items | `{{count}} 个条目` |
|
||||
| 无选中 | — | `已选择 0 个模型`(中文自然,无需分支) |
|
||||
| 重试 | `Retry ({{count}})` | `重试({{count}} 次)` |
|
||||
|
||||
### 5.2 测试要求
|
||||
|
||||
计数测试至少覆盖 `count=0`、`count=1`、`count=2`(方案 §3.4)。对 `zh-CN` 断言渲染结果等于"插值 + 量词"形态(如 `1 个请求`、`2 个请求`),**不**断言存在 `_one/_other` 分支。
|
||||
|
||||
---
|
||||
|
||||
## 6. 英文原句 → 规范中文译文示例
|
||||
|
||||
给开发 Agent 5/6 的参照样本(来自 v1 高频界面)。
|
||||
|
||||
| # | EN(原句) | ZH-CN(规范译文) | 说明 |
|
||||
|---|---|---|---|
|
||||
| 1 | `Create new virtual key` | 新建虚拟密钥 | 动词+宾语,最短祈使形式 |
|
||||
| 2 | `No Virtual Keys found. Click "New Virtual Key" to create your first one.` | 暂无虚拟密钥。点击"新建虚拟密钥"创建你的第一个密钥。 | 空状态客观陈述 + 引导 CTA,CTA 文字与真实按钮一致 |
|
||||
| 3 | `This will permanently delete this model and cannot be undone.` | 此操作将永久删除该模型,且无法撤销。 | 机构条理清晰,动作对象明确 |
|
||||
| 4 | `You've spent $12.34 on Usage this month.` | 本月用量已花费 $12.34。 | 过去式主谓短句,接数值/货币(货币由 i18n 格式化) |
|
||||
| 5 | `Drop any model to see how it compares.` | 拖入任意模型查看对比结果。 | 祈使引导,无翻译腔 |
|
||||
| 6 | `Your changes have not been saved.` | 更改尚未保存。 | 客观陈述,不指责 |
|
||||
|
||||
> 译名对照一律以 `GLOSSARY_EN_ZH.md` 为准,本示例中的词汇(虚拟密钥、用量、花费、拖入、对比)与术语表保持一致。
|
||||
|
||||
---
|
||||
|
||||
## 7. 可访问性(a11y)文案规范
|
||||
|
||||
- 所有 `aria-label`、`title`、`alt`、placeholder 中的用户可见文案**同样翻译**(方案 §5.5),与可见文本用同一 key。
|
||||
- 例:当前 `aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}` → `展开侧边栏` / `收起侧边栏`。
|
||||
- 语言切换器自身提供 `aria-label`(见 `LANGUAGE_SWITCHER_SPEC.md`)。
|
||||
- 图标按钮(无可见文字)必须本地化 `aria-label`;纯装饰图标保持 `aria-hidden`。
|
||||
- 保留英文专有名词的缩写不影响屏幕阅读器核心语义。
|
||||
|
||||
---
|
||||
|
||||
## 8. 对开发 Agent 的强制要求(Agent 5/6)
|
||||
|
||||
1. **只翻 UI 文案**,不翻 §4 清单内容(模型名、API 字段、日志、代码、URL、缩写、品牌)。
|
||||
2. **key 与字典一致性**:中英文 key 集合必须一致;缺 zh-CN 时回退 en,不回退失败显示 key。
|
||||
3. **写 key 不写句子到代码**:用语义 key(`navigation:item.virtualKeys`),不把英文句子当 key。
|
||||
4. `<Trans>` 用于含链接/强调/嵌套,`t()` 用于纯字符串;使用 `<Trans>` 前确保资源就绪(ready 门禁,避免首帧闪 key)。
|
||||
5. **同一译名贯穿全局**:按钮、空状态、确认框、Toast 对同一动作/对象用词必须一致(对照术语表)。
|
||||
6. **数值/日期/货币用 i18n 格式化**,不硬编码。
|
||||
7. **翻译 a11y 文案**(aria-label/title/placeholder)。
|
||||
8. **不改变业务行为**:翻译不改路由、不改校验逻辑、不改提交逻辑,仅替换展示文案。
|
||||
9. 交付时在 `V1_SCOPE_MANIFEST.md`(Agent 0 维护)对应行填 EN/ZH-CN 文案盘点;译名异常情况上报 Agent 2 复核。
|
||||
|
||||
---
|
||||
|
||||
## 9. 术语表引用
|
||||
|
||||
术语定义与单一词条见 `GLOSSARY_EN_ZH.md`。若本规范内个别短语与术语表冲突,以 `GLOSSARY_EN_ZH.md` 为准,并提请 Agent 2 修订。
|
||||
30
docs/i18n/MASTER_PLAN.md
Normal file
30
docs/i18n/MASTER_PLAN.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# LiteLLM Dashboard 中英文国际化 — MASTER_PLAN
|
||||
|
||||
> 总控:Agent 0(`i18n-lead-reviewer`) · 由当前主智能体承担
|
||||
> 基线:`I18N_MULTI_AGENT_PLAN.md` v1.4(评审通过,可执行)
|
||||
> 目标:Dashboard 前端 UI 支持 `en` / `zh-CN`,导航、登录、Models、API Keys、Usage、Cost、Budgets 优先中文化。
|
||||
|
||||
## 波次总览
|
||||
|
||||
| 波次 | 内容 | 涉及角色 | 门禁 |
|
||||
|---|---|---|---|
|
||||
| Wave 0 | 基线评审 + 并行设计 | A0 + A1/A2/A3 | G0 |
|
||||
| Wave 1 | 平台能力与测试基础 | A0 + A4/A5(只读)/A7 | G1 |
|
||||
| Wave 2 | 第一批功能并行开发 | A0 + A5/A6/A7 | G2 |
|
||||
| Wave 3 | 第二批功能并行开发 | A0 + A6A/A6B/A7 | G3 |
|
||||
| Wave 4A/4B | 集成验收 + 定向缺陷修复 | A0 + A2/A7/A8(及开发 Owner) | G4 |
|
||||
|
||||
## 并发约束
|
||||
同刻最多 4 个智能体(1 总控 Agent 0 + 最多 3 执行子代理)。子代理按波次由 Agent 0 拉起,完成即回收。
|
||||
|
||||
## 当前状态
|
||||
(由 Agent 0 在每波次更新)
|
||||
|
||||
- [ ] Wave 0 进行中
|
||||
- [ ] Wave 1 待开始
|
||||
- [ ] Wave 2 待开始
|
||||
- [ ] Wave 3 待开始
|
||||
- [ ] Wave 4 待开始
|
||||
|
||||
## 完成定义
|
||||
见 `I18N_MULTI_AGENT_PLAN.md` §11(v1 完成定义 15 条)。
|
||||
56
docs/i18n/PLATFORM_VALIDATION_REPORT.md
Normal file
56
docs/i18n/PLATFORM_VALIDATION_REPORT.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# LiteLLM Dashboard i18n — 平台验证报告(PLATFORM_VALIDATION_REPORT)
|
||||
|
||||
> 角色:Agent 4(`i18n-platform-developer`)交付;Agent 0 补充完成(A4 因超时未及产出,G1 门禁补齐)
|
||||
> 基线:`I18N_TECH_DESIGN.md`、`I18N_ADR.md`、`POC_REPORT.md`(docs/i18n/)
|
||||
> 日期:2026-09-09
|
||||
|
||||
## 1. 实现范围
|
||||
|
||||
Wave 1 平台交付内容(已合入集成分支 `i18n/w1-integration`):
|
||||
|
||||
| 项 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| i18n 单例 | `src/i18n/i18n.ts` | 惰性 `getI18n()`,静态 resources,`fallbackLng:'en'`,`defaultNS:'common'` |
|
||||
| Provider | `src/i18n/I18nProvider.tsx` + `index.ts` | **首屏就绪门禁 + `<html lang>` 同步**;公/私有出口 |
|
||||
| locale 检测 | `src/i18n/detectLocale.ts` | `zh/zh-Hans/zh-TW→zh-CN`,其余→en |
|
||||
| 偏好存储 | `src/i18n/localePreferences.ts` | `litellm.locale`(P5)cookie+localStorage 双层,SameSite=Lax,生产 Secure |
|
||||
| 注册表 | `src/i18n/resources/registry.ts` | 8 namespace 单一真源,Agent 4 独占 |
|
||||
| 类型 | `src/i18n/types.d.ts` | `defaultNS:'common'`;宽松 string key(见 §3) |
|
||||
| 根布局 | `src/app/layout.tsx` | `I18nProvider` 最外层 |
|
||||
| 切换器 | `src/components/LanguageSwitcher/` | EN/中文 切换 |
|
||||
| 语言资源 | `src/locales/{en,zh-CN}/**` | 8 namespace 骨架 |
|
||||
| E2E 基建 | `tests/e2e/ui/` + `@playwright/test` | P3=a |
|
||||
| 依赖 | `package.json` | i18next、react-i18next、@playwright/test(A4 单一写入) |
|
||||
|
||||
## 2. ADR 符合性核对
|
||||
|
||||
| ADR | 结论 | 核对 |
|
||||
|---|---|---|
|
||||
| ADR-01 库选型 | Accepted | `i18next@^26.4.2`+`react-i18next@^17.0.13` 已落地并构建通过 |
|
||||
| ADR-02 静态导出无服务端 locale | Accepted | 客户端收敛,`next build`(output:export) 通过 |
|
||||
| ADR-03 资源就绪门禁(禁暴 key) | **实现符合** | `I18nProvider` 就绪前不渲染业务子树;`returnNull:true`+`missingKeyHandler` |
|
||||
| ADR-04 首屏=就绪门禁+lang 同步 | **实现符合** | 挂载后 `document.documentElement.lang` 同步;en 直接就绪 |
|
||||
| ADR-05 cookie+localStorage 双层 | **实现符合** | `litellm.locale` 双写;SameSite=Lax;生产 Secure |
|
||||
| ADR-06 title/meta v1 英文 | Accepted | `layout.tsx` metadata 未改 |
|
||||
| ADR-07 v1 不用后端 language | Accepted | 无该字段 |
|
||||
| ADR-08 注册表 A4 独占 | **实现符合** | registry.ts 声明归属;功能 Agent 只写自己的 JSON |
|
||||
|
||||
## 3. 关于「严格 typed key」的决策说明(G1 记录)
|
||||
|
||||
i18next v26 的 qualified-key 严格类型在静态导出下反复破坏 `next build`,利益与运行时门禁/CI 冗余,故 `types.d.ts` 明确采用**宽松 string key**。key 正确性由:运行时就绪门禁(ADR-03)+ A7 `check-keys` CI(G3 门禁)+ `resources.test.ts`(seed key 存在性)三重保证。**此决策由 Agent 0 在 G1 确认。**
|
||||
|
||||
## 4. 验证证据
|
||||
|
||||
| 项 | 结果 | 证据 |
|
||||
|---|---|---|
|
||||
| 单元测试 | 33 通过 | `vitest run --project unit src/i18n/` |
|
||||
| 集成测试 | 7 通过 | `vitest run --project integration src/i18n/` |
|
||||
| 类型检查 | 通过(平台相关) | `next build` TypeScript 阶段无平台错误 |
|
||||
| 静态导出构建 | 通过 | `npm run build`(`output:"export"`) |
|
||||
| check-keys 端到端 | PASS(8 namespace en/zh 一致) | A7 check-keys 对 `src/locales/{en,zh-CN}` |
|
||||
|
||||
## 5. 待补(G1 门禁内)
|
||||
|
||||
- PoC-1..9 回填 `POC_REPORT.md`(本轮 build 确认 PoC-1;其余 PoC-2/3/5/6/8 可通过集成分支验证或标记待 E2E)。
|
||||
- E2E(Playwright)smoke 断言完善(`tests/e2e/ui/` 已建骨架,断言由 Wave 2+ A7 补)。
|
||||
- push 至 remote(因无 GitHub 凭据暂缓,用户决定不 push)。
|
||||
100
docs/i18n/POC_REPORT.md
Normal file
100
docs/i18n/POC_REPORT.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# LiteLLM Dashboard i18n — PoC 验证清单与执行步骤(POC_REPORT)
|
||||
|
||||
> 角色:Agent 1(`i18n-architect`)编写 PoC 必验项;Wave 1 的 Agent 4(平台)与 Agent 7(测试)照此执行并把证据回填到「结果 / 证据」栏。
|
||||
> 约定:PoC-* 为「必验项」,每项都必须给出**方法**、**预期证据**、**结果**(通过/失败/未执行)。
|
||||
> 本机(Agent 1 仅只读)已就地验证的项,标记 **「本机已验证」**;其余标记 **「待执行」**(执行者为 A4/A7)。
|
||||
|
||||
> 目标:在改动最少的前提下,对 `I18N_TECH_DESIGN.md` 的**关键设计假设**给出可复现证据,支撑 G1 门禁(ADR-i18n-01/03/04/05 转 Accepted)。
|
||||
> 前置:Wave 1 平台基线由 A4 建立(`src/i18n/**` + `src/locales` 骨架 + 依赖写入,遵循 D12/FILE_OWNERSHIP)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 环境前提(A4 先落实)
|
||||
|
||||
- 依赖:`i18next@^26.4.2` + `react-i18next@^17.0.13`(写入后运行 `npm ci` 于独立 worktree,禁止并行锁分叉——D12/v1.4)。
|
||||
- `next.config.mjs`:**不改**(静态导出已满足)。
|
||||
- 仅改动 `src/i18n/**`、`src/locales/**`、`src/app/layout.tsx`(挂 Provider)、语言切换器。
|
||||
|
||||
---
|
||||
|
||||
## PoC-1:静态导出构建成功 && i18next 依赖可解析(G1 前置)
|
||||
|
||||
- **方法**:A4 在平台 worktree `npm run build`(`output:"export"`)。检查:构建无因引入 i18next 产生的解析/打包错误;`out/` 正常产出;`turbopack` 能打包 json 资源。
|
||||
- **预期证据**:`next build` 成功,`out/` 目录生成,无「Cannot resolve i18next/react-i18next」、无 static-export 舞台报错;`npm ls i18next react-i18next` 无 peer 冲突。
|
||||
- **结果**:【✅ 通过 —— 2026-09-09,Agent 0 在集成分支 `i18n/w1-integration` 验证】`npm run build` 全绿:`✓ Compiled successfully`、`✓ Generating static pages using 11 workers (51/51)`(全部 51 路由静态预渲染)、TypeScript 阶段无平台错误、`BUILD_EXIT=0`。i18next/react-i18next/@playwright/test 依赖解析正常,无 peer 冲突。
|
||||
|
||||
## PoC-2:首屏策略(就绪门禁 + `<html lang>` 同步)行为
|
||||
|
||||
- **方法**:默认浏览器语言 en:加载页面,在首帧与 JS 就绪后截图/断言。
|
||||
1. en 用户:首帧即为中文 UI 不存在(应显示英文 UI),`<html lang="en">` 保持。
|
||||
2. zh-CN 偏好用户(预置 `litellm.locale=zh-CN` 再刷新):JS 就绪前不渲染业务 `t()` 内容(就绪态/空),就绪后一次性渲染中文,且 `document.documentElement.lang === 'zh-CN'`。
|
||||
- **预期证据**:截图对比 + DOM 断言:①en 首帧无 key/英文句闪烁;②zh 就绪前无业务文案、就绪后 `lang` 正确;③测量就绪门禁额外延时(performance 日志)记录数值,供评审接受性判断。
|
||||
- **结果**:【待执行】
|
||||
|
||||
## PoC-3:`t()` / `<Trans>` 首帧不暴露原始 key(资源就绪门禁)
|
||||
|
||||
- **方法**:在导航/登录样面的一个组件用 `t('common:title')` 与一个 `<Trans>`。用 Playwright 在**首帧**(未就绪)与就绪后采样 DOM 文本。
|
||||
- **预期证据**:首帧 DOM 中**不含** `common:title`、不含未翻译英文句子(即不以 key=原文);就绪后显示译文。如有 `returnNull`,首帧对应节点为空而非显示 key。
|
||||
- **结果**:【待执行】
|
||||
|
||||
## PoC-4:缺 key 英文回退(D2)
|
||||
|
||||
- **方法**:在 zh-CN 资源中删掉某个 en 有的 key(临时),UI 切 zh-CN,观察该字符串。
|
||||
- **预期证据**:该串回退英文(`fallbackLng:'en'`),**不**崩、不显示 key、控制台(dev)有 missingKey warning。
|
||||
- **结果**:【待执行】
|
||||
|
||||
## PoC-5:刷新后语言偏好保留(D6/D5)
|
||||
|
||||
- **方法**:切 zh-CN → 刷新 → 断言仍 zh-CN;清空 cookie 但留 localStorage → 刷新 → 仍 zh-CN(双层读取);两者都清 → 回退浏览器语言/en。
|
||||
- **预期证据**:各分支刷新后语言正确;`litellm.locale` cookie 存在且 `SameSite=Lax`。
|
||||
- **结果**:【待执行】
|
||||
|
||||
## PoC-6:英文为首帧默认、无偏好时按浏览器语言(D5/D2)
|
||||
|
||||
- **方法**:无任何偏好 cookie/localStorage;浏览器 `navigator.language` 分别设 en、zh-CN、zh、zh-Hans、fr。
|
||||
- **预期证据**:en→英文;zh/zh-Hans→zh-CN;fr(不支持)→英文。首帧英文合法,无闪烁。
|
||||
- **结果**:【待执行】
|
||||
|
||||
## PoC-7:整页跳转语言恢复(Login/SSO/MCP OAuth 回跳,D10)
|
||||
|
||||
- **方法**:在 `(dashboard)` 中切 zh-CN → 触发到 `/login` 或模拟 SSO/OAuth 全页跳转(改变 URL/全量 reload)→ 返回原页。
|
||||
- **预期证据**:回跳后仍 zh-CN(从同源 cookie 恢复),`<html lang>` 一致,无 key 闪烁;未登录直登场景与 OAuth 回跳分别覆盖。
|
||||
- **结果**:【待执行】
|
||||
|
||||
## PoC-8:中文量词/计数(D9)在真实组件表现
|
||||
|
||||
- **方法**:在带 `{{count}}` 的计数展示(如 usage 表格)切 zh-CN,覆盖 count=0/1/2。
|
||||
- **预期证据**:输出如「0 个 / 1 个 / 2 个」,无英文复数 s;量词与数为「数+量」顺序正确出现。
|
||||
- **结果**:【待执行】
|
||||
|
||||
## PoC-9:toast / 构建产物不含未翻译硬编码(回归)
|
||||
|
||||
- **方法**:扫描构建后产物与关键页面文本,确认没有「key 串」残留为可见 UI 文本。
|
||||
- **预期证据**:`grep` 产物无 `\w+:\w+\.` 形式的 key 作为文本暴露;关键页面无英文句被用户看到(除 `title`/meta 英文外)。
|
||||
- **结果**:【待执行】
|
||||
|
||||
---
|
||||
|
||||
## 本机已验证项(Agent 1,只读,2026-09-09)
|
||||
|
||||
| # | 项 | 验证方式 | 结果 |
|
||||
|---|---|---|---|
|
||||
| 1 | `react-i18next@17.0.13` 兼容 React 19 / TS 5 | `npm view react-i18next peerDependencies` → `react>=16.8.0`, `i18next>=26.2.0`, `ts ^5\|\|^6\|\|^7`;项目 React 19.2.8 / TS 5.9.3 满足 | 通过 |
|
||||
| 2 | `i18next@26.4.2` 无冲突 peer | `npm view i18next@latest peerDependencies` → 仅 `typescript` | 通过 |
|
||||
| 3 | 根 Provider 链结构 | 读 `src/app/layout.tsx`:`ThemeProvider→NuqsAdapter→ReactQueryProvider→AuthProvider`;`<html lang="en" suppressHydrationWarning>`;metadata 英文 | 确认 |
|
||||
| 4 | `src/i18n` / `src/locales` 当前不存在(基线干净) | `ls` | 确认(Wave0 从零建) |
|
||||
| 5 | 路由组 layout 分布 | 根 / `(dashboard)` / `chat` / `connect` 各有一 `layout.tsx`;`(dashboard)` 为 use client 且另含 SidebarProvider/ThemeContext/PluginModeContext | 确认(接缝见设计 §8) |
|
||||
| 6 | 后端无 `UI settings.language` | `UISettings` 白名单 `ALLOWED_UI_SETTINGS_FIELDS` 不含 `language`;`/get/ui_settings` 由 `LiteLLM_UISettings` 表支撑 | 确认(→ADR-i18n-07) |
|
||||
| 7 | next export 配置 | `next.config.mjs`: `output:"export"`, `trailingSlash:true`, `images.unoptimized` | 确认(静态导出约束成立) |
|
||||
|
||||
## 待执行(Wave 1 A4/A7 回填)
|
||||
|
||||
- PoC-1 .. PoC-9 均【待执行】;其中 PoC-1(构建+依赖)、PoC-2/3(首屏与门禁)、PoC-5(刷新)、PoC-7(跳转恢复)为 **G1 关键门禁**,优先完成并回填证据文件(证据文件命名遵从 v1.4 §11 具名证据约定,由 Agent 3/7 落地)。
|
||||
|
||||
---
|
||||
|
||||
## 建议的 PoC 测试层级归属(供 Agent 3 细化)
|
||||
|
||||
- 单测(unit):`localePreferences.ts`(D5 优先级)、`detectLocale.ts`(`zh`→`zh-CN`)、`i18n.ts` 实例化。
|
||||
- 集成:`I18nProvider` 就绪门禁渲染(PoC-3)、Layout 挂 Provider 后 `useTranslation` 可用。
|
||||
- E2E(`tests/e2e/ui/`,Playwright 对 live proxy):PoC-2/5/6/7(首屏、刷新、浏览器语言、整页回跳)。
|
||||
58
docs/i18n/REGRESSION_MATRIX.md
Normal file
58
docs/i18n/REGRESSION_MATRIX.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# i18n 回归测试矩阵(REGRESSION_MATRIX.md)
|
||||
|
||||
> 角色:Agent 3 `qa-architect`;执行:Agent 7 `i18n-qa`(Wave 4A 完整回归)
|
||||
> 依据:`I18N_MULTI_AGENT_PLAN.md` §11 完成定义、§10 测试重点;`TEST_CASES.md`
|
||||
> 用法:按"页面 × 关键行为"逐格勾选。行为列引用 TC 编号;页面以 v1 范围为准。每格填:`✅ 通过 / ❌ 失败(#缺陷) / ➖ 不适用 / (m) 人工`。
|
||||
|
||||
## 行为列(横轴)编号
|
||||
|
||||
| 列 | 行为 | 主要 TC | 层级 |
|
||||
|---|---|---|---|
|
||||
| B1 | 默认语言 en | TC-01/02 | e2e/unit |
|
||||
| B2 | 切换即时生效 + `<html lang>` | TC-03/08 | e2e/component |
|
||||
| B3 | 刷新保持 | TC-04/05 | e2e/unit |
|
||||
| B4 | 缺 key 回退英文、不显示原始 key | TC-06/07 | e2e/component |
|
||||
| B5 | 动态数量/日期/数字/货币本地化(含 count=0/1/2) | TC-09~13 | unit |
|
||||
| B6 | 模型名/API 字段/日志/代码示例不误翻 | TC-20/21 | integration |
|
||||
| B7 | a11y 文案本地化(aria-label/title/placeholder/error) | TC-23 | component/integration |
|
||||
| B8 | 中文布局不溢出/截断/遮挡 | TC-24 | integration/e2e+(m) |
|
||||
| B9 | 切换语言不清空表单/不触发异常请求 | TC-14/15 | integration/e2e |
|
||||
| B10 | 整页跳转(Login/SSO/MCP OAuth)回跳语言保持 | TC-16~19 | e2e |
|
||||
|
||||
## 矩阵(页面 × 行为)
|
||||
|
||||
| 页面/区域 | Owner | B1 | B2 | B3 | B4 | B5 | B6 | B7 | B8 | B9 | B10 | Review |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Navbar | Agent 5 | | | | | | | | | | ➖ | |
|
||||
| Leftnav | Agent 5 | | | | | | | | | | ➖ | |
|
||||
| 用户菜单 / 全局壳层 | Agent 5 | | | | | | | | | | ➖ | |
|
||||
| 语言切换器 | Agent 5 | | | | | | | | | ➖ | | |
|
||||
| Login | Agent 5 | | | | | | | | | | | |
|
||||
| Onboarding | Agent 5 | | | | | | | | | | | |
|
||||
| Connect | Agent 5 | | | | | | | | | | | |
|
||||
| MCP OAuth | Agent 5 | | | | | | | | | | | |
|
||||
| Models and Endpoints | Agent 6 | | | | | | | | | | | |
|
||||
| API Keys | Agent 6 | | | | | | | | | | | |
|
||||
| Usage | Agent 6A | | | | | | | | | | | |
|
||||
| Cost Tracking | Agent 6A | | | | | | | | | | | |
|
||||
| Budgets | Agent 6B | | | | | | | | | | | |
|
||||
| 全局:构建期 `<title>`/meta 英文 | Agent 4/8 | | | | | ➖ | | | | ➖ | ➖ | |
|
||||
|
||||
## 平台层回归(不绑定单页面)
|
||||
|
||||
| 项目 | 说明 | TC | 结果 |
|
||||
|---|---|---|---|
|
||||
| 默认语言 en(无偏好) | 全站 | TC-01 | |
|
||||
| `<html lang>` 一致 | 全局 | TC-08 | |
|
||||
| 刷新保持 | 全局 | TC-04 | |
|
||||
| 缺 key 英文回退 | 全局 | TC-06 | |
|
||||
| 不显示原始 key(首帧/全程) | 全局 | TC-07/26 | |
|
||||
| 静态产物 key 泄漏 / `<title>` 英文 | `out/` | TC-22/26 | |
|
||||
| en/zh key 集合一致 | 全部 namespace | TC-25 | |
|
||||
| 硬编码扫描报告 | 源码 | T-03 | |
|
||||
|
||||
## 门禁映射
|
||||
|
||||
- 本矩阵在 **Wave 4A** 全量执行(Agent 7 完成 + Agent 8 汇总 + Agent 2 体验复核 + Agent 0 终审)。
|
||||
- 对应完成定义(§11):支持双语言(1)、切换即时+刷新保持(2)、v1 页面中文化(3)、回退不露 key(4)、中文无严重截断重叠(5)、切换不清表单(6)、整页跳转保持(7)、`<title>`/meta 英文(8)、模型名/API 不误翻(9)、相关测试通过(10)、lint/format/build(11)。
|
||||
- 任何 `❌` 须挂缺陷进 `TASK_BOARD.md`,P0/P1 清零后才满足 G4。
|
||||
63
docs/i18n/TASK_BOARD.md
Normal file
63
docs/i18n/TASK_BOARD.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# LiteLLM Dashboard i18n — TASK_BOARD(多智能体进展看板)
|
||||
|
||||
> 维护者:Agent 0。**每启动/交付/Review 一个 Agent 即更新本表**。这是你查看"谁在工作、进展到哪"的单点真相。
|
||||
> 状态取值:待命 / 进行中 / 待 Review / 已完成 / 被阻塞
|
||||
|
||||
## Wave 0 — 并行设计(G0 门禁前)
|
||||
|
||||
| Agent | 名称 | 状态 | worktree/分支 | 交付物 | 最后更新 |
|
||||
|---|---|---|---|---|---|
|
||||
| A1 | i18n-architect | **已完成 ✅(G0 Review 通过)** | (纯设计) | I18N_TECH_DESIGN / I18N_ADR / POC_REPORT / TECH_RISKS | 2026-09-09 G0 |
|
||||
| A2 | localization-designer | **已完成 ✅(G0 Review 通过)** | (纯设计) | LOCALIZATION_SPEC / GLOSSARY_EN_ZH(63条) / LANGUAGE_SWITCHER_SPEC / V1_TRANSLATION_SCOPE / LOCALE_NAVIGATION_BEHAVIOR | 2026-09-09 G0 |
|
||||
| A3 | qa-architect | **已完成 ✅(G0 Review 通过)** | (纯设计) | I18N_TEST_PLAN / TEST_CASES(26) / TEST_TOOLS / REGRESSION_MATRIX | 2026-09-09 G0 |
|
||||
|
||||
**G0 门禁结论(Agent 0,2026-09-09):APPROVED**
|
||||
- 首屏策略已选定「就绪门禁 + `<html lang>` 同步」(ADR-04,非"再评估")。
|
||||
- 后端无 `UI settings.language`(ADR-07 Accepted)。
|
||||
- 构建期 `<title>`/meta 保持英文边界已记录(ADR-06 Accepted)。
|
||||
- 整页跳转语言保持规则已写入本地化与测试方案(LOCALE_NAVIGATION_BEHAVIOR + TEST_CASES TC-16..18)。
|
||||
- E2E 基建缺口已定:选 **a(Wave 1 补齐 Playwright)**,见 DECISIONS P3/P4。
|
||||
- **G0 Review 发现 1 个需修正点(P5)**:语言偏好存储键名跨文档不一致(`dashboard.locale` vs `litellm.locale`)。**已定统一为 `litellm.locale`**,由 A4 以单一常量实现,A5/6 不直接操作存储。
|
||||
- G0 Review 确认 POC_REPORT 9 项必验项已具备(待 Wave 1 A4/A7 回填证据)。
|
||||
|
||||
## Wave 1 — 平台能力与测试基础
|
||||
|
||||
| Agent | 名称 | 状态 | worktree/分支 | 交付物 | 最后更新 |
|
||||
|---|---|---|---|---|---|
|
||||
| A4 | i18n-platform-developer | **实现完成 ✅;build 验证进行中** | i18n/w1-agent4-platform | src/i18n/** + 8 namespace 骨架 + layout + LanguageSwitcher + Playwright基建 + 依赖写入 + 30单测/7集成测试通过 | 2026-09-09 |
|
||||
| A5 | i18n-shell-auth-developer | **只读盘点完成 ✅** | —(只读) | W2_SHELL_INVENTORY.md(~170 key,25 文件) | 2026-09-09 |
|
||||
| A7 | i18n-qa | **工具开发完成 ✅** | i18n/w1-agent7-qa | scripts/i18n/check-keys + scan-hardcoded + 测试(待 A4 集成后正式跑 vitest) | 2026-09-09 |
|
||||
|
||||
**Wave 1 集成基线(Agent 0,本地,未 push)**
|
||||
- 分支 `i18n/w1-integration`(主 worktree)= 基线 `31e3a76d3f` → 平台 `37dd0678f6`(含你的修复 `6321c8b52d`)→ A7 QA `fff2c41c44` 合并。
|
||||
- A4 平台 33 单测 + 7 集成全过;A7 QA 19 测试全过(**修复 1 处 A7 测试漏引号 typo**)。
|
||||
- A7 的 check-keys 对 A4 8 namespace 端到端 **PASS**。
|
||||
- **未决**:集成分支 `npm run build` 验证中(后台);PLATFORM_VALIDATION_REPORT、PoC 回填待补;push 因无 GitHub 凭据暂缓(用户决定不 push)。
|
||||
|
||||
## Wave 2 — 第一批功能
|
||||
|
||||
| Agent | 名称 | 状态 | worktree/分支 | 交付物 | 最后更新 |
|
||||
|---|---|---|---|---|---|
|
||||
| A5 | i18n-shell-auth-developer | 待命 | i18n/w2-agent5-shell | 壳层 + common/navigation/auth namespace | - |
|
||||
| A6 | i18n-feature-developer | 待命 | i18n/w2-agent6-models | Models + API Keys | - |
|
||||
| A7 | i18n-qa | 待命 | i18n/w2-agent7-qa | 持续测试 | - |
|
||||
|
||||
## Wave 3 — 第二批功能
|
||||
|
||||
| Agent | 名称 | 状态 | worktree/分支 | 交付物 | 最后更新 |
|
||||
|---|---|---|---|---|---|
|
||||
| A6A | i18n-feature-developer (Usage/Cost) | 待命 | i18n/w3-agent6a-usage | Usage + Cost Tracking | - |
|
||||
| A6B | i18n-feature-developer (Budgets) | 待命 | i18n/w3-agent6b-budgets | Budgets | - |
|
||||
| A7 | i18n-qa | 待命 | i18n/w3-agent7-qa | 回归 | - |
|
||||
|
||||
## Wave 4 — 集成验收
|
||||
|
||||
| Agent | 名称 | 状态 | 交付物 | 最后更新 |
|
||||
|---|---|---|---|---|
|
||||
| A2 | localization-designer | 待命 | 术语/中文体验复核 | - |
|
||||
| A7 | i18n-qa | 待命 | 完整回归 + 质量报告 | - |
|
||||
| A8 | i18n-integration-release | 待命 | 发布/回滚清单 | - |
|
||||
|
||||
## 缺陷队列
|
||||
(Wave 4B 按 P0 → P1 → 阻塞门禁 P2 → 其他 P2 排序)
|
||||
(空)
|
||||
109
docs/i18n/TECH_RISKS.md
Normal file
109
docs/i18n/TECH_RISKS.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# LiteLLM Dashboard i18n — 技术风险清单(TECH_RISKS)
|
||||
|
||||
> 角色:Agent 1(`i18n-architect`)· Wave 0
|
||||
> 编号 R1..Rn。每条含:风险 / 影响 / 缓解 / Owner。Owner 为「最需要跟踪/缓解」的角色,不一定是唯一。
|
||||
> 关联:`I18N_TECH_DESIGN.md`、`I18N_ADR.md`、`POC_REPORT.md`。
|
||||
|
||||
---
|
||||
|
||||
## R1:静态导出下首屏语言闪烁 / 错误语言闪现
|
||||
|
||||
- **风险**:切 zh-CN 用户首帧可能短暂显示英文或暴露 key,体验倒退。D7 要求不得停留在「再评估」。
|
||||
- **影响**:高(首屏是 G0/G1 门禁项;直接违背 §3.1)。
|
||||
- **缓解**:已选定「语言就绪门禁 + 挂载后同步 `<html lang>`」(ADR-i18n-04);就绪前不渲染业务 `t()`/`<Trans>`(ADR-i18n-03)。PoC-2/3 验证并量化门禁延时。
|
||||
- **Owner**:A4(实现)→ A7(验证)。
|
||||
|
||||
## R2:`react-i18next` / `i18next` 与 React 19 / Next 16 的运行时兼容性未实测
|
||||
|
||||
- **风险**:`npm view` peer 通过,但实际打包(turbopack/next export)与 React 19 可能有边界问题(如 `useSyncExternalStore`、HMR)。
|
||||
- **影响**:中(构建失败或运行时异常会阻断 Wave 1)。
|
||||
- **缓解**:PoC-1 先做最小依赖安装 + `next build`;锁定 `^17`/`^26` 稳定大版本;若发现问题回退 17/26 的补丁小版本并记录。
|
||||
- **Owner**:A4(先做)→ A7(复验)。
|
||||
|
||||
## R3:`<Trans>` 因资源未就绪而瞬态暴露占位/key
|
||||
|
||||
- **风险**:若个别组件绕开统一门禁,`<Trans>` 可能先渲染占位符再替换。
|
||||
- **影响**:中高(违背 §3.1,且 `<Trans>` 难以用简单 `t()` 兜底)。
|
||||
- **缓解**:统一顶层门禁覆盖全局子树;组件 `ready` 兜底;CI 键集合一致性检查(R9)+ 代码评审拦截绕过。
|
||||
- **Owner**:A4(平台)→ A5/A6(使用)→ A7(回归)。
|
||||
|
||||
## R4:语言偏好 cookie/localStorage 不一致或跨跳转丢失
|
||||
|
||||
- **风险**:cookie 与 localStorage 双写可能不一致;`SameSite` 过严导致 SSO/整页跳转回带失败,或过宽带来安全面。
|
||||
- **影响**:中(刷新/回跳语言恢复失败,D10 不满足)。
|
||||
- **缓解**:以显式写入顺序为准的收敛规则(ADR-i18n-05);`SameSite=Lax` + 生产 `Secure`;PoC-5/7 覆盖 cookie 清空/localStorage 留存、整页回跳各分支。
|
||||
- **Owner**:A4 → A7。
|
||||
|
||||
## R5:依赖文件被多 Agent 并行写坏(锁分叉)
|
||||
|
||||
- **风险**:`package.json`/lock 在多 worktree 并行 `npm install/ci` 产生分叉,G1 集成冲突。
|
||||
- **影响**:高(阻塞集成、浪费波次)。
|
||||
- **缓解**:D12 单一写入者(默认仅 A4);下游从 G1 基线 `npm ci` 且不写 lock;v1.4 变更摘要已固化。
|
||||
- **Owner**:A4(执行)→ A0(review)。
|
||||
|
||||
## R6:功能 Agent 与平台共享单点(`src/i18n/**`、注册表、公共 JSON)写冲突
|
||||
|
||||
- **风险**:多人同时改初始化代码/注册表/大 JSON。
|
||||
- **影响**:中(合并冲突、注册表被破坏导致类型/加载异常)。
|
||||
- **缓解**:FILE_OWNERSHIP 规则:`src/i18n/**` 与骨架归 A4 永久独占;新增 namespace 走 A0 派单由 A4 注册;功能 Agent 只写自己 namespace。
|
||||
- **Owner**:A0(调度)→ A4(执行)。
|
||||
|
||||
## R7:en/zh 字典键集合漂移(缺 key/多 key)
|
||||
|
||||
- **风险**:中文更新新增 key 但 en 未同步(或反之),导致运行期 missingKey 回退/多出 key。
|
||||
- **影响**:中(D2 要求 en 为真源、zh 缺 key 回退;键集合须一致 §3.4)。
|
||||
- **缓解**:CI 增加「en vs zh-CN 键集合 diff」校验(可在 `test:types` 或独立脚本,纳入 Agent 7 质量门禁);`CustomTypeOptions` 类型约束 + 开发模式 missingKey warning。
|
||||
- **Owner**:A7(CI/校验)→ A4(类型)→ 各功能 Agent(补 key)。
|
||||
|
||||
## R8:cookie 安全与隐私(`SameSite`、`Secure`、XSS 可读)
|
||||
|
||||
- **风险**:语言非秘密,但 cookie 可被 XSS 读取;`SameSite=None` 若被误用扩大攻击面。
|
||||
- **影响**:低-中(隐私面小;但需遵守 CLAUDE.md「勿把 token 放 localStorage」精神,语言偏好非敏感)。
|
||||
- **缓解**:语言偏好非敏感可放心;cookie `SameSite=Lax; path=/;`,生产 `Secure`;绝不用此 cookie 存令牌;仅当 SSO 第三方域确需带 cookie 时,再评估 `None` 并单独评审。
|
||||
- **Owner**:A4。
|
||||
|
||||
## R9:硬编码文案漏网(中文环境下仍有英文句/key 可见)
|
||||
|
||||
- **风险**:1400+ TSX,批量改造难免遗漏;产物残留 key 串或英文句。
|
||||
- **影响**:中(本地化不完整、观感差)。
|
||||
- **缓解**:扫描工具只报告候选(不改写,§3.4);PoC-9 对产物 grep 残留;纳入 v1 完成定义与 A7 回归;模块按 high-traffic 优先级(导航/登录/Models/API Keys/Usage/Cost/Budgets)。
|
||||
- **Owner**:A5/A6(改造)→ A7(扫描/回归)。
|
||||
|
||||
## R10:静态导出 + 多语言无 SEO,`<title>`/meta 恒为英文
|
||||
|
||||
- **风险**:搜索引擎与分享卡片恒为英文,非本地化。
|
||||
- **影响**:低(D8 明确 v1 不做多语言 SEO)。
|
||||
- **缓解**:接受为 v1 边界;文档记录未来可引入 per-locale 静态 `title`(ADR-i18n-06 后果)。
|
||||
- **Owner**:A0(确认范围)。
|
||||
|
||||
## R11:管理员全局语言设置未来引入(P1)可能改变优先级语义
|
||||
|
||||
- **风险**:后端未来新增 `UI settings.language` 字段,需决定「覆盖用户选择」语义,否则现有优先级迁移需返工。
|
||||
- **影响**:低(v1 无此字段,ADR-i18n-07 已封闭);中(若 v1.1 临时加入则会波及优先级链)。
|
||||
- **缓解**:预留 L5 判定位(I18N_TECH_DESIGN §3.2),把「用户显式」与「探测」分开存储,便于未来叠加管理员全局层而不混淆。
|
||||
- **Owner**:A1(设计预留)→ A0(接产品决策)。
|
||||
|
||||
## R12:中文文本在窄布局/表格溢出(文本长度不同导致 UI 破坏)
|
||||
|
||||
- **风险**:中文普遍比英文紧凑,但长译句或 `{{count}}` 拼接可能导致表格/按钮溢出或换行异常。
|
||||
- **影响**:中(布局回归)。
|
||||
- **缓解**:Wave 1 起在 Login/Navbar/表格样例做中英文并排截图比对;纳入 Agent 3 测试与 A7 回归;`whitespace-nowrap`/截断类妥善处理。
|
||||
- **Owner**:A6(功能页)→ A8(集成验收)。
|
||||
|
||||
## R13:`.json` 资源体积随 namespace 增长、首包加大
|
||||
|
||||
- **风险**:所有 locale 静态打在一起可能增大 bundle;`turbopack` 打包 json 的方式需确认。
|
||||
- **影响**:低-中(Dashboard 体量下影响有限;但静态导出无 CDN 分片语言包,加载即全量)。
|
||||
- **缓解**:PoC-1 检查产物 json 是否被正确 tree-shake/分 chunk;v1 语言仅 2 个,体积可控;若未来多语言再评估代码分割。
|
||||
- **Owner**:A4 → A8。
|
||||
|
||||
---
|
||||
|
||||
## 风险热力(供 A0 优先级参考)
|
||||
|
||||
| 等级 | 风险 |
|
||||
|---|---|
|
||||
| 高 | R1(首屏)、R5(锁分叉) |
|
||||
| 中高 | R3(`<Trans>`)、R9(硬编码遗漏) |
|
||||
| 中 | R2、R4、R6、R7、R12 |
|
||||
| 低 | R8、R10、R11、R13 |
|
||||
257
docs/i18n/TEST_CASES.md
Normal file
257
docs/i18n/TEST_CASES.md
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
# i18n 测试用例(TEST_CASES.md)
|
||||
|
||||
> 角色:Agent 3 `qa-architect`
|
||||
> 依据:`I18N_MULTI_AGENT_PLAN.md` §10 测试重点、§11 完成定义;`I18N_TEST_PLAN.md`
|
||||
> 实施:Agent 7 `i18n-qa`
|
||||
> 命名:层用 `unit`(node `*.test.ts`) / `component`(`*.test.tsx`) / `integration`(`*.integration.test.tsx`) / `e2e`(Playwright `tests/e2e/ui/`);`*` = 自动化,`(m)` = 人工辅助
|
||||
|
||||
每个用例字段:编号、层级、描述、前置、步骤、断言、自动化与否。
|
||||
|
||||
---
|
||||
|
||||
## A. 语言初始化与持久化
|
||||
|
||||
### TC-01 默认语言为英文
|
||||
- 层级:`e2e`
|
||||
- 描述:首次访问(无任何语言偏好)界面与 `<html lang>` 均为英文 `en`。
|
||||
- 前置:清空语言偏好存储(cookie/localStorage)、浏览器无中文偏好;静态导出 `out/` 已构建。
|
||||
- 步骤:1. 无偏好打开根路由。2. 读取 `<html lang>` 与可见文案。
|
||||
- 断言:`<html lang="en">`;界面文案为英文;不出现原始 key。
|
||||
- 自动化:*
|
||||
|
||||
### TC-02 默认语言英文(逻辑合并,无偏好)
|
||||
- 层级:`unit`(`*.test.ts`)
|
||||
- 描述:语言选择合并函数在"无偏好 + 浏览器英文"时归 `en`。
|
||||
- 前置:逻辑纯函数已抽出。
|
||||
- 步骤:调用合并函数,输入空偏好与 "en"。
|
||||
- 断言:返回 `"en"`。
|
||||
- 自动化:*
|
||||
|
||||
### TC-03 切换语言即时生效
|
||||
- 层级:`component` + `e2e`
|
||||
- 描述:切换 `en → zh-CN`,页面文本与 `<html lang>` 即时更新,无需刷新。
|
||||
- 前置:语言切换器与页面在 Provider 下渲染;资源就绪。
|
||||
- 步骤:component:渲染带 `I18nProvider`(可 stub 网络)的页面 → 点击切换器选中文 → 断言关键文本与 lang。e2e:真实浏览器同操作。
|
||||
- 断言:Navbar/Leftnav 等中文化文本出现;`<html lang="zh-CN">`;无原始 key、无刷新。
|
||||
- 自动化:*
|
||||
|
||||
### TC-04 刷新后语言保持
|
||||
- 层级:`e2e`
|
||||
- 描述:切到中文后刷新页面,语言仍为中文。
|
||||
- 前置:偏好写入介质(cookie/localStorage,依架构决策)。
|
||||
- 步骤:1. 切成中文。2. `page.reload()`。3. 读 `<html lang>` 与可见文本。
|
||||
- 断言:`<html lang="zh-CN">`,界面仍为中文。
|
||||
- 自动化:*
|
||||
|
||||
### TC-05 语言偏好在同源介质持久化(单元)
|
||||
- 层级:`unit`
|
||||
- 描述:偏好读写到可注入 storage/cookie 抽象,序列化往返一致。
|
||||
- 前置:偏好读写逻辑独立可测。
|
||||
- 步骤:写入 `"zh-CN"` → 重新读取。
|
||||
- 断言:读回 `"zh-CN"`;存储键稳定、无多余数据。
|
||||
- 自动化:*
|
||||
|
||||
## B. 回退与 key 遮蔽
|
||||
|
||||
### TC-06 缺失中文时回退英文
|
||||
- 层级:`component` + `e2e`
|
||||
- 描述:zh 字典缺某 key 时,渲染英文而非原始 key。
|
||||
- 前置:中文态 + 注入缺失 key 的字典子集(或 e2e 用构建产物中的真实缺口做抽样)。
|
||||
- 步骤:切换到中文,访问缺失 key 所属文案。
|
||||
- 断言:显示对应英文译文;**不**显示 `ns:key` 原始 key。
|
||||
- 自动化:*
|
||||
|
||||
### TC-07 不显示原始翻译 key(首帧/全程)
|
||||
- 层级:`e2e`(首帧)+ `component`(全程)
|
||||
- 描述:无论资源加载时序,任何时刻帧内都不泄漏原始 key 文本(`ns:...`)。
|
||||
- 前置:资源同步就绪或就绪门禁生效;`<Trans>`/`t()` 就绪后渲染。
|
||||
- 步骤:e2e 首次导航截图 + 快速截图序列,扫描页面可见文本。
|
||||
- 断言:页面文本不包含形如 `namespace:key` 或 `key.xxx` 的原始 key 串。
|
||||
- 自动化:*
|
||||
|
||||
### TC-08 `<html lang>` 与当前语言一致
|
||||
- 层级:`component` + `e2e`
|
||||
- 描述:`<html lang>` 始终等于当前语言(en/zh-CN)。
|
||||
- 前置:`<html lang>` 客户端同步已实现。
|
||||
- 步骤:分别在 en、zh、切换后、刷新后读取 `document.documentElement.lang`。
|
||||
- 断言:en→`"en"`,zh→`"zh-CN"`;切换与刷新后保持一致。
|
||||
- 自动化:*
|
||||
|
||||
## C. 动态数量 / 日期 / 数字 / 货币
|
||||
|
||||
### TC-09 中文数量插值 count=0 / 1 / 2(D9)
|
||||
- 层级:`unit`(核心)+ `component`(渲染)
|
||||
- 描述:中文用计数插值与量词:`0 个`、`1 个`、`2 个`/`{{count}} 个`;不机械复制英文复数分支。
|
||||
- 前置:量词插值组件/函数已实现,`{{count}}` 语义。
|
||||
- 步骤:依次以 `count=0`、`count=1`、`count=2` 渲染/调用,中文态。
|
||||
- 断言:输出分别为 `0 个请求`、`1 个请求`、`2 个请求`(按术语表措辞);无英文 one/other 分支语义。
|
||||
- 自动化:*
|
||||
|
||||
### TC-10 英文数量复数
|
||||
- 层级:`unit`
|
||||
- 描述:英文按 i18next/CLDR `one`/`other` 处理:`1 request` vs `2 requests`。
|
||||
- 前置:英文资源含复数分支。
|
||||
- 步骤:`count=1`、`count=2` 渲染。
|
||||
- 断言:`1 request`、`2 requests`。
|
||||
- 自动化:*
|
||||
|
||||
### TC-11 日期本地化
|
||||
- 层级:`unit`
|
||||
- 描述:日期按当前语言格式化(e.g. `Sep 9, 2026` vs 中文格式),时区/时刻固定。
|
||||
- 前置:固定时间源、固定 locale;格式化逻辑独立。
|
||||
- 步骤:同一时间戳在 en、zh 下格式化。
|
||||
- 断言:格式随 locale 变化且正确;无 `NaN`/原始时间戳泄漏。
|
||||
- 自动化:*
|
||||
|
||||
### TC-12 数字本地化
|
||||
- 层级:`unit`
|
||||
- 描述:千分位/小数点随 locale(en `1,234.5` vs zh 千分位),金额单位不重复。
|
||||
- 前置:格式化函数独立。
|
||||
- 步骤:en、zh 各格式化同值。
|
||||
- 断言:分组/小数正确;不出现双重符号或单位。
|
||||
- 自动化:*
|
||||
|
||||
### TC-13 货币本地化
|
||||
- 层级:`unit`
|
||||
- 描述:货币符号与金额按 context/locale 呈现(成本/预算页)。
|
||||
- 前置:货币格式化上下文确定(币种来自数据)。
|
||||
- 步骤:en、zh 各格式化同金额。
|
||||
- 断言:符号与格式正确;数据币种不被误翻。
|
||||
- 自动化:*
|
||||
|
||||
## D. 表单与交互状态
|
||||
|
||||
### TC-14 切换语言不清空表单
|
||||
- 层级:`integration`
|
||||
- 描述:填写表单字段后切换语言,字段值与未提交状态保持,不触发无关请求。
|
||||
- 前置:集成树(真实组件 + Provider),网络边界 stub。
|
||||
- 步骤:填写 → 切语言 → 断言字段值与 `<form>` 状态保持。
|
||||
- 断言:输入值仍在、选择/勾选仍在;无提交/异常请求触发;无原始 key。
|
||||
- 自动化:*
|
||||
|
||||
### TC-15 切换语言不清空表单(E2E 抽查)
|
||||
- 层级:`e2e`
|
||||
- 描述:真实浏览器下填写关键表单(如 Models)后切换语言,值保持。
|
||||
- 前置:静态导出站点可用。
|
||||
- 步骤:填写 → 切中文 → 断言。
|
||||
- 断言:值与交互状态保持。
|
||||
- 自动化:*(抽查核心表单)
|
||||
|
||||
## E. 整页跳转语言保持
|
||||
|
||||
### TC-16 Login 整页跳转回跳后语言保持
|
||||
- 层级:`e2e`
|
||||
- 描述:切成中文 → 走 Login(整页跳转)→ 成功回跳 → 语言仍为中文。
|
||||
- 前置:Login 整页跳转与同源偏好恢复已 PoC/批准。
|
||||
- 步骤:设中文 → 登录跳转 → 回跳 → 读 `<html lang>`/文本。
|
||||
- 断言:中文保持或命中文档化降级方案(需 Agent 0 批准)。
|
||||
- 自动化:*
|
||||
|
||||
### TC-17 SSO 整页跳转回跳后语言保持
|
||||
- 层级:`e2e`
|
||||
- 描述:SSO 外部跳转并返回后语言偏好保持。
|
||||
- 前置:SSO 流程可测(测试账号/模拟)。
|
||||
- 步骤:设中文 → SSO → 返回 → 断言。
|
||||
- 断言:同 TC-16。
|
||||
- 自动化:*
|
||||
|
||||
### TC-18 MCP OAuth 整页跳转回跳后语言保持
|
||||
- 层级:`e2e`
|
||||
- 描述:MCP OAuth 授权跳转并返回后语言保持。
|
||||
- 前置:MCP OAuth 流程可测(mock proxy)。
|
||||
- 步骤:设中文 → OAuth → 返回 → 断言。
|
||||
- 断言:同 TC-16。
|
||||
- 自动化:*
|
||||
|
||||
### TC-19 存储不可用时的降级
|
||||
- 层级:`unit` + `e2e`
|
||||
- 描述:偏好存储不可用/被禁时降级到英文,不崩溃、不报错。
|
||||
- 前置:存储抽象可注入失败。
|
||||
- 步骤:注入读写失败 → 初始化 → 断言。
|
||||
- 断言:回退 `en`;无未捕获异常;不显示原始 key。
|
||||
- 自动化:*
|
||||
|
||||
## F. 不误翻译保护
|
||||
|
||||
### TC-20 模型名不被翻译
|
||||
- 层级:`integration`
|
||||
- 描述:Models 页面渲染的模型名/模型 id 在 en 与 zh 下逐字一致,不受语言切换影响。
|
||||
- 前置:stub 数据含大小写混合/带符号模型名。
|
||||
- 步骤:en、zh 各渲染并比对模型名文本。
|
||||
- 断言:模型名字符串完全一致;不被译成中文/改名。
|
||||
- 自动化:*
|
||||
|
||||
### TC-21 API 字段 / 日志 / 代码示例不被翻译
|
||||
- 层级:`integration`(代码示例/字段)+ `unit`(日志原文)
|
||||
- 描述:API 参数字段、请求体 key、日志原文、代码示例不参与翻译。
|
||||
- 前置:含上述内容的组件与最小字典。
|
||||
- 步骤:zh 下渲染包含代码/字段/日志的页面。
|
||||
- 断言:这些内容保持原样(ASCII/原文),与 en 一致。
|
||||
- 自动化:*
|
||||
|
||||
## G. 构建期元数据与可访问性
|
||||
|
||||
### TC-22 构建期 `<title>` / meta description v1 保持英文
|
||||
- 层级:`e2e`
|
||||
- 描述:静态导出 `out/index.html`(及各路由)的 `<title>` 与 `meta[name=description]` 在 v1 保持英文,未被功能 Agent 意外改写或半中文化。
|
||||
- 前置:`npm run build` 完成;检查静态产物。
|
||||
- 步骤:解析 `out/**/*.html` 的 `<title>`/description(或浏览器读取 `document.title`)。
|
||||
- 断言:为稳定英文文案;不含原始 key、不含半成品中文。
|
||||
- 自动化:*
|
||||
|
||||
### TC-23 可访问性文案本地化(aria-label/title/placeholder)
|
||||
- 层级:`component`(每组件)+ `integration`(成组页面)
|
||||
- 描述:图标按钮、关闭按钮、tooltip、`title`、`placeholder`、校验 error 的 a11y 文本随语言本地化并有英文回退。
|
||||
- 前置:目标组件含该类文案。
|
||||
- 步骤:en、zh 各查询 `getByRole`/`getByLabel`/`toHaveAttribute('aria-label', …)`。
|
||||
- 断言:a11y 文案为对应语言;zh 缺失时回退英文;不显示原始 key;ARIA value 用 `toHaveAttribute` 断言(CLAUDE.md)。
|
||||
- 自动化:*
|
||||
|
||||
### TC-24 中文布局不溢出(自动断言 + 人工走查)
|
||||
- 层级:`integration`(自动溢出)/ `e2e`(窄屏)+ 人工
|
||||
- 描述:中文下导航/表格/弹窗/表单无横向溢出、无截断、无遮挡。
|
||||
- 前置:切换到中文。
|
||||
- 步骤:对关键容器断言 `scrollWidth <= clientWidth`、关键文本 `toBeVisible`;e2e 窄视口;人工跨语言走查。
|
||||
- 断言:无溢出/截断/遮挡;`V1_SCOPE_MANIFEST.md` UI 检查证据。
|
||||
- 自动化:*(溢出断言)+ (m)(视觉走查)
|
||||
|
||||
## H. Key / 字典一致性
|
||||
|
||||
### TC-25 en/zh key 集合一致
|
||||
- 层级:`unit`(脚本级,作为静态校验)——实现为 CLI 工具(`TEST_TOOLS.md`);此处用例=脚本通过条件
|
||||
- 描述:en 与 zh 全部 namespace 的 key 集合、结构与插值变量一致。
|
||||
- 前置:脚本就绪、字典就绪。
|
||||
- 步骤:对每 namespace 运行一致性校验。
|
||||
- 断言:key 集对称一致;缺 key/多余 key/结构不一致/插值变量不一致时脚本非 0 退出并列差异。
|
||||
- 自动化:*(门禁/CI)
|
||||
|
||||
## I. 首屏 / 静态导出
|
||||
|
||||
### TC-26 静态导出构建产物无原始 key 泄漏
|
||||
- 层级:`e2e`/静态产物检查
|
||||
- 描述:构建后的 `out/` 页面源码不含原始 `ns:key` 文本作为用户可见内容(排除 JS bundle 中的字典本身)。
|
||||
- 前置:`npm run build`。
|
||||
- 步骤:解析产物 HTML,过滤 script/style,扫描可见文本。
|
||||
- 断言:可见文本无原始 key 形式。
|
||||
- 自动化:*
|
||||
|
||||
---
|
||||
|
||||
## 用例汇总
|
||||
|
||||
- 共 26 条(TC-01 ~ TC-26)。
|
||||
- 自动化纯自动:除 TC-24 视觉走查外全部为 `*`;TC-24 为自动化+人工混合。
|
||||
- 覆盖方案 §10 全部"测试重点":
|
||||
- 默认语言:TC-01/02
|
||||
- 切换即时生效:TC-03
|
||||
- 刷新保持:TC-04/05
|
||||
- 缺 key 回退英文:TC-06
|
||||
- 不显示原始 key:TC-07/26
|
||||
- `<html lang>` 一致:TC-03/08
|
||||
- 中文不溢出:TC-24
|
||||
- 切换不清表单:TC-14/15
|
||||
- 整页跳转语言保持:TC-16/17/18/19
|
||||
- 动态数量/日期/数字/货币(含 count=0/1/2):TC-09~13
|
||||
- 模型名/API 字段不误翻:TC-20/21
|
||||
- `<title>`/meta v1 保持英文:TC-22
|
||||
- 另补充:a11y 文案(TC-23)、key 一致性(TC-25)。
|
||||
88
docs/i18n/TEST_TOOLS.md
Normal file
88
docs/i18n/TEST_TOOLS.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# i18n 自动化测试任务清单与工具设计(TEST_TOOLS.md)
|
||||
|
||||
> 角色:Agent 3 `qa-architect`;实施:Agent 7 `i18n-qa`
|
||||
> 原则:扫描工具**只报告、不自动改写**(方案 §3.4/§11);测试只跑改动相关文件;Vitest 命令由 Agent 3 依配置确认(方案 §10)
|
||||
|
||||
## 0. 运行说明(Agent 7 前置校准)
|
||||
|
||||
- 定向 vitest:`cd ui/litellm-dashboard && npx vitest run --project <unit|component|integration> <path>`;type:`npm run test:types`。
|
||||
- 禁止无路径全量 `npm run test`(380 文件、CI 才跑全量)。
|
||||
- 工程检查:`npm run lint`、`npm run format:check`、`npm run build`。
|
||||
- **E2E Playwright 基础设施当前不存在**(无 `playwright.config.*`、无 `@playwright/test`、无 `tests/e2e/ui/`)。Agent 7 在 Wave 1 搭建;涉及 `package.json`/锁文件变更时必须由 Agent 0 指派唯一 Owner(方案 §6.1)。
|
||||
|
||||
## 1. 自动化任务清单
|
||||
|
||||
| 编号 | 任务 | 形态 | 何时运行 | 对应用例 |
|
||||
|---|---|---|---|---|
|
||||
| T-01 | 搭建 Playwright 基础(config、`tests/e2e/ui/`、live proxy fixture) | E2E 基建 | Wave 1 | TC-01~04,06~08,16~18,22,26 |
|
||||
| T-02 | key 集合一致性校验脚本 | CLI(node) | G3 门禁/CI、每次字典变更 | TC-25 |
|
||||
| T-03 | 硬编码字符串扫描脚本(只报告) | CLI(node) | 每波次、G3/G4 | — |
|
||||
| T-04 | 语言初始化/偏好/格式化逻辑单元测试 | `*.test.ts`(unit) | 平台 Wave 1 | TC-02,05,09~13,19 |
|
||||
| T-05 | 语言切换器/量词/单组件渲染测试 | `*.test.tsx`(component) | 各功能波 | TC-03,09,10,23 |
|
||||
| T-06 | Provider+页面集成测试(切换、表单保持、a11y、误翻译) | `*.integration.test.tsx` | 各功能波 | TC-03,14,20,21,23,24 |
|
||||
| T-07 | 静态导出产物 key 泄漏/`<title>` 英文检查 | 构建后脚本/E2E | G1、G4 | TC-22,26 |
|
||||
| T-08 | 中文布局溢出自动化断言(+人工走查入口) | `integration`/`e2e` | 各功能波、Wave 4A | TC-24 |
|
||||
| T-09 | `V1_SCOPE_MANIFEST.md` 测试/UI 检查证据回填 | 人工+半自动 | G2/G3/G4 | — |
|
||||
| T-10 | 完整回归矩阵执行并出报告 | Playwright+vitest | Wave 4A | REGRESSION_MATRIX |
|
||||
|
||||
## 2. key 集合一致性校验脚本(T-02)
|
||||
|
||||
**目标**:证明 en/zh(及新 locale)key 集、结构与插值变量一致;组件引用的 key 均存在。
|
||||
|
||||
**设计**:
|
||||
- 输入:`src/locales/en/**/*.json`、`src/locales/zh-CN/**/*.json`。
|
||||
- 对每个 namespace 比对:key 集合(递归扁平化,含冒号前缀)对称差、嵌套结构形状、每叶子的 `{{var}}` 插值变量集合。
|
||||
- 组件引用校验:AST 扫描 `t('ns:key')`、`useTranslation('ns')`、`<Trans>` 与静态字典,找出引用但未声明的 key(悬空引用)。
|
||||
- 输出:无差异 → 退出 0;有差异 → 列出缺失/多余 key、结构/插值差异、悬空引用清单,退出非 0。
|
||||
- 挂接:G3 门禁"中英文 key 集一致";可加入 CI lint 前置。
|
||||
|
||||
**命令形态**:
|
||||
```bash
|
||||
node scripts/i18n/check-keys.mjs # 或通过 vitest 一个用例驱动(TC-25)
|
||||
```
|
||||
|
||||
## 3. 硬编码字符串扫描脚本(T-03)——只报告、不自动改写
|
||||
|
||||
**目标**:发现"漏改为 `t()` 的用户可见文案",辅助人工补翻译。**禁止自动生成 key、禁止自动改写代码**。
|
||||
|
||||
**设计**(对 TSX/TS 源码 AST):
|
||||
- JSX 文案:非空白/非注释/非纯符号的 `JSXText`。
|
||||
- 属性字面量:`aria-label`、`title`、`placeholder`、`alt` 中指向文案的字符串常量。
|
||||
- 未国际化入口:`toast(...)`、`useMessage`、`notify(...)` 等(以项目现状校准 white-list)。
|
||||
- 排除白名单:模型名/API 字段引用(`model`、`langsmith` 等字典值来源)、路径、正则、CSS 类、`data-slot`、纯数字/符号、标识符、已 `t()` 包裹的。
|
||||
- 输出:文件、行号、候选文案、疑似命中形式(文案属性/JSX 文本/消息入口),按文件分组;**附带"仅报告"明确标注**,不写字典。
|
||||
|
||||
**报告消费**:Agent 7 输出人工 review 清单 → 产品/功能 Owner 决定是否改写;禁止工具自动化改写(方案 §3.4 硬约束)。
|
||||
|
||||
## 4. 中文布局检查:自动化 / 人工边界
|
||||
|
||||
**自动化(T-08)**:
|
||||
- 组件/集成断言:中文渲染下对关键容器断言 `scrollWidth <= clientWidth`(无横向溢出)、关键文本 `toBeVisible`、无遮挡(元素间不重叠)。
|
||||
- E2E:在窄视口(如 1280、1024、768)下对 Navbar/Leftnav/表格/弹窗断言同样条件,并做关键帧截图。
|
||||
- 限制:视觉"美观/语义完整"无法纯自动化判定,自动只兜底"不溢出、不截断语义、可读可见"。
|
||||
|
||||
**人工(配合 Agent 2)**:
|
||||
- 跨语言(en/zh)并列视觉走查,覆盖中文长字符串与窄屏。
|
||||
- 截图证据回填 `V1_SCOPE_MANIFEST.md` 的 `UI 检查` 栏。
|
||||
- `TEST_CASES.md` TC-24 标记为自动化+人工混合。
|
||||
|
||||
## 5. 静态导出产物检查(T-07)
|
||||
|
||||
- 构建后扫描 `out/**/*.html`:
|
||||
- 用户可见文本(剔除 script/style/内联 JSON 字典)不含 `namespace:key` 原始 key 形式(TC-26)。
|
||||
- `<title>` 与 `meta[name=description]` 为稳定英文、非原始 key、非半中文(TC-22)。
|
||||
- 挂接:G1(首屏/构建验证)与 G4(发布验收)。
|
||||
|
||||
## 6. E2E 基建要点(T-01)
|
||||
|
||||
- 目录:`tests/e2e/ui/`(Playwright spec,与 vitest 分离)。
|
||||
- 目标:静态导出 `out/` 或 live proxy;语言切换/刷新/整页跳转(Login/SSO/MCP OAuth)的偏好恢复需可稳定复现(测试账号或 mock)。
|
||||
- `package.json` 变更(新增 `@playwright/test`)须经 Agent 0 指定唯一 Owner,遵守 §6.1。
|
||||
|
||||
## 7. Agent 7 建议实现优先级
|
||||
|
||||
1. **T-02 key 一致性校验**(成本低、门禁 G3 硬性、最易踩线)。
|
||||
2. **T-03 硬编码扫描**(只报告,贯穿所有波次,辅助功能 Agent 自查)。
|
||||
3. **T-01 E2E 基建 + 平台 smoke**(默认语言/切换/刷新/`<html lang>`/回退/不显示原始 key)。
|
||||
4. **T-07 静态产物 key 泄漏 + `<title>` 英文检查**(G1 门禁)。
|
||||
5. **T-04/T-05/T-06 各层测试**随功能波补齐,T-08 布局断言进入各模块验收,T-10 在 Wave 4A 全量回归。
|
||||
78
docs/i18n/V1_TRANSLATION_SCOPE.md
Normal file
78
docs/i18n/V1_TRANSLATION_SCOPE.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# v1 翻译范围清单(V1 Translation Scope)
|
||||
|
||||
> 维护者:Agent 2(localization-designer)
|
||||
> 状态:设计稿,待 G0 评审冻结;与 `I18N_MULTI_AGENT_PLAN.md` §1.2/§6.2 及 `DECISIONS.md` D8 对齐
|
||||
> 说明:本文档逐项列出 v1 各页面/组件"**需翻译**"或"**明确不翻译**"及原因,作为 Agent 5/6 改造与 Review 的依据。数据/协议内容是否翻译遵循 `LOCALIZATION_SPEC.md` §4。
|
||||
|
||||
---
|
||||
|
||||
## 1. v1 需翻译范围(逐项)
|
||||
|
||||
### 1.1 全局壳层
|
||||
|
||||
| 组件/区域 | 需翻译? | 原因 / 说明 |
|
||||
|---|---|---|
|
||||
| `leftnav.tsx` 导航菜单项与分组标签 | 需翻译 | 全局入口;`menuGroups` 的 `groupLabel`、`label` 需 I18N 化(含分组标签如 AI Gateway/Observability 等)。`menuGroups` 由 `page_utils.ts` 消费,改造时保留导出结构,仅语义化 label |
|
||||
| `leftnav.tsx` 面包屑(`getBreadcrumb`)/ Navbar 标题 | 需翻译 | 与导航一致,防止两处不一致 |
|
||||
| `leftnav.tsx` aria-label(折叠/展开侧边栏) | 需翻译 | a11y 文案(`LOCALIZATION_SPEC.md` §7) |
|
||||
| Navbar 用户菜单 / `SidebarAccountMenu` | 需翻译 | 显示名、菜单项、提示文案 |
|
||||
| `navbar.tsx` 其余可见菜单与搜索占位 | 需翻译 | 依实际文案盘点 |
|
||||
| 语言切换器(新组件) | 需翻译 | 自身 aria-label 与菜单项随语言渲染 |
|
||||
|
||||
### 1.2 登录与引导
|
||||
|
||||
| 页面 | 需翻译? | 说明 |
|
||||
|---|---|---|
|
||||
| `src/app/login/LoginPage.tsx` | 需翻译 | 登录表单、提交、错误提示、SSO 入口 |
|
||||
| `src/app/onboarding/`(OnboardingForm/FormBody/Loading/Error 等) | 需翻译 | 引导步骤、表单、加载/错误视图 |
|
||||
| `src/app/connect/` | 需翻译 | Connect 页面 |
|
||||
| MCP OAuth 相关回跳/授权提示 UI | 需翻译 | 见 `LOCALE_NAVIGATION_BEHAVIOR.md`(注意整页跳转语言恢复) |
|
||||
|
||||
### 1.3 Models 与 API Keys
|
||||
|
||||
| 页面/组件 | 需翻译? | 说明 |
|
||||
|---|---|---|
|
||||
| Models and Endpoints(`/models`)列表、创建/编辑表单、过滤、空态、校验 | 需翻译 | 静态与列表类文案;`model-hub`/AI Hub 候选页属低优先级,v1 不强求 |
|
||||
| API Keys(`/api-keys`)列表、创建弹窗、删除确认、用量展示 | 需翻译 | 含确认框、空态、校验 |
|
||||
| 相关复用组件(`key_value_input`、`copy button` 等) | 需翻译 | 复用组件文案由调用方传入或按需本地化 |
|
||||
|
||||
### 1.4 Usage / Cost / Budget
|
||||
|
||||
| 页面/组件 | 需翻译? | 说明 |
|
||||
|---|---|---|
|
||||
| Usage(`/usage`)筛选、表格头、图表说明、空态 | 需翻译 | 数值/货币用 i18n 格式化 |
|
||||
| Cost Tracking(`/cost-tracking` / `cost-optimization`) | 需翻译 | 成本跟踪页面;数值格式化随 i18n |
|
||||
| Budgets(`/budgets`)创建/编辑、限额设置、列表 | 需翻译 | 含"Budget Limit"等术语(见术语表) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 明确不翻译范围(逐项 + 原因)
|
||||
|
||||
遵循 `LOCALIZATION_SPEC.md` §4 与方案 §1.2:
|
||||
|
||||
| 项 | 不翻译原因 |
|
||||
|---|---|
|
||||
| 模型名 / Model ID / Deployment / Provider 名 | 用户/后端数据,非 UI 文案 |
|
||||
| API 字段、请求/响应键、JSON 结构 | 协议内容 |
|
||||
| 日志原文、错误消息、错误堆栈(stack trace) | 需原样排障,保留原文 |
|
||||
| 代码示例、curl、终端命令、config.yaml、.env | 技术内容 |
|
||||
| URL、路径、文件名 | 技术标识 |
|
||||
| API / URL / SSO / MCP / OAuth / REST / ID / JSON 等缩写 | 通用技术缩写 |
|
||||
| 品牌名 LiteLLM / OpenAI / Anthropic | 品牌 |
|
||||
| 角色/权限代码值(admin、internal_user 等) | 代码值,仅显示名可翻译 |
|
||||
| Beta / New 徽标 | 视觉因子,保留英文 |
|
||||
| 构建期 `<title>` / metadata description | D8,v1 保持英文,不做多语言 SEO |
|
||||
| Guardrails / Policies / Teams / Users / Orgs / Projects / Logs / Playground / Prompts 等整模块文案(未列入 v1 功能批次) | 属 v2+ 候选(方案 §5.7),v1 范围外不被动外扩 |
|
||||
| Python SDK、API 文档、代码示例翻译 | 方案 §1.2 明确排除 |
|
||||
| 后端 FastAPI 错误响应全面国际化 | 方案 §1.2 排除 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 与 §6.2 状态清单对应
|
||||
|
||||
本清单即 `V1_SCOPE_MANIFEST.md`(Agent 0 维护)中 v1 行的**文案盘点输入**。Agent 5/6 完成任务时在清单相应行填写 EN/ZH-CN 盘点,Agent 7 填测试/UI 检查证据,Agent 2 在 Wave 4A 做术语与中文体验复核。
|
||||
|
||||
## 4. 边界红线
|
||||
|
||||
- 未列入第 1 节的功能页面文案,v1 一律**不翻译**(保持英文),不要顺手外扩造成半成品。
|
||||
- 第 2 节不翻译项,任何 Agent 不得翻译;发现误翻即退回原 Owner,并作为缺陷记录。
|
||||
388
docs/i18n/W2_SHELL_INVENTORY.md
Normal file
388
docs/i18n/W2_SHELL_INVENTORY.md
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
# W2 Shell & Auth 文案盘点清单(Wave 1 只读产出)
|
||||
|
||||
> 作者:Agent 5(`i18n-shell-auth-developer`)
|
||||
> 阶段:Wave 1 只读盘点(G1 前未创建/修改任何 `src/**`、`src/locales/**`、`package*.json`、`layout.tsx`)
|
||||
> 范围:全局壳层(Leftnav/Navbar/ThemeToggle/SidebarAccountMenu/SidebarUsageCard)+ 认证引导(Login/Onboarding/Connect/MCP OAuth)
|
||||
> 依据:`LOCALIZATION_SPEC.md`(§3.4 key 规范、§4 不可翻译、§7 a11y)、`GLOSSARY_EN_ZH.md`、`V1_TRANSLATION_SCOPE.md`、`I18N_MULTI_AGENT_PLAN.md` §3.4/§5.6/§6
|
||||
> 归属 namespace:`navigation`(导航/壳层)、`auth`(登录/引导/连接)、`common`(通用动作/复用 key)
|
||||
|
||||
## 0. 环境说明(重要接缝)
|
||||
|
||||
- 本盘点在**干净主工作区**(`ui/litellm-dashboard`)进行,且 **`src/locales/{en,zh-CN}` namespace 骨架在本次盘点工作区中并不存在**(属 Wave 1 Agent 4 交付物,尚未合入主工作区)。
|
||||
- 因此"是否已存在(common?)"一栏:`common:action.*` / `common:actions.*` 等 key 依据 `LOCALIZATION_SPEC.md` §2.2、`GLOSSARY_EN_ZH.md` §3 与方案 §6 所述骨架命名**推断存在**;**本次盘点未能对 `common.json` 物理校验**。Wave 2 动手前必须由 Agent 5 在拿到移交后的 `common.json` 时逐条核对真实 key(本清单提供备选,供核准)。
|
||||
- key 命名严格遵循 §3.4:`<namespace>:<domain>.<page>.<element>[.<state>]`;语义 key,不用整句当 key。
|
||||
- 大量 key 同时用于可见文本与 a11y(aria-label/title/placeholder),二者同 key(§7)。
|
||||
|
||||
## 1. 关键接缝风险(先读这节)
|
||||
|
||||
| # | 风险 | 位置 | 说明 / 处理 |
|
||||
|---|---|---|---|
|
||||
| R1 | **`menuGroups` 是导出共享配置** | `leftnav.tsx`(119-363 行)被 `page_utils.ts`(getAvailablePages) 与 `getBreadcrumb`(leftnav 内部) 消费 | `groupLabel`/`label` 中文化必须**保留导出结构与 key/page/route/roles 字段不变**,仅把 label 换成 i18n key 引用。`page_utils.ts` 用 `item.label`/`group.groupLabel` 做字符串拼接(`${group.groupLabel} > ${parentLabel}`),且 `page_metadata.ts` 存 `pageDescriptions` 英文。Wave 2 需为 `page_utils`/`page_metadata` 也 I18N 化(否则 UI Settings 可见性列表仍显英文)。**这是跨模块共享 key 的核心接缝。** |
|
||||
| R2 | **面包屑与导航共用同一套 key** | `getBreadcrumb`(leftnav 408-419 行)→ `DashboardHeader.tsx`(`title`) | 面包屑 title 必须与导航 label 同一 key,避免两处不一致(§3.1)。`SECTION_DISPLAY` 常量(391-397 行)已把大写分组映射为显示名,需一并 I18N 化。 |
|
||||
| R3 | **同一动作词在多个组件重复**(Logout/Connect/Disconnect/Beta 等) | 见 §3/§6 | 必须统一走 `common`/同 namespace 单 key,禁止各组件造同义 key。 |
|
||||
| R4 | **登录/SSO/OAuth 整页跳转语言恢复** | login、mcp/oauth/callback | 规范 §1.4:跳转返回需从同源 cookie/localStorage 恢复语言。属行为不改(仅文案),Wave 2 只换文案,不碰跳转/校验/路由逻辑。 |
|
||||
| R5 | **Beta/New/vX.Y.Z 徽标与品牌不译** | 各处 | `Beta`、`LiteLLM`、`v{version}`、`AUTO_REDIRECT_UI_LOGIN_TO_SSO`、`DISABLE_ADMIN_UI` 等属 §4 不可翻译,**不进 key**。 |
|
||||
| R6 | **Code/命令/环境变量/URL 不译** | login SSO 提示、default credentials | 代码片段、`MASTER_KEY`、`admin`、链接 URL 保持英文原文(§4 第 3/4/5 条)。 |
|
||||
|
||||
## 2. 盘点覆盖度
|
||||
|
||||
| 文件 | 状态 | 备注 |
|
||||
|---|---|---|
|
||||
| `src/components/leftnav.tsx` | 已全文盘点 | menuGroups + breadcrumb + a11y |
|
||||
| `src/components/navbar.tsx`(顶层 Navbar) | 已全文盘点 | 仅 logo/badge/aria-label |
|
||||
| `src/components/Navbar/BlogDropdown.tsx` | 已盘点 | 含错误/空态 |
|
||||
| `src/components/Navbar/DocsLink.tsx` | 已盘点 | `Docs` |
|
||||
| `src/components/Navbar/CommunityEngagementButtons.tsx` | 已盘点 | aria-label + tooltip |
|
||||
| `src/components/Navbar/NotificationsBell.tsx` | 已盘点 | 弹窗正文 + aria-label |
|
||||
| `src/components/Navbar/UserDropdown.tsx` | 已盘点 | 用户菜单 |
|
||||
| `src/components/Navbar/WorkerDropdown.tsx` | 已盘点 | aria-label + empty |
|
||||
| `src/components/Navbar/ViewSwitcher.tsx` | 已盘点 | AI Gateway/Chat 切换 |
|
||||
| `src/components/Navbar/navDisplayName.ts` | 已盘点 | `Account` 回退文案 |
|
||||
| `src/components/ThemeToggle/ThemeToggle.tsx` | 已盘点 | aria-label/title |
|
||||
| `src/components/SidebarAccountMenu/SidebarAccountMenu.tsx` | 已盘点 | 用户菜单(与 UserDropdown 高度重复) |
|
||||
| `src/components/SidebarUsageCard.tsx` | 已盘点 | Enterprise usage 卡 |
|
||||
| `src/components/page_utils.ts` / `page_metadata.ts` | 已盘点(消费方) | R1 接缝 |
|
||||
| `src/app/login/LoginPage.tsx` + `page.tsx` | 已全文盘点 | 登录表单/SSO/禁用态 |
|
||||
| `src/app/onboarding/*`(Form/FormBody/Loading/Error/page) | 已盘点 | 引导 |
|
||||
| `src/app/connect/*` + `src/components/chat/{MCPAppsPanel,ConnectFlowBanner,MCPConnectPicker,MCPCredentialsTab}` | 已盘点 | Connect 页 |
|
||||
| `src/app/mcp/oauth/callback/page.tsx` | 已盘点 | OAuth 回跳 UI |
|
||||
| **不在 v1 范围(只读参考,不拟 key)** | `Chat`(chat UI)、`MCP Servers` 配置页、`Tools/Vector Stores`、`Skills/Agents/Guardrails` 等 | 属 v2+ 候选(方案 §5.7)。但 **leftnav 中的 `menuGroups` label 属全局壳层,即使指向 v2 页面也仍由 leftnav 统一 I18N**(仅渲染入口,非页面功能翻译)。 |
|
||||
|
||||
## 3. 全局壳层文案盘点
|
||||
|
||||
### 3.1 leftnav.tsx — menuGroups(组/项,含面包屑,共享配置)
|
||||
|
||||
分组 groupLabel(`SECTION_DISPLAY` 显示名,key 与导航一致,供面包屑 section 复用):
|
||||
|
||||
| 文件 | 位置 | 原文 EN | 拟定 key | namespace | 是否已存在(common?) | 备注 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| leftnav.tsx | 119 | AI GATEWAY → "AI Gateway" | `navigation:group.aiGateway` | navigation | 否 | 分组标签;页面大写、SECTION_DISPLAY 用显示名,key 共用一套 |
|
||||
| leftnav.tsx | 197 | OBSERVABILITY → "Observability" | `navigation:group.observability` | navigation | 否 | §3.4 示例 key |
|
||||
| leftnav.tsx | 229 | ACCESS CONTROL → "Access Control" | `navigation:group.accessControl` | navigation | 否 | 术语表 §2 "Access Control"→访问控制 |
|
||||
| leftnav.tsx | 262 | DEVELOPER TOOLS → "Developer Tools" | `navigation:group.developerTools` | navigation | 否 | 术语表 §2 |
|
||||
| leftnav.tsx | 320 | SETTINGS → "Settings" | `navigation:group.settings` | navigation | 否 | 术语表 Settings→设置 |
|
||||
|
||||
AI GATEWAY 组 items:
|
||||
|
||||
| leftnav | 121 | Virtual Keys | `navigation:item.virtualKeys` | navigation | 否 | 术语表:Virtual Key→虚拟密钥 |
|
||||
| leftnav | 126 | Playground | `navigation:item.playground` | navigation | 否 | v1 低优先级,**建议保留英文**(术语表备注);key 仍拟好备用 |
|
||||
| leftnav | 133 | Models + Endpoints | `navigation:item.modelsAndEndpoints` | navigation | 否 | 术语表:模型与端点 |
|
||||
| leftnav | 142 | Agentic | `navigation:item.agentic` | navigation | 否 | Agent——v1 低优先级;见术语表 |
|
||||
| leftnav | 146 | Agents | `navigation:item.agents` | navigation | 否 | v2+ 页面,但入口由壳层统一渲染 |
|
||||
| leftnav | 154 | Workflow Runs | `navigation:item.workflowRuns` | navigation | 否 | 术语表 §2 |
|
||||
| leftnav | 161 | Memory | `navigation:item.memory` | navigation | 否 | 术语表 §2:记忆 |
|
||||
| leftnav | 167 | MCP Servers | `navigation:item.mcpServers` | navigation | 否 | "MCP"不译 |
|
||||
| leftnav | 168 | Skills | `navigation:item.skills` | navigation | 否 | v2+,保留英文(术语表) |
|
||||
| leftnav | 169 | Guardrails | `navigation:item.guardrails` | navigation | 否 | 产品专名不译(术语表),key 备用 |
|
||||
| leftnav | 174 | Policies | `navigation:item.policies` | navigation | 否 | v2+ |
|
||||
| leftnav | 179 | Tools | `navigation:item.tools` | navigation | 否 | v2+ |
|
||||
| leftnav | 183 | Search Tools | `navigation:item.searchTools` | navigation | 否 | 术语表 §2:搜索工具 |
|
||||
| leftnav | 184 | Vector Stores | `navigation:item.vectorStores` | navigation | 否 | v2+,向量存储 |
|
||||
| leftnav | 188 | Tool Policies | `navigation:item.toolPolicies` | navigation | 否 | 工具策略 |
|
||||
|
||||
OBSERVABILITY / ACCESS CONTROL / DEVELOPER TOOLS / SETTINGS 组 items:
|
||||
|
||||
| leftnav | 205 | Usage | `navigation:item.usage` | navigation | 否 | 术语表:用量 |
|
||||
| leftnav | 214 | Cost Optimization | `navigation:item.costOptimization` | navigation | 否 | 术语表:成本优化;含 `<BetaBadge/>`(不译,见 R5) |
|
||||
| leftnav | 218 | Logs | `navigation:item.logs` | navigation | 否 | 日志(v2+ 页面,入口照译) |
|
||||
| leftnav | 222 | Guardrails Monitor | `navigation:item.guardrailsMonitor` | navigation | 否 | Guardrails 专名不译 |
|
||||
| leftnav | 231 | Teams | `navigation:item.teams` | navigation | 否 | §3.4 示例 key;v2+ 页面入口 |
|
||||
| leftnav | 235 | Projects | `navigation:item.projects` | navigation | 否 | 含 BetaBadge(不译) |
|
||||
| leftnav | 243 | Internal Users | `navigation:item.internalUsers` | navigation | 否 | 术语表:内部用户 |
|
||||
| leftnav | 247 | Organizations | `navigation:item.organizations` | navigation | 否 | 组织 |
|
||||
| leftnav | 253 | Access Groups | `navigation:item.accessGroups` | navigation | 否 | 访问组 |
|
||||
| leftnav | 258 | Budgets | `navigation:item.budgets` | navigation | 否 | 预算 |
|
||||
| leftnav | 264 | API Reference | `navigation:item.apiReference` | navigation | 否 | 术语表 §2:API 参考 |
|
||||
| leftnav | 265 | AI Hub | `navigation:item.aiHub` | navigation | 否 | 品牌/产品名,可保留(建议按术语表复核) |
|
||||
| leftnav | 270 | Learning Resources | `navigation:item.learningResources` | navigation | 否 | 术语表 §2:学习资料 |
|
||||
| leftnav | 276 | Response Cache | `navigation:item.responseCache` | navigation | 否 | 术语表 §2:响应缓存 |
|
||||
| leftnav | 284 | Experimental | `navigation:item.experimental` | navigation | 否 | 术语表 §2:实验功能 |
|
||||
| leftnav | 289 | Prompts | `navigation:item.prompts` | navigation | 否 | 保留英文(术语表) |
|
||||
| leftnav | 297 | API Playground | `navigation:item.apiPlayground` | navigation | 否 | 建议保留"API Playground" |
|
||||
| leftnav | 303 | Tag Management | `navigation:item.tagManagement` | navigation | 否 | 术语表 §2:标签管理 |
|
||||
| leftnav | 311 | Old Usage | `navigation:item.oldUsage` | navigation | 否 | 术语表 §2:旧版用量 |
|
||||
| leftnav | 326 | Settings | `navigation:item.settings` | navigation | 否 | 设置 |
|
||||
| leftnav | 333 | Router Settings | `navigation:item.routerSettings` | navigation | 否 | 术语表:网关设置 |
|
||||
| leftnav | 340 | Logging & Alerts | `navigation:item.loggingAndAlerts` | navigation | 否 | 日志与告警 |
|
||||
| leftnav | 347 | Admin Settings | `navigation:item.adminSettings` | navigation | 否 | 管理设置 |
|
||||
| leftnav | 354 | Cost Tracking | `navigation:item.costTracking` | navigation | 否 | 术语表:成本跟踪 |
|
||||
| leftnav | 358 | UI Theme | `navigation:item.uiTheme` | navigation | 否 | 术语表:界面主题 |
|
||||
|
||||
leftnav a11y / 其余:
|
||||
|
||||
| leftnav | 606 | aria-label="LiteLLM home" | `navigation:brand.homeAriaLabel` | navigation | 否 | a11y;"LiteLLM"品牌不译,仅"home"语义 |
|
||||
| leftnav | 607 | alt="LiteLLM" | (品牌,不译) | — | — | §4 品牌不译 |
|
||||
| leftnav | 631 | aria-label "Expand sidebar"/"Collapse sidebar" | `navigation:sidebar.expand` / `navigation:sidebar.collapse` | navigation | 否 | §7 示例;navbar 同文案(R3) |
|
||||
| leftnav | 622 | v{version} | (不译) | — | — | §4 `v{major.minor}` |
|
||||
|
||||
### 3.2 navbar.tsx(顶层)
|
||||
|
||||
| navbar | 77 | title "Expand sidebar"/"Collapse sidebar" | `navigation:sidebar.expand` / `navigation:sidebar.collapse` | navigation | 否 | 与 leftnav 同 key(R3) |
|
||||
| navbar | 93 | alt="LiteLLM Brand" | (品牌,不译) | — | — | §4 |
|
||||
| navbar | 109 | title="Thanks for using LiteLLM!" | `navigation:navbar.thanksMessage` | navigation | 否 | 提示 title |
|
||||
| navbar | 142 | aria-label="Product documentation" | `navigation:navbar.productDocsAria` | navigation | 否 | a11y |
|
||||
|
||||
### 3.3 Navbar 子组件
|
||||
|
||||
**BlogDropdown.tsx**(Blog 菜单):
|
||||
|
||||
| BlogDropdown | 触发按钮 | Blog | `navigation:blog.title` | navigation | 否 | 下拉触发器可见文本 |
|
||||
| BlogDropdown | loading aria | aria-label="loading" | `common:aria.loading` | common | 否 | a11y;多组件复用(R3),可放 common |
|
||||
| BlogDropdown | 错误态 | Failed to load posts | `navigation:blog.loadError` | navigation | 否 | toast/错误 |
|
||||
| BlogDropdown | 错误态按钮 | Retry | `common:action.retry` | common | 拟共存(common:actions/action) | §2.2 通用动作;Wave 2 核对现有 key(retry 并入 common) |
|
||||
| BlogDropdown | 空态 | No posts available | `navigation:blog.empty` | navigation | 否 | 空态 |
|
||||
| BlogDropdown | 底部链接 | View all posts | `navigation:blog.viewAll` | navigation | 否 | |
|
||||
| @formatDate | — | (toLocaleDateString "en-US" 硬编码) | `navigation:blog.date` 区域化 | navigation | 否 | **接缝**:硬编码 `en-US` locale,Wave 2 需 `t()` 区域化(R6/i18n 格式化) |
|
||||
|
||||
**DocsLink.tsx**:
|
||||
|
||||
| DocsLink | 可见文本 | Docs | `navigation:docs.title` | navigation | 否 | 与 Blog 对应 |
|
||||
|
||||
**CommunityEngagementButtons.tsx**(aria-label + tooltip,§7 同翻):
|
||||
|
||||
| CommunityEngagementButtons | aria-label/tooltip | Join Slack | `navigation:community.joinSlack` | navigation | 否 | 品牌 Slack 不译主体,"加入 Slack" |
|
||||
| CommunityEngagementButtons | aria-label/tooltip | LiteLLM on GitHub | `navigation:community.github` | navigation | 否 | 品牌 LiteLLM 不译 |
|
||||
| CommunityEngagementButtons | ButtonGroup aria | Community links | `navigation:community.groupAria` | navigation | 否 | a11y |
|
||||
|
||||
**NotificationsBell.tsx**(弹窗正文 + a11y):
|
||||
|
||||
| NotificationsBell | popover aria | aria-label="Notifications" | `navigation:notifications.triggerAria` | navigation | 否 | a11y |
|
||||
| NotificationsBell | PopoverTitle | LiteLLM Auto Router | `navigation:notifications.autoRouterTitle` | navigation | 否 | 产品名 LiteLLM 不译;"Auto Router"可译"自动路由" |
|
||||
| NotificationsBell | PopoverDescription | Route every request to the cheapest model... | `navigation:notifications.autoRouterBody` | navigation | 否 | 说明文本 |
|
||||
| NotificationsBell | 按钮 | Read the docs | `navigation:notifications.readDocs` | navigation | 否 | |
|
||||
| NotificationsBell | 按钮 | Mark as read | `navigation:notifications.markAsRead` | navigation | 否 | |
|
||||
|
||||
**UserDropdown.tsx**(navbar 与 sidebar 变体共用):
|
||||
|
||||
| UserDropdown | 触发 aria | `Account menu — {role} — signed in as {email}` | `auth:account.triggerAria` | auth | 否 | 插值 `{{role}}`/`{{email}}`;模板字符串 → 语义 key+插值 |
|
||||
| UserDropdown | aria | (同上,SidebarAccountMenu 变体) | `auth:account.triggerAria` | auth | 否 | 两处共用(R3) |
|
||||
| UserDropdown | 徽标 | Premium / Standard | `auth:account.tier.premium` / `auth:account.tier.standard` | auth | 否 | 订阅等级 |
|
||||
| UserDropdown | tooltip | Upgrade to Premium for advanced features | `auth:account.tier.upgradeTooltip` | auth | 否 | |
|
||||
| UserDropdown | 行 | User ID | `auth:account.userId` | auth | 否 | |
|
||||
| UserDropdown | 行 | Role | `auth:account.role` | auth | 否 | |
|
||||
| UserDropdown | copy | Copy User ID | `common:action.copyUserId` | common | 拟存 | 复制类(R3);Wave 2 核对 |
|
||||
| UserDropdown | 开关 | Hide New Feature Indicators | `auth:account.toggle.hideNew` | auth | 否 | |
|
||||
| UserDropdown | aria | Toggle hide new feature indicators | `auth:account.toggle.hideNewAria` | auth | 否 | a11y |
|
||||
| UserDropdown | 开关 | Hide All Prompts | `auth:account.toggle.hidePrompts` | auth | 否 | |
|
||||
| UserDropdown | aria | Toggle hide all prompts | `auth:account.toggle.hidePromptsAria` | auth | 否 | |
|
||||
| UserDropdown | 开关 | Hide Blog Posts | `auth:account.toggle.hideBlog` | auth | 否 | |
|
||||
| UserDropdown | aria | Toggle hide blog posts | `auth:account.toggle.hideBlogAria` | auth | 否 | |
|
||||
| UserDropdown | 开关 | Hide Bouncing Icon | `auth:account.toggle.hideBouncing` | auth | 否 | |
|
||||
| UserDropdown | aria | Toggle hide bouncing icon | `auth:account.toggle.hideBouncingAria` | auth | 否 | |
|
||||
| UserDropdown | 按钮 | Logout | `auth:account.logout` | auth | 否 | 术语表 §3:登出 |
|
||||
|
||||
**WorkerDropdown.tsx**:
|
||||
|
||||
| WorkerDropdown | ComboboxInput aria | aria-label="Worker" | `navigation:worker.ariaLabel` | navigation | 否 | a11y |
|
||||
| WorkerDropdown | 空态 | No matching workers | `navigation:worker.empty` | navigation | 否 | |
|
||||
|
||||
**ViewSwitcher.tsx**(AI Gateway / Chat 切换):
|
||||
|
||||
| ViewSwitcher | 默认/项 | AI Gateway | `navigation:view.aiGateway` | navigation | 否 | 术语表 §2:AI 网关 |
|
||||
| ViewSwitcher | 项/active | Chat | `navigation:view.chat` | navigation | 否 | |
|
||||
| ViewSwitcher | disabled 说明 | Admins can enable in Settings | `navigation:view.chatDisabled` | navigation | 否 | |
|
||||
|
||||
**navDisplayName.ts**:
|
||||
|
||||
| navDisplayName | 回退值 | "Account" | `auth:account.fallbackName` | auth | 否 | 当无 email/id 时的显示名 |
|
||||
|
||||
### 3.4 ThemeToggle / SidebarAccountMenu / SidebarUsageCard
|
||||
|
||||
**ThemeToggle.tsx**:
|
||||
|
||||
| ThemeToggle | aria-label/title | Switch to dark mode (beta) | `navigation:theme.toDark` | navigation | 否 | §7;"(beta)"保留或并入 |
|
||||
| ThemeToggle | aria-label/title | Switch to light mode | `navigation:theme.toLight` | navigation | 否 | |
|
||||
|
||||
**SidebarAccountMenu.tsx**(用户菜单,与 UserDropdown 高度重复 → 共用 key,R3):
|
||||
|
||||
| SidebarAccountMenu | 头部 | LiteLLM | (品牌不译) | — | — | §4 |
|
||||
| SidebarAccountMenu | title | Thanks for using LiteLLM! | `navigation:navbar.thanksMessage` | navigation | 否 | 与 navbar 同 key(R3) |
|
||||
| SidebarAccountMenu | 触发 aria | Account menu — ... | `auth:account.triggerAria` | auth | 否 | 与 UserDropdown 同 key |
|
||||
| SidebarAccountMenu | 行 | Tier | `auth:account.tier.label` | auth | 否 | |
|
||||
| SidebarAccountMenu | 徽标 | Premium / Standard | `auth:account.tier.premium` / `auth:account.tier.standard` | auth | 否 | 同上 |
|
||||
| SidebarAccountMenu | title | Upgrade to Premium for advanced features | `auth:account.tier.upgradeTooltip` | auth | 否 | |
|
||||
| SidebarAccountMenu | 行 | Role | `auth:account.role` | auth | 否 | |
|
||||
| SidebarAccountMenu | 行 | Email | `auth:account.email` | auth | 否 | |
|
||||
| SidebarAccountMenu | 行 | User ID | `auth:account.userId` | auth | 否 | 与 UserDropdown 同 key |
|
||||
| SidebarAccountMenu | copy | Copy email / Copy user ID | `common:action.copyEmail` / `common:action.copyUserId` | common | 拟存 | |
|
||||
| SidebarAccountMenu | toggle×4 | Hide New Feature Indicators / Hide All Prompts / Hide Blog Posts / Hide Bouncing Icon | `auth:account.toggle.*` | auth | 否 | 与 UserDropdown 同 key |
|
||||
| SidebarAccountMenu | toggle aria×4 | Toggle hide ... | `auth:account.toggle.*Aria` | auth | 否 | 与 UserDropdown 同 key |
|
||||
| SidebarAccountMenu | 按钮 | Logout | `auth:account.logout` | auth | 否 | |
|
||||
|
||||
**SidebarUsageCard.tsx**(侧边栏用量卡):
|
||||
|
||||
| SidebarUsageCard | collapsed title | Enterprise usage | `navigation:usageCard.title` | navigation | 否 | |
|
||||
| SidebarUsageCard | 副标题 | Active plan | `navigation:usageCard.activePlan` | navigation | 否 | license 无过期日期时 |
|
||||
| SidebarUsageCard | 标签 | Seats / Teams | `navigation:usageCard.seats` / `navigation:usageCard.teams` | navigation | 否 | 数据标签 |
|
||||
| SidebarUsageCard | 加载 | Loading… | `common:loading.ellipsis` | common | 拟存 | §4 加载中 |
|
||||
| SidebarUsageCard | aria | (aria-valuetext `{used} of {total}`) | `navigation:usageCard.valuetext` | navigation | 否 | a11y 插值 |
|
||||
|
||||
**page_utils.ts / page_metadata.ts(消费方,R1)**:
|
||||
|
||||
| page_utils | 51 | "No description available" | `common:error.noDescription` | common | 否 | 兜底文本 |
|
||||
| page_metadata | — | `pageDescriptions` 英文说明 | (每页 description key) | navigation | 否 | **需 I18N 化**,否则 UI Settings 可见性列表显英文(R1);提供 `navigation:pageDesc.*` 系列 |
|
||||
|
||||
## 4. 认证/引导/连接文案盘点(auth / navigation / common)
|
||||
|
||||
### 4.1 LoginPage.tsx(含 SSO / 禁用 / 表单)
|
||||
|
||||
| LoginPage | 校验 | Please enter your username | `auth:login.usernameRequired` | auth | 否 | 校验提示(zod message) |
|
||||
| LoginPage | 校验 | Please enter your password | `auth:login.passwordRequired` | auth | 否 | |
|
||||
| LoginPage | 标题 | 🚅 LiteLLM | (品牌不译) | — | — | |
|
||||
| LoginPage | 副标题 | Login | `auth:login.title` | auth | 否 | |
|
||||
| LoginPage | 副标题 | Access your LiteLLM Admin UI. | `auth:login.subtitle` | auth | 否 | "LiteLLM Admin UI"品牌不译 |
|
||||
| LoginPage | alert | Default Credentials | `auth:login.defaultCredsTitle` | auth | 否 | |
|
||||
| LoginPage | alert 说明 | By default, Username is `admin` and Password is ... | `auth:login.defaultCredsBody` | auth | 否 | `admin`/`MASTER_KEY` 代码值不译(§4) |
|
||||
| LoginPage | 链接 | Check the documentation | `auth:login.checkDocs` | auth | 否 | |
|
||||
| LoginPage | alert | Admin UI Disabled | `auth:login.adminDisabledTitle` | auth | 否 | |
|
||||
| LoginPage | alert 说明 | The Admin UI has been disabled by the administrator... | `auth:login.adminDisabledBody` | auth | 否 | |
|
||||
| LoginPage | alert | SSO 提示标题 | Single Sign-On (SSO) is enabled... | `auth:login.ssoNoticeTitle` | auth | 否 | 含 `AUTO_REDIRECT_UI_LOGIN_TO_SSO` 代码不译 |
|
||||
| LoginPage | alert 关闭 aria | aria-label="Close" | `common:action.close` | common | 拟存 | §2.2 |
|
||||
| LoginPage | 字段 | Worker | `auth:login.workerLabel` | auth | 否 | |
|
||||
| LoginPage | placeholder | Choose a worker to connect to | `auth:login.workerPlaceholder` | auth | 否 | |
|
||||
| LoginPage | 字段 | Username | `auth:login.usernameLabel` | auth | 否 | |
|
||||
| LoginPage | placeholder | Enter your username | `auth:login.usernamePlaceholder` | auth | 否 | |
|
||||
| LoginPage | 字段 | Password | `auth:login.passwordLabel` | auth | 否 | |
|
||||
| LoginPage | placeholder | Enter your password | `auth:login.passwordPlaceholder` | auth | 否 | |
|
||||
| LoginPage | 提交中 | Logging in... | `auth:login.submitting` | auth | 否 | |
|
||||
| LoginPage | 按钮 | Login | `auth:login.submit` | auth | 否 | §3.4 示例 key |
|
||||
| LoginPage | 按钮 | Login with SSO | `auth:login.ssoSubmit` | auth | 否 | |
|
||||
| LoginPage | tooltip | Please configure SSO to log in with SSO. | `auth:login.ssoNotConfigured` | auth | 否 | |
|
||||
| LoginPage | loading aria | aria-label="loading" | `common:aria.loading` | common | 否 | 与 BlogLoading 同 key(R3) |
|
||||
| — | 后端 error | `loginMutation.error.message` | (后端消息不翻,§4) | — | — | 保留原文展示,不拟 key |
|
||||
|
||||
### 4.2 Onboarding(引导)
|
||||
|
||||
**OnboardingForm.tsx**:
|
||||
|
||||
| OnboardingForm | claim 失败 | Failed to start session. Please try again. | `auth:onboarding.startSessionError` | auth | 否 | |
|
||||
| OnboardingForm | claim 失败 | Failed to submit. Please try again. | `auth:onboarding.submitError` | auth | 否 | |
|
||||
|
||||
**OnboardingFormBody.tsx**:
|
||||
|
||||
| OnboardingFormBody | 标题 | Reset Password / Sign Up | `auth:onboarding.action.resetPassword` / `auth:onboarding.action.signUp` | auth | 否 | 动作标签,两处按钮共用 |
|
||||
| OnboardingFormBody | 标题 | 🚅 LiteLLM | (品牌不译) | — | — | |
|
||||
| OnboardingFormBody | 说明(reset) | Reset your password to access Admin UI. | `auth:onboarding.resetDesc` | auth | 否 | |
|
||||
| OnboardingFormBody | 说明(signup) | Claim your user account to login to Admin UI. | `auth:onboarding.signupDesc` | auth | 否 | |
|
||||
| OnboardingFormBody | alert | SSO | `auth:onboarding.ssoTitle` | auth | 否 | |
|
||||
| OnboardingFormBody | alert | SSO is under the Enterprise Tier. | `auth:onboarding.ssoBody` | auth | 否 | |
|
||||
| OnboardingFormBody | 按钮 | Get Free Trial | `auth:onboarding.freeTrial` | auth | 否 | |
|
||||
| OnboardingFormBody | 字段 | Email Address | `auth:onboarding.emailLabel` | auth | 否 | |
|
||||
| OnboardingFormBody | 字段 | Password | `auth:onboarding.passwordLabel` | auth | 否 | |
|
||||
| OnboardingFormBody | 说明(reset) | Enter your new password | `auth:onboarding.passwordResetDesc` | auth | 否 | |
|
||||
| OnboardingFormBody | 说明(signup) | Create a password for your account | `auth:onboarding.passwordSignupDesc` | auth | 否 | |
|
||||
| OnboardingFormBody | 校验 | password required to sign up | `auth:onboarding.passwordRequired` | auth | 否 | |
|
||||
| OnboardingFormBody | loading aria | aria-label="loading" | `common:aria.loading` | common | 否 | |
|
||||
|
||||
**OnboardingLoadingView / OnboardingErrorView / page**:
|
||||
|
||||
| OnboardingLoadingView | aria | Loading invitation | `auth:onboarding.loadingAria` | auth | 否 | a11y |
|
||||
| OnboardingErrorView | 标题 | Failed to load invitation | `auth:onboarding.loadError` | auth | 否 | |
|
||||
| OnboardingErrorView | 说明 | The invitation link may be invalid or expired. | `auth:onboarding.loadErrorDesc` | auth | 否 | |
|
||||
| OnboardingErrorView | 链接 | Back to Login | `auth:onboarding.backToLogin` | auth | 否 | 术语表 Back→返回 |
|
||||
| page.tsx (onboarding) | Suspense | Loading... | `common:loading.ellipsis` | common | 拟存 | 与 SidebarUsageCard 同 key |
|
||||
|
||||
### 4.3 Connect 页与 MCP OAuth
|
||||
|
||||
**connect/page.tsx**:无硬编码文案(业务状态由子组件承载),不拟 key。
|
||||
|
||||
**ConnectFlowBanner.tsx**:
|
||||
|
||||
| ConnectFlowBanner | 标题 | Connect your MCP servers to {{client}} | `auth:connectFlow.title` | auth | 否 | 插值 `{{client}}`(clientLabel) |
|
||||
| ConnectFlowBanner | 说明 | Authorize the servers you want to use below, then click Finish connecting... | `auth:connectFlow.subtitle` | auth | 否 | |
|
||||
| ConnectFlowBanner | 按钮 | Finish connecting | `auth:connectFlow.finish` | auth | 否 | 关键 consent 动作文案 |
|
||||
| ConnectFlowBanner | 标签 | My client is on a remote or SSH machine | `auth:connectFlow.remoteClient` | auth | 否 | |
|
||||
|
||||
**MCPAppsPanel.tsx**(Connect 应用网格):
|
||||
|
||||
| MCPAppsPanel | 按钮 | Connect / Connecting… / Disconnect | `auth:connectFlow.connect` / `auth:connectFlow.connecting` / `auth:connectFlow.disconnect` | auth | 否 | R3:与 MCPCredentialsTab 共用 |
|
||||
| MCPAppsPanel | 徽标 | Not supported on this connection | `auth:connectFlow.unsupported` | auth | 否 | |
|
||||
| MCPAppsPanel | 标题 | MCP Servers | `navigation:item.mcpServers` | navigation | 否 | 与 leftnav 同 key(R3/R1) |
|
||||
| MCPAppsPanel | 说明 | Click a server to see its tools and connect | `auth:connectFlow.listHint` | auth | 否 | |
|
||||
| MCPAppsPanel | 说明 | Browse tools, authenticate once, use in chat | `auth:connectFlow.browseHint` | auth | 否 | |
|
||||
| MCPAppsPanel | 加载 | Loading tools... | `auth:connectFlow.loadingTools` | auth | 否 | |
|
||||
| MCPAppsPanel | 计数 | {{count}} tool(s) available | `auth:connectFlow.toolsCount` | auth | 否 | 复数插值(zh 用 `{{count}} 个工具`) |
|
||||
| MCPAppsPanel | placeholder | Search servers... | `auth:connectFlow.searchPlaceholder` | auth | 否 | |
|
||||
| MCPAppsPanel | tab | All | `common:filter.all` | common | 拟存 | §4 表单词 All→全部;核对 common |
|
||||
| MCPAppsPanel | tab | Connected (n) | `auth:connectFlow.tab.connected` | auth | 否 | 插值计数 |
|
||||
| MCPAppsPanel | 空态 | No MCP servers are available to this connection yet... | `auth:connectFlow.emptyConnectMode` | auth | 否 | |
|
||||
| MCPAppsPanel | 空态 | No MCP servers configured. Add servers in Tools -> MCP Servers. | `auth:connectFlow.emptyNoServer` | auth | 否 | |
|
||||
| MCPAppsPanel | 空态 | No servers connected yet. | `auth:connectFlow.emptyConnected` | auth | 否 | |
|
||||
| MCPAppsPanel | 空态 | No servers match your search. | `auth:connectFlow.emptySearch` | auth | 否 | |
|
||||
| MCPAppsPanel | 返回 | Back | `common:action.back` | common | 拟存 | 术语表 Back→返回 |
|
||||
| MCPAppsPanel | 兜底 | MCP server | `auth:connectFlow.serverFallback` | auth | 否 | description 缺省 |
|
||||
| MCPAppsPanel | 详情标题 | Information | `auth:connectFlow.infoTitle` | auth | 否 | |
|
||||
| MCPAppsPanel | 详情行 | Server ID / Transport / Status | `auth:connectFlow.detail.serverId` / `transport` / `status` | auth | 否 | |
|
||||
| MCPAppsPanel | 状态值 | Connected / Not connected | `auth:connectFlow.status.connected` / `status.notConnected` | auth | 否 | |
|
||||
| MCPAppsPanel | 标题 | Available Tools | `auth:connectFlow.toolsTitle` | auth | 否 | |
|
||||
| MCPAppsPanel | 空态 | No tools available | `auth:connectFlow.noTools` | auth | 否 | |
|
||||
| MCPAppsPanel | Beta | Beta | (不译,§4) | — | — | |
|
||||
| MCPAppsPanel | toast | Could not load tools for {{server}} | `auth:connectFlow.toolsLoadError` | auth | 否 | 插值 |
|
||||
|
||||
**MCPConnectPicker.tsx**:
|
||||
|
||||
| MCPConnectPicker | 空态 | No MCP servers configured | `auth:connectFlow.emptyNoServer` | auth | 否 | 与 MCPAppsPanel 同 key(R3) |
|
||||
| MCPConnectPicker | toast | Could not load tools for {{server}} — it will be excluded... | `auth:connectFlow.toolsLoadErrorExcluded` | auth | 否 | 插值,含 `—` 说明 |
|
||||
|
||||
**MCPCredentialsTab.tsx**(App 凭据表):
|
||||
|
||||
| MCPCredentialsTab | 标题 | App Credentials | `auth:credentials.title` | auth | 否 | |
|
||||
| MCPCredentialsTab | 说明 | Your stored OAuth connections; used automatically in chat | `auth:credentials.subtitle` | auth | 否 | |
|
||||
| MCPCredentialsTab | 列头 | App / Connected / Status / Actions | `auth:credentials.col.app` / `col.connected` / `col.status` / `col.actions` | auth | 否 | |
|
||||
| MCPCredentialsTab | 空态 | No connections yet | `auth:credentials.empty` | auth | 否 | |
|
||||
| MCPCredentialsTab | 空态说明 | Go to Integrations and click Connect to authorize an MCP server | `auth:credentials.emptyHint` | auth | 否 | 含"Integrations"/"Connect"强调 |
|
||||
| MCPCredentialsTab | 时间 | just now | `auth:credentials.justNow` | auth | 否 | |
|
||||
| MCPCredentialsTab | 状态 | Does not expire / Expired / Expires in {{n}}d/h/m | `auth:credentials.expiry.*` | auth | 否 | 插值 |
|
||||
| MCPCredentialsTab | title | Revoke connection | `auth:credentials.revokeTitle` | auth | 否 | a11y title |
|
||||
| MCPCredentialsTab | 弹窗标题 | Revoke connection? | `auth:credentials.revokeDialogTitle` | auth | 否 | |
|
||||
| MCPCredentialsTab | 弹窗说明 | This removes the stored OAuth credential for {{name}}... | `auth:credentials.revokeDialogBody` | auth | 否 | 插值 |
|
||||
| MCPCredentialsTab | 按钮 | Cancel / Revoke | `common:action.cancel` / `auth:credentials.revokeConfirm` | common/auth | 拟存/新增 | Cancel 用 common(§2.2) |
|
||||
| MCPCredentialsTab | toast | Failed to revoke connection. Please try again. | `auth:credentials.revokeError` | auth | 否 | |
|
||||
|
||||
**mcp/oauth/callback/page.tsx**:
|
||||
|
||||
| oauth callback | 标题 | LiteLLM MCP OAuth | `navigation:mcpOAuth.title` | navigation | 否 | 回调中间页(§1.4 整页跳转语言恢复关切) |
|
||||
| oauth callback | 说明 | Authorization complete. You may close this window and return to the LiteLLM dashboard. | `navigation:mcpOAuth.complete` | navigation | 否 | |
|
||||
| oauth callback | 说明 | If the window does not close automatically, everything is still saved—you can close it manually. | `navigation:mcpOAuth.manualClose` | navigation | 否 | |
|
||||
| oauth callback | Suspense | Loading... | `common:loading.ellipsis` | common | 拟存 | |
|
||||
|
||||
## 5. 复用既有 common key 清单(Wave 2 需逐条核对 `common.json` 真实 key)
|
||||
|
||||
> 下列 key 依据规范 §2.2 / 术语表 §3 推断骨架已提供(`common:action.*`、`common:actions.*` 命名差异需以实际骨架为准)。**复用,不重复造;若骨架 key 命名不同(e.g. `actions.save` vs `action.save`),以骨架为准并同步本表。** 新增通用 key 须 Agent 0 Review(方案 §6)。
|
||||
|
||||
| 复用 key(拟定) | UI 位 | 说明 |
|
||||
|---|---|---|
|
||||
| `common:action.close` | LoginPage alert 关闭 aria | Close |
|
||||
| `common:action.cancel` | MCPCredentialsTab Cancel | Cancel |
|
||||
| `common:action.back` | MCPAppsPanel Back | Back(可能新增,视 common 是否含 back) |
|
||||
| `common:action.retry` | BlogDropdown Retry | Retry |
|
||||
| `common:action.copy` / `copyEmail` / `copyUserId` | UserDropdown / SidebarAccountMenu | Copy 类(核对 common 是否已含 copy) |
|
||||
| `common:aria.loading` | Login / Onboarding / Blog loading aria | 多组件复用 |
|
||||
| `common:loading.ellipsis` | SidebarUsageCard / onboarding / mcp callback | "Loading…" |
|
||||
| `common:filter.all` | MCPAppsPanel tab "All" | All→全部 |
|
||||
| `common:error.noDescription` | page_utils 兜底 | 可新增 |
|
||||
|
||||
## 6. 需新增到 `navigation` / `auth` 的 key 汇总(供 Wave 2 使用)
|
||||
|
||||
### navigation namespace(导航/壳层)
|
||||
|
||||
- 分组 key(5):`navigation:group.{aiGateway,observability,accessControl,developerTools,settings}`
|
||||
- 菜单项 label key(~40):`navigation:item.{virtualKeys,playground,modelsAndEndpoints,agentic,agents,workflowRuns,memory,mcpServers,skills,guardrails,policies,tools,searchTools,vectorStores,toolPolicies,usage,costOptimization,logs,guardrailsMonitor,teams,projects,internalUsers,organizations,accessGroups,budgets,apiReference,aiHub,learningResources,responseCache,experimental,prompts,apiPlayground,tagManagement,oldUsage,settings,routerSettings,loggingAndAlerts,adminSettings,costTracking,uiTheme}`
|
||||
- 面包屑/共享:`navigation:group.*` 复用;`navigation:pageDesc.<page>` 系列(page_metadata 说明)
|
||||
- 侧栏/导航 a11y:`navigation:sidebar.expand/collapse`、`navigation:brand.homeAriaLabel`、`navigation:navbar.thanksMessage`、`navigation:navbar.productDocsAria`
|
||||
- Navbar 子组件:`navigation:blog.{title,loadError,empty,viewAll,date}`、`navigation:docs.title`、`navigation:community.{joinSlack,github,groupAria}`、`navigation:notifications.{triggerAria,autoRouterTitle,autoRouterBody,readDocs,markAsRead}`、`navigation:worker.{ariaLabel,empty}`、`navigation:view.{aiGateway,chat,chatDisabled}`、`navigation:theme.{toDark,toLight}`、`navigation:usageCard.{title,activePlan,seats,teams,valuetext}`
|
||||
- MCP OAuth 中间页:`navigation:mcpOAuth.{title,complete,manualClose}`
|
||||
|
||||
### auth namespace(登录/引导/连接)
|
||||
|
||||
- 登录:`auth:login.{usernameRequired,passwordRequired,title,subtitle,defaultCredsTitle,defaultCredsBody,checkDocs,adminDisabledTitle,adminDisabledBody,ssoNoticeTitle,workerLabel,workerPlaceholder,usernameLabel,usernamePlaceholder,passwordLabel,passwordPlaceholder,submitting,submit,ssoSubmit,ssoNotConfigured}`
|
||||
- 账号菜单:`auth:account.{triggerAria,fallbackName,tier.label,tier.premium,tier.standard,tier.upgradeTooltip,userId,role,email,toggle.hideNew/hidePrompts/hideBlog/hideBouncing 及 *Aria,logout}`
|
||||
- 引导:`auth:onboarding.{startSessionError,submitError,action.resetPassword,action.signUp,resetDesc,signupDesc,ssoTitle,ssoBody,freeTrial,emailLabel,passwordLabel,passwordResetDesc,passwordSignupDesc,passwordRequired,loadingAria,loadError,loadErrorDesc,backToLogin}`
|
||||
- Connect:`auth:connectFlow.{title,subtitle,finish,remoteClient,connect,connecting,disconnect,unsupported,listHint,browseHint,loadingTools,toolsCount,searchPlaceholder,tab.connected,emptyConnectMode,emptyNoServer,emptyConnected,emptySearch,serverFallback,infoTitle,detail.serverId/transport/status,status.connected/notConnected,toolsTitle,noTools,toolsLoadError,toolsLoadErrorExcluded}`
|
||||
- 凭据:`auth:credentials.{title,subtitle,col.app/connected/status/actions,empty,emptyHint,justNow,expiry.*,revokeTitle,revokeDialogTitle,revokeDialogBody,revokeConfirm,revokeError}`
|
||||
|
||||
## 7. 覆盖度结论
|
||||
|
||||
- **已完整盘点并拟 key**:leftnav、navbar、Navbar/**(Blog,Docs,Community,Notifications,UserDropdown,WorkerDropdown,ViewSwitcher,navDisplayName)、ThemeToggle、SidebarAccountMenu、SidebarUsageCard、page_utils/page_metadata(消费方)、login、onboarding 全部、connect(含 chat/MCPAppsPanel、ConnectFlowBanner、MCPConnectPicker、MCPCredentialsTab)、mcp/oauth/callback。
|
||||
- **未盘点(v2+ 不在 v1 范围,仅入口经 leftnav 拟 key)**:MCP Servers 配置页、Tools/Vector Stores、Agents/Skills、Guardrails、Chat UI 等整模块(方案 §5.7)。其 leftnav 入口 label 已由壳层统一覆盖。
|
||||
- **命名捷径**:`auth:connectFlow.*` / `auth:credentials.*` / `navigation:pageDesc.*` 均为新增,未与既有工程冲突(本次工作区无 `src/locales`,无法做字符串比对)。
|
||||
120
ui/litellm-dashboard/package-lock.json
generated
120
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -22,6 +22,7 @@
|
|||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"dayjs": "1.11.19",
|
||||
"i18next": "^26.4.2",
|
||||
"jwt-decode": "4.0.0",
|
||||
"lucide-react": "0.513.0",
|
||||
"moment": "2.30.1",
|
||||
|
|
@ -36,6 +37,7 @@
|
|||
"react-copy-to-clipboard": "5.1.1",
|
||||
"react-dom": "19.2.8",
|
||||
"react-hook-form": "7.82.0",
|
||||
"react-i18next": "^17.0.13",
|
||||
"react-json-view-lite": "2.5.0",
|
||||
"react-markdown": "9.1.0",
|
||||
"react-syntax-highlighter": "15.6.6",
|
||||
|
|
@ -48,6 +50,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.39.2",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tailwindcss/postcss": "4.3.2",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
|
|
@ -402,9 +405,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
|
|
@ -2548,6 +2551,22 @@
|
|||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
|
|
@ -7287,6 +7306,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
|
||||
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://locize.com"
|
||||
}
|
||||
},
|
||||
"node_modules/html-url-attributes": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||
|
|
@ -7334,6 +7362,34 @@
|
|||
"ms": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.4.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
|
||||
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
|
|
@ -10383,6 +10439,35 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/pluralize": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||
|
|
@ -10602,6 +10687,33 @@
|
|||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.13",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.13.tgz",
|
||||
"integrity": "sha512-Cc1PscmblIHA1kljTqDwrcVMI21ydgmUzw0UAeQBe7pAOgfuRLfzXze4EUBQoeDiICzFIXXhHFoZxuetNg5D0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"html-parse-stringify": "^4.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
|
|
@ -12071,7 +12183,7 @@
|
|||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"dayjs": "1.11.19",
|
||||
"i18next": "^26.4.2",
|
||||
"jwt-decode": "4.0.0",
|
||||
"lucide-react": "0.513.0",
|
||||
"moment": "2.30.1",
|
||||
|
|
@ -52,6 +53,7 @@
|
|||
"react-copy-to-clipboard": "5.1.1",
|
||||
"react-dom": "19.2.8",
|
||||
"react-hook-form": "7.82.0",
|
||||
"react-i18next": "^17.0.13",
|
||||
"react-json-view-lite": "2.5.0",
|
||||
"react-markdown": "9.1.0",
|
||||
"react-syntax-highlighter": "15.6.6",
|
||||
|
|
@ -64,6 +66,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.39.2",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tailwindcss/postcss": "4.3.2",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
|
|
|
|||
200
ui/litellm-dashboard/scripts/i18n/check-keys-dangling-lib.mjs
Normal file
200
ui/litellm-dashboard/scripts/i18n/check-keys-dangling-lib.mjs
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
// T-02b 悬空 key 校验库 —— 组件引用的 key ⊆ 已声明 key(TEST_TOOLS §2 组件引用校验)。
|
||||
// 使用 TypeScript 编译器 AST(typescript 属既有依赖,不新增 package.json 项),
|
||||
// 只读扫描 `.ts/.tsx` 中的 `t("ns:key")` 引用,对照 en locale 资源检查是否存在。
|
||||
// 只报告,不改写,不自动生成 key。
|
||||
//
|
||||
// 保守策略(降低误报):
|
||||
// - 仅当文件“i18n-active”(import 了 react-i18next 的 useTranslation,或 @/i18n 的 getI18n)
|
||||
// 才执行引用提取,避免把无关的本地 `t()` 函数当成翻译调用。
|
||||
// - 仅匹配首参为字符串字面量的 `t(...)` 与 `xxx.t(...)` 调用;非字面量(模板/变量)跳过并
|
||||
// 标注为“动态 key”,供人工复核。
|
||||
// - 无命名空间前缀的 key 视为 default namespace(defaultNS,来自 registry)。
|
||||
// - 复数分支:引用 `key` 时,声明侧可能出现 `key_one`/`key_other`/`key_0`,视为合法。
|
||||
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { extname, join } from "node:path";
|
||||
import ts from "typescript";
|
||||
import { collectNamespaces } from "./check-keys-lib.mjs";
|
||||
|
||||
const defaultNS = "common";
|
||||
const PLURAL_SUFFIXES = ["one", "other", "zero", "few", "many", "two"];
|
||||
const IGNORED_DIRS = new Set(["node_modules", ".next", "out", "dist", ".git", "coverage"]);
|
||||
|
||||
// ---------- 文件收集 ----------
|
||||
export function collectTsFiles(root) {
|
||||
const out = [];
|
||||
const walk = (dir) => {
|
||||
if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory()) return;
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) {
|
||||
if (!IGNORED_DIRS.has(entry)) walk(full);
|
||||
} else if (st.isFile() && (extname(full) === ".ts" || extname(full) === ".tsx")) {
|
||||
// 排除类型声明文件 *.d.ts(extname 也是 .ts)
|
||||
if (!full.endsWith(".d.ts")) out.push(full);
|
||||
}
|
||||
}
|
||||
};
|
||||
const st = statSync(root, { throwIfNoEntry: false });
|
||||
if (!st) return out;
|
||||
if (st.isFile()) {
|
||||
if (extname(root) === ".ts" || extname(root) === ".tsx") out.push(root);
|
||||
} else {
|
||||
walk(root);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeImportSpecifier(spec) {
|
||||
return spec.replace(/^["']|["']$/g, "");
|
||||
}
|
||||
|
||||
// 判断文件是否“i18n-active”:import useTranslation(@react-i18next) 或 import { getI18n } @/i18n。
|
||||
export function isI18nActive(source) {
|
||||
const s = ts.createSourceFile("_", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
let active = false;
|
||||
ts.forEachChild(s, (node) => {
|
||||
if (active) return;
|
||||
if (ts.isImportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
|
||||
const mod = node.moduleSpecifier.text;
|
||||
const isReactI18next = mod === "react-i18next";
|
||||
const isLocalI18n = mod === "@/i18n" || mod.endsWith("/i18n") || mod.endsWith("/i18n/index");
|
||||
if (!isReactI18next && !isLocalI18n) return;
|
||||
const named = node.importClause?.namedBindings;
|
||||
if (named && ts.isNamedImports(named)) {
|
||||
for (const el of named.elements) {
|
||||
const name = el.name.text;
|
||||
if (name === "useTranslation" || name === "getI18n" || name === "I18nProvider") {
|
||||
active = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return active;
|
||||
}
|
||||
|
||||
// 提取一个字符串字面量 k 的路径(支持点分隔),同时从声明字典查是否有 key 或复数后缀。
|
||||
function keyExists(declFlat, path) {
|
||||
if (declFlat.has(path)) return true;
|
||||
// 复数/序数后缀:key_one / key_other / key_0 …(key 可能带子路径,仅最后一个段加后缀)
|
||||
for (const p of declFlat.keys()) {
|
||||
if (p.startsWith(path + "_")) {
|
||||
const suf = p.slice(path.length + 1);
|
||||
if (PLURAL_SUFFIXES.includes(suf) || /^\d+$/.test(suf)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 提取文件中所有翻译 key 引用,返回 { qualified, unqualified, dynamic } 的数组。
|
||||
export function extractTranslationKeys(source, filePath) {
|
||||
const s = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const refs = [];
|
||||
let seenTIdentifier = false;
|
||||
const inTrans = new Set();
|
||||
|
||||
// 记录 <Trans> 组件的 i18nKey 属性与 children 静态文本(策略性最小支持:仅 i18nKey)。
|
||||
function visit(node) {
|
||||
// Trans i18nKey="ns:key"
|
||||
if (ts.isJsxAttribute(node) && node.name.getText(s) === "i18nKey") {
|
||||
const init = node.initializer;
|
||||
if (init && ts.isStringLiteral(init)) {
|
||||
refs.push({ kind: "trans", key: init.text, line: s.getLineAndCharacterOfPosition(init.getStart(s)).line + 1 });
|
||||
}
|
||||
}
|
||||
// t("...") 或 obj.t("...")
|
||||
if (ts.isCallExpression(node)) {
|
||||
const expr = node.expression;
|
||||
let callee = null;
|
||||
if (ts.isPropertyAccessExpression(expr)) callee = expr.name.text;
|
||||
else if (ts.isIdentifier(expr)) callee = expr.text;
|
||||
if (callee === "t" && node.arguments.length > 0 && ts.isStringLiteral(node.arguments[0])) {
|
||||
seenTIdentifier = true;
|
||||
const key = node.arguments[0].text;
|
||||
const line = s.getLineAndCharacterOfPosition(node.getStart(s)).line + 1;
|
||||
if (key.includes(":")) refs.push({ kind: "qualified", key, line });
|
||||
else refs.push({ kind: "unqualified", key, line });
|
||||
} else if (
|
||||
callee === "t" &&
|
||||
node.arguments.length > 0 &&
|
||||
!ts.isStringLiteral(node.arguments[0]) &&
|
||||
!ts.isTemplateLiteral(node.arguments[0])
|
||||
) {
|
||||
const line = s.getLineAndCharacterOfPosition(node.getStart(s)).line + 1;
|
||||
refs.push({ kind: "dynamic", key: node.arguments[0].getText(s), line });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function walkNode(node) {
|
||||
if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) {
|
||||
const tag = node.tagName.getText(s);
|
||||
if (tag === "Trans") {
|
||||
inTrans.add(node);
|
||||
}
|
||||
}
|
||||
visit(node);
|
||||
ts.forEachChild(node, walkNode);
|
||||
}
|
||||
walkNode(s);
|
||||
|
||||
// 过滤:callee 为 t 的调用,需文件 i18n-active 且(存在 useTranslation t 或 getI18n/Trans)。
|
||||
// seenTIdentifier 标记存在 t(...) 调用;i18n-active 在外层已判断。
|
||||
return { refs, active: seenTIdentifier };
|
||||
}
|
||||
|
||||
/**
|
||||
* 主入口:扫描 root(目录/文件),对比 en locale 资源,返回悬空引用列表。
|
||||
* @param {string} root 目标目录或文件
|
||||
* @param {object} opts { enDir, localesDir }
|
||||
*/
|
||||
export function findDanglingReferences(root, { enDir, locale = "en" } = {}) {
|
||||
const enNamespaceFiles = collectNamespaces(enDir);
|
||||
const declFlatByNs = new Map();
|
||||
for (const [ns, obj] of enNamespaceFiles) {
|
||||
const flat = new Set();
|
||||
// 复用 flattenDict,取所有叶子路径
|
||||
// (手动扁平化,保留分支节点,但悬空校验只看叶子是否可达)
|
||||
(function walk(o, p) {
|
||||
for (const [k, v] of Object.entries(o)) {
|
||||
const path = p ? `${p}.${k}` : k;
|
||||
if (v !== null && typeof v === "object" && !Array.isArray(v)) walk(v, path);
|
||||
else flat.add(path);
|
||||
}
|
||||
})(obj, "");
|
||||
declFlatByNs.set(ns, flat);
|
||||
}
|
||||
const enNames = new Set(enNamespaceFiles.keys());
|
||||
|
||||
const files = collectTsFiles(root);
|
||||
const dangling = [];
|
||||
|
||||
for (const file of files) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
if (!isI18nActive(source)) continue;
|
||||
const { refs } = extractTranslationKeys(source, file);
|
||||
const nsOf = (key) => {
|
||||
const idx = key.indexOf(":");
|
||||
if (idx === -1) return { ns: defaultNS, path: key };
|
||||
return { ns: key.slice(0, idx), path: key.slice(idx + 1) };
|
||||
};
|
||||
for (const ref of refs) {
|
||||
if (ref.kind === "dynamic") {
|
||||
dangling.push({ file, line: ref.line, kind: "dynamic", key: ref.key, ns: undefined });
|
||||
continue;
|
||||
}
|
||||
const { ns, path } = nsOf(ref.key);
|
||||
if (!enNames.has(ns)) {
|
||||
dangling.push({ file, line: ref.line, kind: "unknown-namespace", key: ref.key, ns });
|
||||
continue;
|
||||
}
|
||||
if (!keyExists(declFlatByNs.get(ns), path)) {
|
||||
dangling.push({ file, line: ref.line, kind: "missing-key", key: ref.key, ns });
|
||||
}
|
||||
}
|
||||
}
|
||||
return dangling;
|
||||
}
|
||||
91
ui/litellm-dashboard/scripts/i18n/check-keys-dangling.mjs
Normal file
91
ui/litellm-dashboard/scripts/i18n/check-keys-dangling.mjs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
#!/usr/bin/env node
|
||||
// T-02b 悬空 key 校验 CLI —— 组件引用的 key ⊆ 已声明 key(只报告 + 可作为门禁退出码)。
|
||||
// 用量:node scripts/i18n/check-keys-dangling.mjs [dir|file ...]
|
||||
// --en <dir> 指定 en locale 资源目录(默认 src/locales/en)
|
||||
// 退出码:0 = 无缺 key;1 = 存在缺 key/未知 namespace/动态 key;2 = 用法/IO 错误。
|
||||
//
|
||||
// 说明:动态 key(首参非字面量)无法静态判定,作为人工复核提示列出,不计入失败(除非 --fail-on-dynamic)。
|
||||
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname } from "node:path";
|
||||
import { findDanglingReferences } from "./check-keys-dangling-lib.mjs";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const DEFAULT_EN = resolve(root, "src/locales/en");
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { targets: [], en: DEFAULT_EN, failOnDynamic: false, help: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--en" && argv[i + 1]) args.en = resolve(argv[++i]);
|
||||
else if (a === "--fail-on-dynamic") args.failOnDynamic = true;
|
||||
else if (a === "--help" || a === "-h") args.help = true;
|
||||
else if (!a.startsWith("-")) args.targets.push(a);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
||||
console.log(`T-02b dangling translation-key checker
|
||||
|
||||
Usage:
|
||||
node scripts/i18n/check-keys-dangling.mjs [dir|file ...] [--en <enDir>] [--fail-on-dynamic]
|
||||
|
||||
Scans .ts/.tsx sources for t("ns:key") / <Trans i18nKey> references and verifies each
|
||||
referenced key exists in the en locale resource. Files must import useTranslation /
|
||||
getI18n to be considered i18n-active.
|
||||
|
||||
Exit code 0 when no missing keys; 1 when missing keys / unknown namespaces found
|
||||
(or dynamic keys with --fail-on-dynamic); 2 on IO error.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { targets, en, failOnDynamic } = parseArgs(process.argv.slice(2));
|
||||
if (targets.length === 0) {
|
||||
console.error("check-keys-dangling: no target dir/file given. Pass a path or --help.");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let dangling;
|
||||
try {
|
||||
dangling = findDanglingReferences(targets[0], { enDir: en });
|
||||
// 支持多个 target:合并结果(简单起见,逐 target 运行追加)
|
||||
for (const t of targets.slice(1)) {
|
||||
dangling = dangling.concat(findDanglingReferences(t, { enDir: en }));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`check-keys-dangling: failed (${e.message})`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const byKind = (k) => dangling.filter((d) => d.kind === k);
|
||||
const missingKey = byKind("missing-key");
|
||||
const unknownNs = byKind("unknown-namespace");
|
||||
const dynamics = byKind("dynamic");
|
||||
|
||||
if (dangling.length === 0) {
|
||||
console.log(`check-keys-dangling: PASS — no dangling translation keys in ${targets.join(", ")}.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (missingKey.length > 0) {
|
||||
console.log(`[MISSING-KEY] ${missingKey.length} 引用的 key 未在 en locale 声明:`);
|
||||
for (const d of missingKey) console.log(` ${d.file}:${d.line} [${d.ns}] ${d.key}`);
|
||||
}
|
||||
if (unknownNs.length > 0) {
|
||||
console.log(`[UNKNOWN-NS] ${unknownNs.length} 引用了未注册的 namespace:`);
|
||||
for (const d of unknownNs) console.log(` ${d.file}:${d.line} ${d.key}`);
|
||||
}
|
||||
if (dynamics.length > 0) {
|
||||
console.log(`[DYNAMIC] ${dynamics.length} 引用为动态 key(无法静态校验,需人工复核):`);
|
||||
for (const d of dynamics) console.log(` ${d.file}:${d.line} ${d.key}`);
|
||||
}
|
||||
|
||||
const failed = missingKey.length > 0 || unknownNs.length > 0 || (failOnDynamic && dynamics.length > 0);
|
||||
if (failed) {
|
||||
console.error("check-keys-dangling: FAILED — dangling/key issues found.");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("check-keys-dangling: WARN — 存在需人工复核的动态 key(未因动态 key 失败)。");
|
||||
process.exit(0);
|
||||
149
ui/litellm-dashboard/scripts/i18n/check-keys-lib.mjs
Normal file
149
ui/litellm-dashboard/scripts/i18n/check-keys-lib.mjs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// T-02 key 集合一致性校验库(纯 Node,无第三方依赖)。
|
||||
// 对比 en / zh-CN 同名 namespace 的:
|
||||
// 1. 递归扁平化 key 集合(含嵌套路径,点分隔)
|
||||
// 2. 嵌套结构形状(叶子 vs 对象)
|
||||
// 3. 每叶子上的 {{var}} 插值变量集合
|
||||
// 输出对称差清单,供 CLI / CI 门禁(G3)消费。
|
||||
// 只读、不改写任何文件。
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const DEFAULT_LOCALES = ["en", "zh-CN"];
|
||||
|
||||
// 从 JSON 值中提取插值变量 `{{name}}`(i18next 标准插值)。
|
||||
export function extractInterpolationVars(value) {
|
||||
const vars = new Set();
|
||||
if (typeof value !== "string") return vars;
|
||||
const re = /\{\{\s*([A-Za-z0-9_][A-Za-z0-9_.]*)\s*\}\}/g;
|
||||
let m;
|
||||
while ((m = re.exec(value)) !== null) {
|
||||
vars.add(m[1]);
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
|
||||
// 递归扁平化一个字典:返回 Map<keyPath, { value, isLeaf }>。
|
||||
// 对象值递归,叶子(字符串/数字/布尔/null/数组)记录 value。
|
||||
export function flattenDict(obj, prefix = "") {
|
||||
const out = new Map();
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const path = prefix ? `${prefix}.${k}` : k;
|
||||
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
|
||||
const sub = flattenDict(v, path);
|
||||
for (const [p, info] of sub) out.set(p, info);
|
||||
out.set(path, { value: undefined, isLeaf: false }); // 记录分支节点形状
|
||||
} else {
|
||||
out.set(path, { value: v, isLeaf: true });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseJson(filePath) {
|
||||
return JSON.parse(readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
// 列出目录下顶层文件(不含子目录),去扩展名,返回 { name, absPath, ext }。
|
||||
export function listLocaleFiles(dir) {
|
||||
if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory()) return [];
|
||||
return readdirSync(dir)
|
||||
.filter((f) => statSync(join(dir, f)).isFile())
|
||||
.map((f) => {
|
||||
const dot = f.lastIndexOf(".");
|
||||
const name = dot > 0 ? f.slice(0, dot) : f;
|
||||
return { name, absPath: join(dir, f), ext: dot > 0 ? f.slice(dot + 1) : "" };
|
||||
});
|
||||
}
|
||||
|
||||
// 命名空间之间没有共同文件也能校验;只对有同名校的文件做比对。
|
||||
export function collectNamespaces(localeDir) {
|
||||
const files = listLocaleFiles(localeDir).filter((f) => f.ext === "json");
|
||||
return new Map(files.map((f) => [f.name, parseJson(f.absPath)]));
|
||||
}
|
||||
|
||||
// 对比一对字典(en vs zh),返回差异详情。
|
||||
export function diffDicts(a, b) {
|
||||
const fa = flattenDict(a);
|
||||
const fb = flattenDict(b);
|
||||
const aPaths = new Set(fa.keys());
|
||||
const bPaths = new Set(fb.keys());
|
||||
|
||||
const missing = []; // 在 b 中有、a 中没有(即 zh 缺 en 的 key)
|
||||
const extra = []; // 在 a 中有、b 没有
|
||||
const shapeMismatch = [];
|
||||
const interpolationMismatch = [];
|
||||
|
||||
// 以 a (en) 为基准:遍历所有叶子路径
|
||||
for (const p of aPaths) {
|
||||
if (!bPaths.has(p)) {
|
||||
extra.push(p); // en 有、zh 无 -> zh 缺
|
||||
}
|
||||
}
|
||||
for (const p of bPaths) {
|
||||
if (!aPaths.has(p)) {
|
||||
missing.push(p); // zh 有、en 无
|
||||
}
|
||||
}
|
||||
|
||||
// 结构/插值比对仅在双方都存在叶子时进行
|
||||
for (const p of aPaths) {
|
||||
if (!bPaths.has(p)) continue;
|
||||
const infoA = fa.get(p);
|
||||
const infoB = fb.get(p);
|
||||
if (infoA.isLeaf !== infoB.isLeaf) {
|
||||
shapeMismatch.push(p);
|
||||
continue;
|
||||
}
|
||||
if (!infoA.isLeaf) continue;
|
||||
const va = extractInterpolationVars(infoA.value);
|
||||
const vb = extractInterpolationVars(infoB.value);
|
||||
const vaArr = [...va].sort();
|
||||
const vbArr = [...vb].sort();
|
||||
if (vaArr.join("|") !== vbArr.join("|")) {
|
||||
interpolationMismatch.push({ key: p, en: vaArr, zh: vbArr });
|
||||
}
|
||||
}
|
||||
|
||||
return { missing, extra, shapeMismatch, interpolationMismatch };
|
||||
}
|
||||
|
||||
// 顶层入口:给定 en 目录与 zh 目录,返回每个共同 namespace 的差异。
|
||||
// 同时报告只有一方存在的 namespace(enOnly / zhOnly)。
|
||||
export function diffLocaleDirs(enDir, zhDir, { locales = DEFAULT_LOCALES } = {}) {
|
||||
const enNs = collectNamespaces(enDir);
|
||||
const zhNs = collectNamespaces(zhDir);
|
||||
const enNames = new Set(enNs.keys());
|
||||
const zhNames = new Set(zhNs.keys());
|
||||
|
||||
const results = [];
|
||||
for (const name of enNames) {
|
||||
if (zhNames.has(name)) {
|
||||
const d = diffDicts(enNs.get(name), zhNs.get(name));
|
||||
results.push({ namespace: name, ...d });
|
||||
} else {
|
||||
results.push({
|
||||
namespace: name,
|
||||
missing: [],
|
||||
extra: [],
|
||||
shapeMismatch: [],
|
||||
interpolationMismatch: [],
|
||||
onlyInEn: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
const onlyInZh = [...zhNames].filter((n) => !enNames.has(n));
|
||||
return { namespaces: results, enOnly: [...enNames].filter((n) => !zhNames.has(n)), zhOnly: onlyInZh };
|
||||
}
|
||||
|
||||
export function hasDiff(diff) {
|
||||
if (diff.enOnly.length > 0 || diff.zhOnly.length > 0) return true;
|
||||
return diff.namespaces.some(
|
||||
(n) =>
|
||||
n.missing.length > 0 ||
|
||||
n.extra.length > 0 ||
|
||||
n.shapeMismatch.length > 0 ||
|
||||
n.interpolationMismatch.length > 0 ||
|
||||
n.onlyInEn,
|
||||
);
|
||||
}
|
||||
123
ui/litellm-dashboard/scripts/i18n/check-keys.mjs
Normal file
123
ui/litellm-dashboard/scripts/i18n/check-keys.mjs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
#!/usr/bin/env node
|
||||
// T-02 key 集合一致性校验 CLI(下方红色:只读、不写入字典)。
|
||||
// 校验 en 与 zh-CN 同名 namespace 的 key 集合、嵌套结构、插值变量集合完全一致。
|
||||
//
|
||||
// 用法:
|
||||
// node scripts/i18n/check-keys.mjs
|
||||
// node scripts/i18n/check-keys.mjs --en <enDir> --zh <zhDir>
|
||||
//
|
||||
// 退出码:一致 -> 0;不一致 -> 1。适合 CI 门禁(G3)。
|
||||
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { diffLocaleDirs, hasDiff } from "./check-keys-lib.mjs";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const DEFAULT_EN = resolve(root, "src/locales/en");
|
||||
const DEFAULT_ZH = resolve(root, "src/locales/zh-CN");
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { en: DEFAULT_EN, zh: DEFAULT_ZH };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === "--en" && argv[i + 1]) args.en = resolve(argv[++i]);
|
||||
else if (argv[i] === "--zh" && argv[i + 1]) args.zh = resolve(argv[++i]);
|
||||
else if (argv[i] === "--help" || argv[i] === "-h") args.help = true;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
||||
console.log(`T-02 key consistency checker
|
||||
|
||||
Usage:
|
||||
node scripts/i18n/check-keys.mjs [--en <enDir>] [--zh <zhDir>]
|
||||
|
||||
Default dirs (relative to dashboard root):
|
||||
--en src/locales/en
|
||||
--zh src/locales/zh-CN
|
||||
|
||||
Exit code 0 when key sets match, 1 when any difference is found.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { en, zh } = parseArgs(process.argv.slice(2));
|
||||
|
||||
let diff;
|
||||
try {
|
||||
diff = diffLocaleDirs(en, zh);
|
||||
} catch (e) {
|
||||
console.error(`check-keys: failed to read locale dirs (${e.message})`);
|
||||
console.error(` en: ${en}`);
|
||||
console.error(` zh: ${zh}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const failed = hasDiff(diff);
|
||||
let anyLine = false;
|
||||
|
||||
const print = (line) => {
|
||||
console.log(line);
|
||||
anyLine = true;
|
||||
};
|
||||
|
||||
if (diff.enOnly.length > 0) {
|
||||
print(`[EN-ONLY] namespace 仅存在于 ${en}:`);
|
||||
for (const n of diff.enOnly) print(` - ${n}`);
|
||||
}
|
||||
if (diff.zhOnly.length > 0) {
|
||||
print(`[ZH-ONLY] namespace 仅存在于 ${zh}:`);
|
||||
for (const n of diff.zhOnly) print(` - ${n}`);
|
||||
}
|
||||
|
||||
for (const ns of diff.namespaces) {
|
||||
const nsProblems =
|
||||
ns.onlyInEn ||
|
||||
ns.missing.length > 0 ||
|
||||
ns.extra.length > 0 ||
|
||||
ns.shapeMismatch.length > 0 ||
|
||||
ns.interpolationMismatch.length > 0;
|
||||
|
||||
if (!nsProblems) {
|
||||
print(
|
||||
`[OK] ${ns.namespace}: key set matched (` +
|
||||
`${ns.extra.length} missing / ${ns.shapeMismatch.length} shape / ` +
|
||||
`${ns.interpolationMismatch.length} interpolation)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ns.onlyInEn) {
|
||||
print(`[DIFF] ${ns.namespace}: namespace 只有 ${en} 侧`);
|
||||
continue;
|
||||
}
|
||||
|
||||
print(`[DIFF] ${ns.namespace}:`);
|
||||
if (ns.missing.length > 0) {
|
||||
print(` missing (zh 有、en 无):`);
|
||||
for (const k of ns.missing) print(` - ${k}`);
|
||||
}
|
||||
if (ns.extra.length > 0) {
|
||||
print(` extra (en 有、zh 无):`);
|
||||
for (const k of ns.extra) print(` - ${k}`);
|
||||
}
|
||||
if (ns.shapeMismatch.length > 0) {
|
||||
print(` shape mismatch (叶子/对象不一致):`);
|
||||
for (const k of ns.shapeMismatch) print(` - ${k}`);
|
||||
}
|
||||
if (ns.interpolationMismatch.length > 0) {
|
||||
print(` interpolation mismatch ({{...}} 变量不一致):`);
|
||||
for (const { key: k, en: ev, zh: zv } of ns.interpolationMismatch)
|
||||
print(` - ${k}: en=[${ev.join(", ")}] zh=[${zv.join(", ")}]`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!anyLine) {
|
||||
console.log(`OK: 未发现 ${en} / ${zh} 下的 locale 文件(或目录不存在)。`);
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.error("check-keys: FAILED — en/zh key sets are not consistent.");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("check-keys: PASS — en/zh key sets consistent.");
|
||||
process.exit(0);
|
||||
139
ui/litellm-dashboard/scripts/i18n/scan-hardcoded-lib.mjs
Normal file
139
ui/litellm-dashboard/scripts/i18n/scan-hardcoded-lib.mjs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// T-03 硬编码字符串扫描库(纯 Node,无第三方依赖)。
|
||||
// 只报告疑似"用户可见"的硬编码文案,绝不改写代码、绝不自动生成 key。
|
||||
// 供人工 review(方案 §3.4 硬约束:工具只报告)。
|
||||
//
|
||||
// 策略(line-based 启发式,适合作为报告工具而非精确解析器):
|
||||
// 1. JSX 文本:`>文案<` 形态的非空白/非注释/非纯符号文本。
|
||||
// 2. 可访问性/文案属性字面量:aria-label / title / placeholder / alt / label。
|
||||
// 3. 常见未国际化消息入口:toast( ... ) / notify( ... ) / useMessage / useToast。
|
||||
// 排除:
|
||||
// - 已 t() / <Trans> 包裹的文案
|
||||
// - 纯数字/符号/URL/路径/正则/占位符
|
||||
// - 标识符(模式串)、CSS 类、data-slot、样式令牌
|
||||
// - 明显是模型名/API 字段/日志/代码示例的标识符
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
// ---------- 单个候选 ----------
|
||||
// { file, line, col, kind, text }
|
||||
|
||||
// 判文案属性值是否"疑似用户可见文案"(过滤纯符号/数字/占位)。
|
||||
const PLACEHOLDER_RE = /[{}]/;
|
||||
const URL_RE = /^(https?:)?\/\//i;
|
||||
const PATH_RE = /^[./~][\w./-]*$/;
|
||||
|
||||
export function isLikelyCopy(s) {
|
||||
const t = s.trim();
|
||||
if (t.length === 0) return false;
|
||||
// 纯数字/符号组合(不含字母)
|
||||
if (/^[\d\s.,%:+\-*/()_'"`]+$/.test(t)) return false;
|
||||
// 占位符(含 {{ }} 或 { } 渲染表达式)——由插值负责,不视为硬编码
|
||||
if (PLACEHOLDER_RE.test(t)) return false;
|
||||
// URL / 路径
|
||||
if (URL_RE.test(t) || PATH_RE.test(t)) return false;
|
||||
// 单 token 且形式为 CSS 类/标识符(连字符/下划线/无空格)——排除模型名、类名、变量
|
||||
if (/^[A-Za-z0-9][\w-]*$/.test(t)) return false;
|
||||
// 必须含字母
|
||||
if (!/[A-Za-z]/.test(t)) return false;
|
||||
// 至少两个词,保证是短语/句子而非单词级标识符片段
|
||||
const words = t.split(/\s+/).filter(Boolean);
|
||||
if (words.length < 2) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 过滤已 <Trans> 包裹(或其参数为 t() 表达式)的文本。
|
||||
// 行内出现 <Trans 时,本行 JSX 文本很可能已国际化,跳过以避免误报;
|
||||
// 其余候选(属性/入口)由各自模式精确排除表达式与单 token,无需整行 t() 判断。
|
||||
export function lineHasTransWrapper(context) {
|
||||
return /<Trans[\s>]/.test(context);
|
||||
}
|
||||
|
||||
// 从单行提取 `attr="value"` 形态的属性字面量。
|
||||
// attr 限定于文案属性集合。
|
||||
const ATTR_TEXT_RE = /\b(aria-label|title|placeholder|alt)="([^"]*)"/g;
|
||||
export function findCopyAttributes(line) {
|
||||
const out = [];
|
||||
let m;
|
||||
ATTR_TEXT_RE.lastIndex = 0;
|
||||
while ((m = ATTR_TEXT_RE.exec(line)) !== null) {
|
||||
out.push({ attr: m[1], text: m[2], col: m.index });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 从 JSX 文本提取 `>text<`。只捕获紧跟 `>`、后跟 `</` 或 `/>` 的文本节点。
|
||||
// 形如:<Tag>hello world</Tag> 或 <Tag>hi</Tag>
|
||||
export function findJsxText(line) {
|
||||
const out = [];
|
||||
const re = />([^<>{}\n]+)</g;
|
||||
let m;
|
||||
while ((m = re.exec(line)) !== null) {
|
||||
const t = m[1].trim();
|
||||
if (t.length > 0) {
|
||||
out.push({ text: t, col: m.index + 1 });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 常见未国际化消息入口:toast( / notify( / setToast( / useMessage / enqueueSnackbar / addToast
|
||||
const MSG_ENTRY_RE = /\b(toast|notify|enqueueSnackbar|addToast|showToast|useMessage|useToast)\s*\(([^)]*)\)/g;
|
||||
export function findMessageEntries(line) {
|
||||
const out = [];
|
||||
let m;
|
||||
MSG_ENTRY_RE.lastIndex = 0;
|
||||
while ((m = MSG_ENTRY_RE.exec(line)) !== null) {
|
||||
const arg = m[2].trim();
|
||||
// 仅字符串字面量(单/双引号)视为候选;对象/表达式跳过
|
||||
const strHit = arg.match(/^(['"])(.*?)\1/);
|
||||
if (strHit) {
|
||||
out.push({ entry: m[1], text: strHit[2], col: m.index });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 主扫描:给定文件绝对路径与内容行数组,返回候选列表。
|
||||
export function scanContent(source, filePath) {
|
||||
const lines = source.split("\n");
|
||||
const candidates = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const stripped = line.replace(/\/\/.*$/, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
if (stripped.trim() === "") continue;
|
||||
|
||||
// 该行是否含 <Trans>(其 JSX 文本已国际化,跳过 JSX 文本候选项)
|
||||
const trans = lineHasTransWrapper(line);
|
||||
|
||||
// 1) 文案属性(aria-label/title/placeholder/alt 的纯字符串字面量;
|
||||
// t("...") 形式是 aria-label={t(...)},不匹配本引号模式,天然排除)
|
||||
for (const { attr, text, col } of findCopyAttributes(stripped)) {
|
||||
if (isLikelyCopy(text)) {
|
||||
candidates.push({ file: filePath, line: i + 1, col, kind: `attr:${attr}`, text });
|
||||
}
|
||||
}
|
||||
|
||||
// 2) JSX 文本
|
||||
if (!trans) {
|
||||
for (const { text, col } of findJsxText(stripped)) {
|
||||
if (isLikelyCopy(text)) {
|
||||
candidates.push({ file: filePath, line: i + 1, col, kind: "jsx-text", text });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 消息入口
|
||||
for (const { entry, text, col } of findMessageEntries(line)) {
|
||||
if (isLikelyCopy(text)) {
|
||||
candidates.push({ file: filePath, line: i + 1, col, kind: `entry:${entry}`, text });
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function scanFile(filePath) {
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
return scanContent(source, filePath);
|
||||
}
|
||||
80
ui/litellm-dashboard/scripts/i18n/scan-hardcoded.mjs
Normal file
80
ui/litellm-dashboard/scripts/i18n/scan-hardcoded.mjs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
#!/usr/bin/env node
|
||||
// T-03 硬编码字符串扫描 CLI —— 只报告,不自动改写,不自动生成 key(方案 §3.4)。
|
||||
// 扫描 .ts / .tsx 中疑似"用户可见"的硬编码文案,输出供人工 review 的清单。
|
||||
//
|
||||
// 用法:
|
||||
// node scripts/i18n/scan-hardcoded.mjs [dir|file ...]
|
||||
// 默认扫描 src/(若存在)。
|
||||
// 可传多个目录/文件;目录会递归遍历 .ts/.tsx。
|
||||
//
|
||||
// 输出带 "REPORT ONLY" 标注;退出码 0(报告工具不因命中失败)。
|
||||
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { dirname, extname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { scanContent } from "./scan-hardcoded-lib.mjs";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const DEFAULT_SRC = resolve(root, "src");
|
||||
|
||||
const IGNORED_DIRS = new Set(["node_modules", ".next", "out", "dist", ".git", "coverage"]);
|
||||
const TARGET_EXT = new Set([".ts", ".tsx"]);
|
||||
|
||||
function collectFiles(path, acc = []) {
|
||||
const st = statSync(path);
|
||||
if (st.isFile()) {
|
||||
if (TARGET_EXT.has(extname(path))) acc.push(path);
|
||||
return acc;
|
||||
}
|
||||
for (const entry of readdirSync(path)) {
|
||||
const full = join(path, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
if (IGNORED_DIRS.has(entry)) continue;
|
||||
collectFiles(full, acc);
|
||||
} else if (TARGET_EXT.has(extname(full))) {
|
||||
acc.push(full);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
const inputs = process.argv.slice(2).filter((a) => !a.startsWith("-"));
|
||||
const targets = inputs.length > 0 ? inputs : [DEFAULT_SRC];
|
||||
|
||||
const files = [];
|
||||
for (const t of targets) {
|
||||
if (!existsSync(t)) {
|
||||
console.error(`scan-hardcoded: path not found: ${t}`);
|
||||
process.exit(2);
|
||||
}
|
||||
collectFiles(t, files);
|
||||
}
|
||||
|
||||
// 按文件分组去重排序
|
||||
const unique = [...new Set(files)].sort();
|
||||
const grouped = new Map();
|
||||
let total = 0;
|
||||
|
||||
for (const f of unique) {
|
||||
const source = readFileSync(f, "utf8");
|
||||
const hits = scanContent(source, f);
|
||||
if (hits.length > 0) grouped.set(f, hits);
|
||||
total += hits.length;
|
||||
}
|
||||
|
||||
console.log(
|
||||
"TODO REPORT — 疑似硬编码\"用户可见\"文案候选,供人工 review,工具不做改写。",
|
||||
);
|
||||
console.log(`扫描 ${unique.length} 个文件,${total} 个候选命中。\n`);
|
||||
|
||||
for (const [f, hits] of grouped) {
|
||||
console.log(`# ${f}`);
|
||||
for (const h of hits) {
|
||||
console.log(` ${h.line}:${h.col} [${h.kind}] ${JSON.stringify(h.text)}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
console.log(`OK: 未发现疑似硬编码的用户可见文案候选。`);
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { ThemeProvider } from "next-themes";
|
|||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import ReactQueryProvider from "@/contexts/ReactQueryProvider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { I18nProvider } from "@/i18n";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
|
|
@ -27,14 +28,16 @@ export default function RootLayout({
|
|||
// cannot predict; suppressHydrationWarning confines that mismatch to this element.
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={inter.className}>
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem disableTransitionOnChange>
|
||||
<NuqsAdapter>
|
||||
<ReactQueryProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
<Toaster />
|
||||
</ReactQueryProvider>
|
||||
</NuqsAdapter>
|
||||
</ThemeProvider>
|
||||
<I18nProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem disableTransitionOnChange>
|
||||
<NuqsAdapter>
|
||||
<ReactQueryProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
<Toaster />
|
||||
</ReactQueryProvider>
|
||||
</NuqsAdapter>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useI18n, type Locale } from "@/i18n";
|
||||
|
||||
const LANGUAGE_OPTIONS: { value: Locale; key: string }[] = [
|
||||
// Keys are unprefixed: `common` is the default namespace (types.d.ts), so
|
||||
// i18next v26 typing resolves them without the `common:` prefix.
|
||||
{ value: "en", key: "languages.en" },
|
||||
{ value: "zh-CN", key: "languages.zh-CN" },
|
||||
];
|
||||
|
||||
/**
|
||||
* EN / 中文 language toggle. Uses `useI18n().setLocale` which persists the
|
||||
* choice to the unified `litellm.locale` key and applies it to the active
|
||||
* i18next instance. The active option is the current locale.
|
||||
*/
|
||||
export function LanguageSwitcher() {
|
||||
const { locale, setLocale } = useI18n();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1" role="group" aria-label={t("language.name")}>
|
||||
{LANGUAGE_OPTIONS.map((option) => {
|
||||
const isActive = option.value === locale;
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={isActive ? "secondary" : "ghost"}
|
||||
aria-pressed={isActive}
|
||||
onClick={() => setLocale(option.value)}
|
||||
>
|
||||
{t(option.key)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { i18n } from "i18next";
|
||||
|
||||
// Control i18n initialisation resolves so we can assert the readiness gate
|
||||
// blocks the business subtree before the instance is ready.
|
||||
let releaseGate: (instance: i18n) => void = () => {};
|
||||
let pending: Promise<i18n> | null = null;
|
||||
|
||||
vi.mock("./i18n", () => {
|
||||
return {
|
||||
getI18n: () =>
|
||||
(pending ??= new Promise<i18n>((resolve) => {
|
||||
releaseGate = resolve;
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider, useI18n } from "@/i18n";
|
||||
|
||||
function GateConsumer() {
|
||||
const { locale } = useI18n();
|
||||
return <span data-testid="gate-child">{locale}</span>;
|
||||
}
|
||||
|
||||
const ORIGINAL_LANG = "en";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.lang = ORIGINAL_LANG;
|
||||
pending = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
pending = null;
|
||||
localStorage.clear();
|
||||
document.documentElement.lang = ORIGINAL_LANG;
|
||||
});
|
||||
|
||||
describe("I18nProvider readiness gate", () => {
|
||||
it("does not render the business subtree before the instance is ready", () => {
|
||||
localStorage.setItem("litellm.locale", "zh-CN");
|
||||
render(
|
||||
<I18nProvider>
|
||||
<GateConsumer />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
// While i18n initialisation is pending, children must not appear (no key exposure).
|
||||
expect(screen.queryByTestId("gate-child")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders children once initialisation resolves", async () => {
|
||||
localStorage.setItem("litellm.locale", "zh-CN");
|
||||
render(
|
||||
<I18nProvider>
|
||||
<GateConsumer />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("gate-child")).not.toBeInTheDocument();
|
||||
|
||||
// Resolve the pending initialisation with a minimal fake instance.
|
||||
releaseGate({
|
||||
isInitialized: true,
|
||||
language: "zh-CN",
|
||||
changeLanguage: vi.fn(async () => {}),
|
||||
t: vi.fn(),
|
||||
} as unknown as i18n);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("gate-child")).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
111
ui/litellm-dashboard/src/i18n/I18nProvider.integration.test.tsx
Normal file
111
ui/litellm-dashboard/src/i18n/I18nProvider.integration.test.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
|
||||
import { I18nProvider, useI18n } from "@/i18n";
|
||||
|
||||
/** Reports the active locale and a translated value so tests can assert wiring. */
|
||||
function Consumer() {
|
||||
const { locale, setLocale } = useI18n();
|
||||
const { t } = useTranslation();
|
||||
const [pressed, setPressed] = useState<number>(0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="locale">{locale}</span>
|
||||
<span data-testid="translated">{t("common:languages.en")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLocale("zh-CN");
|
||||
setPressed((n) => n + 1);
|
||||
}}
|
||||
>
|
||||
to-zh
|
||||
</button>
|
||||
<span data-testid="pressed">{pressed}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ORIGINAL_LANG = "en";
|
||||
|
||||
beforeEach(() => {
|
||||
// Force a clean preference + language for each test.
|
||||
localStorage.clear();
|
||||
document.documentElement.lang = ORIGINAL_LANG;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.lang = ORIGINAL_LANG;
|
||||
});
|
||||
|
||||
describe("I18nProvider readiness gate", () => {
|
||||
it("renders the business subtree only once the instance is ready (no key flash)", async () => {
|
||||
render(
|
||||
<I18nProvider>
|
||||
<Consumer />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
// Children must render (gate opened) with a real translated value, not a raw key.
|
||||
const translated = await screen.findByTestId("translated");
|
||||
expect(translated).toHaveTextContent("English");
|
||||
});
|
||||
|
||||
it("syncs document.documentElement.lang to the resolved locale (en)", async () => {
|
||||
render(
|
||||
<I18nProvider>
|
||||
<Consumer />
|
||||
</I18nProvider>,
|
||||
);
|
||||
await waitFor(() => expect(document.documentElement.lang).toBe("en"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("I18nProvider zh-CN preference", () => {
|
||||
it("resolves a stored zh-CN preference, renders Chinese, and syncs <html lang>", async () => {
|
||||
localStorage.setItem("litellm.locale", "zh-CN");
|
||||
render(
|
||||
<I18nProvider>
|
||||
<Consumer />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("locale")).toHaveTextContent("zh-CN"));
|
||||
expect(document.documentElement.lang).toBe("zh-CN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useI18n setLocale", () => {
|
||||
it("switches language, applies it, and updates the consumer", async () => {
|
||||
render(
|
||||
<I18nProvider>
|
||||
<Consumer />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
await screen.findByTestId("translated");
|
||||
fireEvent.click(screen.getByRole("button", { name: "to-zh" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("locale")).toHaveTextContent("zh-CN"));
|
||||
expect(document.documentElement.lang).toBe("zh-CN");
|
||||
expect(screen.getByTestId("pressed")).toHaveTextContent("1");
|
||||
});
|
||||
|
||||
it("persists the explicit choice to the unified preferences key", async () => {
|
||||
render(
|
||||
<I18nProvider>
|
||||
<Consumer />
|
||||
</I18nProvider>,
|
||||
);
|
||||
await screen.findByTestId("translated");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "to-zh" }));
|
||||
await waitFor(() => expect(screen.getByTestId("locale")).toHaveTextContent("zh-CN"));
|
||||
|
||||
expect(localStorage.getItem("litellm.locale")).toBe("zh-CN");
|
||||
});
|
||||
});
|
||||
96
ui/litellm-dashboard/src/i18n/I18nProvider.tsx
Normal file
96
ui/litellm-dashboard/src/i18n/I18nProvider.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import type { i18n } from "i18next";
|
||||
|
||||
import { getI18n } from "./i18n";
|
||||
import { createBrowserLocaleEnv, resolveLocale, writeLocalePreference } from "./localePreferences";
|
||||
import type { Locale } from "./resources/registry";
|
||||
|
||||
interface I18nContextValue {
|
||||
locale: Locale;
|
||||
setLocale: (locale: Locale) => Promise<void>;
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||
|
||||
/** Access the active locale and a `setLocale` that persists and applies it. */
|
||||
export function useI18n(): I18nContextValue {
|
||||
const ctx = useContext(I18nContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useI18n must be used within <I18nProvider>");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the whole dashboard (root layout) as the outermost provider.
|
||||
*
|
||||
* First-screen strategy = language readiness gate (I18N_TECH_DESIGN §4 / ADR-03/04):
|
||||
* - Resolve the target locale from the D5 preference chain.
|
||||
* - Initialize the lazy i18next singleton and switch to the target language.
|
||||
* - Only then render the business subtree, so no `t()`/`<Trans>` content is ever
|
||||
* rendered before resources are ready (no raw key flash).
|
||||
* - Sync `document.documentElement.lang` with the resolved language post-mount.
|
||||
* The English default is equally gated (sub-frame), since a non-initialised
|
||||
* i18next would otherwise expose raw keys for `en` too.
|
||||
*/
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [i18n, setI18n] = useState<i18n | null>(null);
|
||||
const [locale, setLocaleState] = useState<Locale>("en");
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const env = createBrowserLocaleEnv();
|
||||
const target = resolveLocale(env);
|
||||
|
||||
void getI18n()
|
||||
.then(async (instance) => {
|
||||
if (cancelled) return;
|
||||
await instance.changeLanguage(target);
|
||||
if (cancelled) return;
|
||||
document.documentElement.lang = instance.language;
|
||||
setI18n(instance);
|
||||
setLocaleState(instance.language as Locale);
|
||||
setReady(true);
|
||||
})
|
||||
.catch(() => {
|
||||
// Pathological case: bundled static resources failed to initialise.
|
||||
// Do not render business content without an instance (that would expose
|
||||
// raw keys); surface the failure in the console.
|
||||
if (!cancelled) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[i18n] failed to initialise i18next instance");
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setLocale = useCallback(
|
||||
async (next: Locale) => {
|
||||
const env = createBrowserLocaleEnv();
|
||||
writeLocalePreference(next, env);
|
||||
if (i18n) {
|
||||
await i18n.changeLanguage(next);
|
||||
document.documentElement.lang = i18n.language;
|
||||
}
|
||||
setLocaleState(next);
|
||||
},
|
||||
[i18n],
|
||||
);
|
||||
|
||||
if (!ready || !i18n) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<I18nContext.Provider value={{ locale, setLocale }}>{children}</I18nContext.Provider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
}
|
||||
67
ui/litellm-dashboard/src/i18n/detectLocale.test.ts
Normal file
67
ui/litellm-dashboard/src/i18n/detectLocale.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { detectLocale, detectLocaleFromList, isSupportedLocale } from "./detectLocale";
|
||||
|
||||
describe("detectLocale", () => {
|
||||
it("maps bare zh to zh-CN", () => {
|
||||
expect(detectLocale("zh")).toBe("zh-CN");
|
||||
});
|
||||
|
||||
it("maps zh-Hans to zh-CN", () => {
|
||||
expect(detectLocale("zh-Hans")).toBe("zh-CN");
|
||||
});
|
||||
|
||||
it("maps zh-CN to zh-CN", () => {
|
||||
expect(detectLocale("zh-CN")).toBe("zh-CN");
|
||||
});
|
||||
|
||||
it("maps lowercase zh variants to zh-CN", () => {
|
||||
expect(detectLocale("zh-cn")).toBe("zh-CN");
|
||||
expect(detectLocale("ZH")).toBe("zh-CN");
|
||||
});
|
||||
|
||||
it("maps en to en", () => {
|
||||
expect(detectLocale("en")).toBe("en");
|
||||
expect(detectLocale("en-US")).toBe("en");
|
||||
});
|
||||
|
||||
it("maps unsupported languages to en (fallback)", () => {
|
||||
expect(detectLocale("fr")).toBe("en");
|
||||
expect(detectLocale("de")).toBe("en");
|
||||
});
|
||||
|
||||
it("falls back to en for empty/null/undefined input", () => {
|
||||
expect(detectLocale("")).toBe("en");
|
||||
expect(detectLocale(null)).toBe("en");
|
||||
expect(detectLocale(undefined)).toBe("en");
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace", () => {
|
||||
expect(detectLocale(" zh-Hans ")).toBe("zh-CN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectLocaleFromList", () => {
|
||||
it("returns the first supported locale honouring en priority", () => {
|
||||
expect(detectLocaleFromList(["en", "zh-CN"])).toBe("en");
|
||||
expect(detectLocaleFromList(["zh-CN", "en"])).toBe("zh-CN");
|
||||
});
|
||||
|
||||
it("skips unsupported tags and finds the first supported one", () => {
|
||||
expect(detectLocaleFromList(["fr", "zh"])).toBe("zh-CN");
|
||||
expect(detectLocaleFromList(["fr-FR", "en-US"])).toBe("en");
|
||||
});
|
||||
|
||||
it("falls back to en when nothing is supported", () => {
|
||||
expect(detectLocaleFromList(["fr", "de"])).toBe("en");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSupportedLocale", () => {
|
||||
it("accepts only exact supported locales", () => {
|
||||
expect(isSupportedLocale("en")).toBe(true);
|
||||
expect(isSupportedLocale("zh-CN")).toBe(true);
|
||||
expect(isSupportedLocale("zh")).toBe(false);
|
||||
expect(isSupportedLocale(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
40
ui/litellm-dashboard/src/i18n/detectLocale.ts
Normal file
40
ui/litellm-dashboard/src/i18n/detectLocale.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { supportedLngs, type Locale } from "./resources/registry";
|
||||
|
||||
/**
|
||||
* Normalize a BCP-47 / browser language tag to one of the supported locales.
|
||||
*
|
||||
* Rules (I18N_TECH_DESIGN §3.1):
|
||||
* - any Chinese tag (`zh`, `zh-Hans`, `zh-CN`, ...) -> `zh-CN`
|
||||
* - an exact supported locale -> itself
|
||||
* - anything else -> `en` (fallback)
|
||||
*
|
||||
* Pure function, safe to call in any environment.
|
||||
*/
|
||||
export function detectLocale(raw: string | null | undefined): Locale {
|
||||
if (!raw) return "en";
|
||||
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
|
||||
if (normalized.startsWith("zh")) return "zh-CN";
|
||||
|
||||
const exact = supportedLngs.find((lng) => lng.toLowerCase() === normalized);
|
||||
if (exact) return exact as Locale;
|
||||
|
||||
return "en";
|
||||
}
|
||||
|
||||
/** Accept a list of candidate browser languages and return the first supported one. */
|
||||
export function detectLocaleFromList(candidates: readonly string[]): Locale {
|
||||
for (const candidate of candidates) {
|
||||
const detected = detectLocale(candidate);
|
||||
if (detected !== "en" || candidate.toLowerCase().startsWith("en")) {
|
||||
// A non-en detection is authoritative; for `en` we stop at the first en tag.
|
||||
return detected;
|
||||
}
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
export function isSupportedLocale(value: string | null | undefined): value is Locale {
|
||||
return typeof value === "string" && (supportedLngs as readonly string[]).includes(value);
|
||||
}
|
||||
30
ui/litellm-dashboard/src/i18n/i18n.test.ts
Normal file
30
ui/litellm-dashboard/src/i18n/i18n.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getI18n } from "./i18n";
|
||||
|
||||
describe("getI18n", () => {
|
||||
it("returns a promise resolving to an initialised i18next instance", async () => {
|
||||
const i18n = await getI18n();
|
||||
expect(i18n.isInitialized).toBe(true);
|
||||
});
|
||||
|
||||
it("returns the same singleton across calls", async () => {
|
||||
const first = await getI18n();
|
||||
const second = await getI18n();
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it("resolves enabled namespaces from the registry", async () => {
|
||||
const i18n = await getI18n();
|
||||
expect(i18n.t("common:languages.en")).toBe("English");
|
||||
expect(i18n.t("navigation:dashboard")).toBe("Dashboard");
|
||||
});
|
||||
|
||||
it("switches to zh-CN and resolves Chinese resources", async () => {
|
||||
const i18n = await getI18n();
|
||||
await i18n.changeLanguage("zh-CN");
|
||||
expect(i18n.t("common:languages.zh-CN")).toBe("简体中文");
|
||||
expect(i18n.t("navigation:dashboard")).toBe("仪表盘");
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
});
|
||||
48
ui/litellm-dashboard/src/i18n/i18n.ts
Normal file
48
ui/litellm-dashboard/src/i18n/i18n.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { createInstance, type i18n } from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
import { RESOURCES, supportedLngs } from "./resources/registry";
|
||||
|
||||
/**
|
||||
* Lazy i18next singleton. Initialization happens once and is reused across
|
||||
* HMR / re-mounts; callers `await getI18n()` rather than importing a module-top
|
||||
* instance (I18N_TECH_DESIGN §2.2) so the static-export build never initializes
|
||||
* i18next during compilation.
|
||||
*
|
||||
* Note (i18next v26): `.init()`'s promise resolves to the bound `t` function,
|
||||
* not the instance, so we await it only to observe readiness and resolve the
|
||||
* cached instance itself.
|
||||
*/
|
||||
let pending: Promise<i18n> | null = null;
|
||||
|
||||
export function getI18n(): Promise<i18n> {
|
||||
if (!pending) {
|
||||
const instance = createInstance();
|
||||
|
||||
const initPromise = instance.use(initReactI18next).init({
|
||||
resources: RESOURCES,
|
||||
supportedLngs: [...supportedLngs],
|
||||
fallbackLng: "en",
|
||||
load: "currentOnly",
|
||||
nonExplicitSupportedLngs: false,
|
||||
defaultNS: "common",
|
||||
ns: ["common"],
|
||||
// Never render a raw English sentence as a "missing-key fallback"; a
|
||||
// fully-missing key renders nothing (ADR-03 §5.1) and warns in dev so it
|
||||
// surfaces early. English is the type source, so this is a last resort.
|
||||
returnNull: true,
|
||||
returnEmptyString: false,
|
||||
interpolation: { escapeValue: false },
|
||||
react: { useSuspense: false },
|
||||
missingKeyHandler: (_lngs, _ns, key) => {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[i18n] missing key: ${key}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
pending = initPromise.then(() => instance);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
10
ui/litellm-dashboard/src/i18n/index.ts
Normal file
10
ui/litellm-dashboard/src/i18n/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
"use client";
|
||||
|
||||
// ./i18n.ts imports react-i18next, whose module scope calls React context APIs.
|
||||
// Without this directive, importing the barrel from a server component (e.g.
|
||||
// app/layout.tsx) evaluates that module in the server graph and the build fails
|
||||
// with "createContext is not a function".
|
||||
export { I18nProvider, useI18n } from "./I18nProvider";
|
||||
export { getI18n } from "./i18n";
|
||||
export type { Locale } from "./resources/registry";
|
||||
export { supportedLngs, NAMESPACES } from "./resources/registry";
|
||||
172
ui/litellm-dashboard/src/i18n/localePreferences.test.ts
Normal file
172
ui/litellm-dashboard/src/i18n/localePreferences.test.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { LOCALE_STORAGE_KEY } from "./localePreferences";
|
||||
import {
|
||||
clearLocalePreference,
|
||||
readCookie,
|
||||
readStoredLocale,
|
||||
resolveLocale,
|
||||
writeLocalePreference,
|
||||
type LocaleEnv,
|
||||
} from "./localePreferences";
|
||||
|
||||
interface MemoryState {
|
||||
storage: Map<string, string>;
|
||||
/** Simulated Set-Cookie directives applied in order. */
|
||||
cookieDirectives: string[];
|
||||
languages: string[];
|
||||
secure: boolean;
|
||||
}
|
||||
|
||||
function makeEnv(state: MemoryState): LocaleEnv {
|
||||
const cookieString = () => {
|
||||
// Reconstruct a document.cookie string from the applied directives, keeping
|
||||
// the most recently set value per name.
|
||||
const values = new Map<string, string>();
|
||||
for (const directive of state.cookieDirectives) {
|
||||
const [nameAndValue] = directive.split(";");
|
||||
const idx = nameAndValue.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
const name = nameAndValue.slice(0, idx).trim();
|
||||
const value = nameAndValue.slice(idx + 1).trim();
|
||||
values.set(name, value);
|
||||
}
|
||||
return Array.from(values.entries())
|
||||
.map(([name, value]) => `${name}=${value}`)
|
||||
.join("; ");
|
||||
};
|
||||
|
||||
return {
|
||||
getCookieString: cookieString,
|
||||
setCookie: (name, value, opts) => {
|
||||
state.cookieDirectives.push(
|
||||
`${name}=${encodeURIComponent(value)}; SameSite=Lax; path=/${opts.secure ? "; Secure" : ""}`,
|
||||
);
|
||||
},
|
||||
removeCookie: (name) => {
|
||||
state.cookieDirectives.push(`${name}=; Max-Age=0; SameSite=Lax; path=/`);
|
||||
},
|
||||
storageGet: (key) => state.storage.get(key) ?? null,
|
||||
storageSet: (key, value) => {
|
||||
state.storage.set(key, value);
|
||||
},
|
||||
storageRemove: (key) => {
|
||||
state.storage.delete(key);
|
||||
},
|
||||
browserLanguages: () => state.languages,
|
||||
isSecure: () => state.secure,
|
||||
};
|
||||
}
|
||||
|
||||
function freshState(overrides: Partial<MemoryState> = {}): MemoryState {
|
||||
return {
|
||||
storage: new Map(),
|
||||
cookieDirectives: [],
|
||||
languages: ["en"],
|
||||
secure: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("readCookie", () => {
|
||||
it("parses a simple cookie string", () => {
|
||||
expect(readCookie("a=1; b=2", "b")).toBe("2");
|
||||
});
|
||||
|
||||
it("decodes URI-encoded values", () => {
|
||||
expect(readCookie("litellm.locale=zh-CN", LOCALE_STORAGE_KEY)).toBe("zh-CN");
|
||||
});
|
||||
|
||||
it("returns null when the key is absent or the string is empty", () => {
|
||||
expect(readCookie("", "x")).toBeNull();
|
||||
expect(readCookie("a=1", "x")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeLocalePreference", () => {
|
||||
it("writes to both localStorage and cookie with SameSite=Lax and path=/", () => {
|
||||
const state = freshState();
|
||||
const env = makeEnv(state);
|
||||
|
||||
writeLocalePreference("zh-CN", env);
|
||||
|
||||
expect(state.storage.get(LOCALE_STORAGE_KEY)).toBe("zh-CN");
|
||||
const directive = state.cookieDirectives.find((d) => d.startsWith("litellm.locale="));
|
||||
expect(directive).toContain("SameSite=Lax");
|
||||
expect(directive).toContain("path=/");
|
||||
expect(directive).not.toContain("Secure");
|
||||
});
|
||||
|
||||
it("attaches Secure when the environment reports it", () => {
|
||||
const state = freshState({ secure: true });
|
||||
const env = makeEnv(state);
|
||||
|
||||
writeLocalePreference("zh-CN", env);
|
||||
|
||||
const directive = state.cookieDirectives.find((d) => d.startsWith("litellm.locale="));
|
||||
expect(directive).toContain("Secure");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readStoredLocale", () => {
|
||||
it("reads from localStorage first", () => {
|
||||
const state = freshState({ storage: new Map([[LOCALE_STORAGE_KEY, "zh-CN"]]) });
|
||||
state.cookieDirectives.push(`${LOCALE_STORAGE_KEY}=en; path=/`);
|
||||
expect(readStoredLocale(makeEnv(state))).toBe("zh-CN");
|
||||
});
|
||||
|
||||
it("falls back to the cookie when localStorage is empty", () => {
|
||||
const state = freshState();
|
||||
state.cookieDirectives.push(`litellm.locale=zh-CN; path=/`);
|
||||
expect(readStoredLocale(makeEnv(state))).toBe("zh-CN");
|
||||
});
|
||||
|
||||
it("returns null when no preference exists", () => {
|
||||
expect(readStoredLocale(makeEnv(freshState()))).toBeNull();
|
||||
});
|
||||
|
||||
it("treats an unsupported stored value as absent", () => {
|
||||
const state = freshState({ storage: new Map([[LOCALE_STORAGE_KEY, "fr"]]) });
|
||||
expect(readStoredLocale(makeEnv(state))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearLocalePreference", () => {
|
||||
it("removes the key from both layers", () => {
|
||||
const state = freshState({
|
||||
storage: new Map([[LOCALE_STORAGE_KEY, "zh-CN"]]),
|
||||
cookieDirectives: [`litellm.locale=zh-CN; path=/`],
|
||||
});
|
||||
const env = makeEnv(state);
|
||||
|
||||
clearLocalePreference(env);
|
||||
|
||||
expect(state.storage.has(LOCALE_STORAGE_KEY)).toBe(false);
|
||||
expect(state.cookieDirectives.at(-1)).toContain("Max-Age=0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLocale (D5 priority)", () => {
|
||||
it("prefers an explicit stored preference over browser language", () => {
|
||||
const state = freshState({
|
||||
storage: new Map([[LOCALE_STORAGE_KEY, "zh-CN"]]),
|
||||
languages: ["en"],
|
||||
});
|
||||
expect(resolveLocale(makeEnv(state))).toBe("zh-CN");
|
||||
});
|
||||
|
||||
it("uses browser language when no stored preference exists", () => {
|
||||
expect(resolveLocale(makeEnv(freshState({ languages: ["zh-CN"] })))).toBe("zh-CN");
|
||||
expect(resolveLocale(makeEnv(freshState({ languages: ["zh", "en"] })))).toBe("zh-CN");
|
||||
});
|
||||
|
||||
it("honours browser language order with en priority", () => {
|
||||
expect(resolveLocale(makeEnv(freshState({ languages: ["en", "zh-CN"] })))).toBe("en");
|
||||
expect(resolveLocale(makeEnv(freshState({ languages: ["zh-CN", "en"] })))).toBe("zh-CN");
|
||||
});
|
||||
|
||||
it("falls back to en for unsupported browser languages", () => {
|
||||
expect(resolveLocale(makeEnv(freshState({ languages: ["fr", "de"] })))).toBe("en");
|
||||
expect(resolveLocale(makeEnv(freshState({ languages: [] })))).toBe("en");
|
||||
});
|
||||
});
|
||||
139
ui/litellm-dashboard/src/i18n/localePreferences.ts
Normal file
139
ui/litellm-dashboard/src/i18n/localePreferences.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { detectLocale, isSupportedLocale } from "./detectLocale";
|
||||
import { type Locale } from "./resources/registry";
|
||||
|
||||
/**
|
||||
* Unified language preference storage key (DECISIONS.md P5).
|
||||
* Always read/write this key; feature agents must not touch storage directly.
|
||||
*/
|
||||
export const LOCALE_STORAGE_KEY = "litellm.locale";
|
||||
|
||||
export interface LocaleEnv {
|
||||
/** Raw `document.cookie` string, e.g. `"a=1; b=2"`. */
|
||||
getCookieString(): string;
|
||||
/**
|
||||
* Set a cookie. `secure` is decided by the environment so tests can assert the
|
||||
* attribute independently of the global `window`.
|
||||
*/
|
||||
setCookie(name: string, value: string, opts: { secure: boolean }): void;
|
||||
removeCookie(name: string): void;
|
||||
storageGet(key: string): string | null;
|
||||
storageSet(key: string, value: string): void;
|
||||
storageRemove(key: string): void;
|
||||
browserLanguages(): readonly string[];
|
||||
/** Whether `Secure` should be attached to cookies (true in production over HTTPS). */
|
||||
isSecure(): boolean;
|
||||
}
|
||||
|
||||
/** Convenience default for the browser environment. */
|
||||
export function createBrowserLocaleEnv(): LocaleEnv {
|
||||
const inHttps =
|
||||
typeof window !== "undefined" && window.location.protocol === "https:";
|
||||
return {
|
||||
getCookieString: () => (typeof document === "undefined" ? "" : document.cookie),
|
||||
setCookie: (name, value, opts) => {
|
||||
if (typeof document === "undefined") return;
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; SameSite=Lax; path=/${opts.secure ? "; Secure" : ""}`;
|
||||
},
|
||||
removeCookie: (name) => {
|
||||
if (typeof document === "undefined") return;
|
||||
document.cookie = `${name}=; Max-Age=0; SameSite=Lax; path=/`;
|
||||
},
|
||||
storageGet: (key) => {
|
||||
try {
|
||||
return typeof localStorage === "undefined" ? null : localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
storageSet: (key, value) => {
|
||||
try {
|
||||
localStorage?.setItem(key, value);
|
||||
} catch {
|
||||
/* storage may be unavailable (e.g. private mode); cookie still carries the value */
|
||||
}
|
||||
},
|
||||
storageRemove: (key) => {
|
||||
try {
|
||||
localStorage?.removeItem(key);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
},
|
||||
browserLanguages: () =>
|
||||
typeof navigator === "undefined" ? [] : Array.from(navigator.languages ?? [navigator.language]).filter(Boolean),
|
||||
isSecure: () => inHttps,
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse a raw cookie string and return the value for `name` or null. */
|
||||
export function readCookie(rawCookies: string, name: string): string | null {
|
||||
if (!rawCookies) return null;
|
||||
for (const part of rawCookies.split(";")) {
|
||||
const idx = part.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
const key = part.slice(0, idx).trim();
|
||||
if (key === name) {
|
||||
try {
|
||||
return decodeURIComponent(part.slice(idx + 1).trim());
|
||||
} catch {
|
||||
return part.slice(idx + 1).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the explicitly stored locale. Per L1, if the unified key exists anywhere
|
||||
* (localStorage fast path first, then cookie) it is treated as the user's
|
||||
* explicit choice. Returns null when no preference has been written.
|
||||
*/
|
||||
export function readStoredLocale(env: LocaleEnv): Locale | null {
|
||||
const fromStorage = env.storageGet(LOCALE_STORAGE_KEY);
|
||||
const value = fromStorage ?? readCookie(env.getCookieString(), LOCALE_STORAGE_KEY);
|
||||
if (value === null || value === "") return null;
|
||||
return isSupportedLocale(value) ? value : null;
|
||||
}
|
||||
|
||||
/** Persist an explicit user choice to both cookie and localStorage. */
|
||||
export function writeLocalePreference(locale: Locale, env: LocaleEnv): void {
|
||||
env.storageSet(LOCALE_STORAGE_KEY, locale);
|
||||
env.setCookie(LOCALE_STORAGE_KEY, locale, { secure: env.isSecure() });
|
||||
}
|
||||
|
||||
/** Remove the stored preference from both layers. */
|
||||
export function clearLocalePreference(env: LocaleEnv): void {
|
||||
env.storageRemove(LOCALE_STORAGE_KEY);
|
||||
env.removeCookie(LOCALE_STORAGE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* D5 priority resolution:
|
||||
* 1. explicit stored preference (cookie/localStorage)
|
||||
* 2. browser language (first supported, normalized via detectLocale)
|
||||
* 3. `en` (fallback)
|
||||
*/
|
||||
export function resolveLocale(env: LocaleEnv): Locale {
|
||||
const stored = readStoredLocale(env);
|
||||
if (stored) return stored;
|
||||
return detectLocaleFromBrowser(env) ?? "en";
|
||||
}
|
||||
|
||||
function detectLocaleFromBrowser(env: LocaleEnv): Locale | null {
|
||||
const languages = env.browserLanguages();
|
||||
if (languages.length === 0) return null;
|
||||
|
||||
// Filter by supportedLngs and take the first supported tag (D5: browser
|
||||
// languages are used only when no explicit preference exists).
|
||||
for (const raw of languages) {
|
||||
if (isEnglishTag(raw)) return "en";
|
||||
const detected = detectLocale(raw);
|
||||
if (detected !== "en") return detected; // e.g. zh -> zh-CN
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
function isEnglishTag(raw: string): boolean {
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
return normalized === "en" || normalized.startsWith("en-");
|
||||
}
|
||||
34
ui/litellm-dashboard/src/i18n/resources.test.ts
Normal file
34
ui/litellm-dashboard/src/i18n/resources.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { RESOURCES, NAMESPACES } from "./resources/registry";
|
||||
|
||||
// Resource-shape tests. With i18next v26's typed-qualified-key support being
|
||||
// fragile (and breaking `next build`), `types.d.ts` types keys loosely and key
|
||||
// correctness is delegated to runtime readiness + A7's `check-keys` CI gate.
|
||||
// These tests keep the registry honest at runtime: every locale exposes every
|
||||
// registered namespace, and the seed keys the platform actually uses exist in
|
||||
// both locales — so a removed/moved resource fails here, not silently at runtime.
|
||||
describe("i18n resource registry", () => {
|
||||
it("exposes every registered namespace for every locale", () => {
|
||||
for (const locale of Object.keys(RESOURCES) as (keyof typeof RESOURCES)[]) {
|
||||
for (const name of NAMESPACES) {
|
||||
expect(RESOURCES[locale], `${locale}.${name}`).toHaveProperty(name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("has the common seed keys used by the platform in en and zh-CN", () => {
|
||||
for (const locale of ["en", "zh-CN"] as const) {
|
||||
const common = RESOURCES[locale].common as Record<string, unknown>;
|
||||
expect(common).toHaveProperty("language.name");
|
||||
expect(common).toHaveProperty("languages.en");
|
||||
expect(common).toHaveProperty("languages.zh-CN");
|
||||
}
|
||||
});
|
||||
|
||||
it("has the navigation seed key in en and zh-CN", () => {
|
||||
for (const locale of ["en", "zh-CN"] as const) {
|
||||
const nav = RESOURCES[locale].navigation as Record<string, unknown>;
|
||||
expect(nav).toHaveProperty("dashboard");
|
||||
}
|
||||
});
|
||||
});
|
||||
86
ui/litellm-dashboard/src/i18n/resources/registry.ts
Normal file
86
ui/litellm-dashboard/src/i18n/resources/registry.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import type { ResourceLanguage } from "i18next";
|
||||
|
||||
import commonEn from "@/locales/en/common.json";
|
||||
import navigationEn from "@/locales/en/navigation.json";
|
||||
import authEn from "@/locales/en/auth.json";
|
||||
import modelsEn from "@/locales/en/models.json";
|
||||
import apiKeysEn from "@/locales/en/apiKeys.json";
|
||||
import usageEn from "@/locales/en/usage.json";
|
||||
import costEn from "@/locales/en/cost.json";
|
||||
import budgetsEn from "@/locales/en/budgets.json";
|
||||
|
||||
import commonZh from "@/locales/zh-CN/common.json";
|
||||
import navigationZh from "@/locales/zh-CN/navigation.json";
|
||||
import authZh from "@/locales/zh-CN/auth.json";
|
||||
import modelsZh from "@/locales/zh-CN/models.json";
|
||||
import apiKeysZh from "@/locales/zh-CN/apiKeys.json";
|
||||
import usageZh from "@/locales/zh-CN/usage.json";
|
||||
import costZh from "@/locales/zh-CN/cost.json";
|
||||
import budgetsZh from "@/locales/zh-CN/budgets.json";
|
||||
|
||||
export const supportedLngs = ["en", "zh-CN"] as const;
|
||||
export type Locale = (typeof supportedLngs)[number];
|
||||
|
||||
/**
|
||||
* Namespace registry — the single source of truth for namespaces and their
|
||||
* static resources. Agent 4 owns this file permanently (FILE_OWNERSHIP rule 6).
|
||||
* Function agents only add keys to their own JSON; any new namespace must be
|
||||
* routed through Agent 4.
|
||||
*/
|
||||
// Namespace registry — the single source of truth for namespaces and their
|
||||
// static resources. Agent 4 owns this file permanently (FILE_OWNERSHIP rule 6).
|
||||
// Function agents only add keys to their own JSON; any new namespace must be
|
||||
// routed through Agent 4.
|
||||
//
|
||||
// KEY TYPING NOTE: use `satisfies Record<Namespace, object>` — NOT
|
||||
// `Record<Namespace, ResourceKey>`. The broad `ResourceKey` target would erase
|
||||
// each namespace's concrete key shapes, so `CustomTypeOptions.resources`
|
||||
// (types.d.ts) could only type the defaultNS (common) keys and could not infer
|
||||
// qualified keys like `navigation:dashboard`. `satisfies object` only validates
|
||||
// the shape without widening, keeping the `as const` literal keys intact.
|
||||
export const NAMESPACES = [
|
||||
"common",
|
||||
"navigation",
|
||||
"auth",
|
||||
"models",
|
||||
"apiKeys",
|
||||
"usage",
|
||||
"cost",
|
||||
"budgets",
|
||||
] as const;
|
||||
export type Namespace = (typeof NAMESPACES)[number];
|
||||
|
||||
const enResources = {
|
||||
common: commonEn,
|
||||
navigation: navigationEn,
|
||||
auth: authEn,
|
||||
models: modelsEn,
|
||||
apiKeys: apiKeysEn,
|
||||
usage: usageEn,
|
||||
cost: costEn,
|
||||
budgets: budgetsEn,
|
||||
} as const satisfies Record<Namespace, object>;
|
||||
|
||||
const zhCNResources = {
|
||||
common: commonZh,
|
||||
navigation: navigationZh,
|
||||
auth: authZh,
|
||||
models: modelsZh,
|
||||
apiKeys: apiKeysZh,
|
||||
usage: usageZh,
|
||||
cost: costZh,
|
||||
budgets: budgetsZh,
|
||||
} as const satisfies Record<Namespace, object>;
|
||||
|
||||
/**
|
||||
* Resource loading map: locale -> namespace -> static JSON.
|
||||
* English is the type source of truth (D2). Kept as a literal (`as const`) so
|
||||
* `typeof RESOURCES["en"]` drives `CustomTypeOptions['resources']` with concrete
|
||||
* key shapes for typed `t()` keys.
|
||||
*/
|
||||
export const RESOURCES = {
|
||||
en: enResources,
|
||||
"zh-CN": zhCNResources,
|
||||
} as const satisfies Record<Locale, ResourceLanguage>;
|
||||
|
||||
export const defaultNS: Namespace = "common";
|
||||
23
ui/litellm-dashboard/src/i18n/types.d.ts
vendored
Normal file
23
ui/litellm-dashboard/src/i18n/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Global i18next type augmentation. This file is owned by Agent 4 permanently.
|
||||
*/
|
||||
// This import makes this file a module, which turns `declare module` below into
|
||||
// an augmentation instead of an ambient declaration that would shadow the whole
|
||||
// i18next package's types.
|
||||
import "i18next";
|
||||
|
||||
declare module "i18next" {
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: "common";
|
||||
// Deliberately NO strict `resources` key augmentation here. i18next v26's
|
||||
// typed qualified keys are fragile (colons stripped in the union, qualified
|
||||
// non-default lookups typed `unknown`, resource-shape sensitivity) and, in
|
||||
// this static-export dashboard, repeatedly broke `next build`'s type check
|
||||
// for a benefit that is redundant with the guarantees we already have:
|
||||
// - runtime key correctness is enforced by the readiness gate (ADR-03,
|
||||
// no raw-key flash) and
|
||||
// - en/zh key inventory is enforced by A7's check-keys CI gate.
|
||||
// So keys are typed loosely (`string`), which compiles stably. Common-NS
|
||||
// keys are still used unprefixed per `defaultNS`.
|
||||
}
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/en/apiKeys.json
Normal file
3
ui/litellm-dashboard/src/locales/en/apiKeys.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"apiKeys": "API Keys"
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/en/auth.json
Normal file
3
ui/litellm-dashboard/src/locales/en/auth.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"signIn": "Sign in"
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/en/budgets.json
Normal file
3
ui/litellm-dashboard/src/locales/en/budgets.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"budgets": "Budgets"
|
||||
}
|
||||
11
ui/litellm-dashboard/src/locales/en/common.json
Normal file
11
ui/litellm-dashboard/src/locales/en/common.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"language": {
|
||||
"name": "Language",
|
||||
"switchTo": "Switch language"
|
||||
},
|
||||
"languages": {
|
||||
"en": "English",
|
||||
"zh-CN": "简体中文"
|
||||
},
|
||||
"loadError": "An error occurred while loading the page."
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/en/cost.json
Normal file
3
ui/litellm-dashboard/src/locales/en/cost.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"cost": "Cost"
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/en/models.json
Normal file
3
ui/litellm-dashboard/src/locales/en/models.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"models": "Models"
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/en/navigation.json
Normal file
3
ui/litellm-dashboard/src/locales/en/navigation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"dashboard": "Dashboard"
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/en/usage.json
Normal file
3
ui/litellm-dashboard/src/locales/en/usage.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"usage": "Usage"
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/zh-CN/apiKeys.json
Normal file
3
ui/litellm-dashboard/src/locales/zh-CN/apiKeys.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"apiKeys": "API 密钥"
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/zh-CN/auth.json
Normal file
3
ui/litellm-dashboard/src/locales/zh-CN/auth.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"signIn": "登录"
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/zh-CN/budgets.json
Normal file
3
ui/litellm-dashboard/src/locales/zh-CN/budgets.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"budgets": "预算"
|
||||
}
|
||||
11
ui/litellm-dashboard/src/locales/zh-CN/common.json
Normal file
11
ui/litellm-dashboard/src/locales/zh-CN/common.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"language": {
|
||||
"name": "语言",
|
||||
"switchTo": "切换语言"
|
||||
},
|
||||
"languages": {
|
||||
"en": "English",
|
||||
"zh-CN": "简体中文"
|
||||
},
|
||||
"loadError": "加载页面时发生错误。"
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/zh-CN/cost.json
Normal file
3
ui/litellm-dashboard/src/locales/zh-CN/cost.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"cost": "成本"
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/zh-CN/models.json
Normal file
3
ui/litellm-dashboard/src/locales/zh-CN/models.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"models": "模型"
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/zh-CN/navigation.json
Normal file
3
ui/litellm-dashboard/src/locales/zh-CN/navigation.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"dashboard": "仪表盘"
|
||||
}
|
||||
3
ui/litellm-dashboard/src/locales/zh-CN/usage.json
Normal file
3
ui/litellm-dashboard/src/locales/zh-CN/usage.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"usage": "用量"
|
||||
}
|
||||
133
ui/litellm-dashboard/tests/i18n/check-keys-dangling-lib.test.ts
Normal file
133
ui/litellm-dashboard/tests/i18n/check-keys-dangling-lib.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { describe, it, expect, afterAll } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
collectTsFiles,
|
||||
extractTranslationKeys,
|
||||
findDanglingReferences,
|
||||
isI18nActive,
|
||||
} from "../../scripts/i18n/check-keys-dangling-lib.mjs";
|
||||
|
||||
function writeLocale(dir, name, obj) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, `${name}.json`), JSON.stringify(obj));
|
||||
}
|
||||
|
||||
describe("collectTsFiles", () => {
|
||||
it("collects only .ts / .tsx and skips ignored dirs", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "dangling-collect-"));
|
||||
mkdirSync(join(tmp, "node_modules"), { recursive: true });
|
||||
mkdirSync(join(tmp, "src"), { recursive: true });
|
||||
writeFileSync(join(tmp, "src", "a.tsx"), "x");
|
||||
writeFileSync(join(tmp, "src", "b.ts"), "y");
|
||||
writeFileSync(join(tmp, "src", "c.d.ts"), "z");
|
||||
writeFileSync(join(tmp, "node_modules", "ignored.tsx"), "i");
|
||||
const files = collectTsFiles(tmp)
|
||||
.map((f) => f.split("/").slice(-2).join("/"))
|
||||
.sort();
|
||||
expect(files).toEqual(["src/a.tsx", "src/b.ts"]); // 排除 .d.ts 与 node_modules,且 a 在 b 前按序
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("isI18nActive", () => {
|
||||
it("true when importing useTranslation from react-i18next", () => {
|
||||
expect(isI18nActive(`import { useTranslation } from "react-i18next";`)).toBe(true);
|
||||
});
|
||||
it("true when importing getI18n from @/i18n", () => {
|
||||
expect(isI18nActive(`import { getI18n } from "@/i18n";`)).toBe(true);
|
||||
});
|
||||
it("false when no i18n import", () => {
|
||||
expect(isI18nActive(`import { useState } from "react"; function f(){ const t=(x)=>x; return t("hi"); }`)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractTranslationKeys", () => {
|
||||
it("extracts qualified, unqualified, and <Trans i18nKey> refs", () => {
|
||||
const src = `
|
||||
import { useTranslation } from "react-i18next";
|
||||
export function C(){
|
||||
const { t } = useTranslation();
|
||||
return (<>
|
||||
<span>{t("common:hello")}</span>
|
||||
<span>{t("dashboard")}</span>
|
||||
<Trans i18nKey="navigation:dashboard">fallback</Trans>
|
||||
</>);
|
||||
}
|
||||
`;
|
||||
const { refs } = extractTranslationKeys(src, "/f/C.tsx");
|
||||
const kinds = refs.map((r) => r.kind).sort();
|
||||
expect(kinds).toContain("qualified");
|
||||
expect(kinds).toContain("unqualified");
|
||||
expect(refs.filter((r) => r.kind === "trans").map((r) => r.key)).toEqual(["navigation:dashboard"]);
|
||||
});
|
||||
|
||||
it("marks non-string first args as dynamic", () => {
|
||||
const src = `
|
||||
import { useTranslation } from "react-i18next";
|
||||
export function C({k}){ const { t } = useTranslation(); return <span>{t(k)}</span>; }
|
||||
`;
|
||||
const { refs } = extractTranslationKeys(src, "/f/C.tsx");
|
||||
expect(refs.filter((r) => r.kind === "dynamic").length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findDanglingReferences", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "dangling-"));
|
||||
const enDir = join(tmp, "en");
|
||||
const srcDir = join(tmp, "src");
|
||||
mkdirSync(srcDir, { recursive: true });
|
||||
|
||||
writeLocale(enDir, "common", { hello: "Hello", action: { save: "Save" }, count: "{{count}} items" });
|
||||
writeLocale(enDir, "navigation", { dashboard: "Dashboard" });
|
||||
|
||||
const writeSrc = (name, src) => writeFileSync(join(srcDir, name), src);
|
||||
|
||||
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
it("flags a referenced key missing from the declared resource", () => {
|
||||
writeSrc(
|
||||
"Missing.tsx",
|
||||
`import { useTranslation } from "react-i18next";
|
||||
export function C(){ const { t } = useTranslation(); return <span>{t("common:hello")} {t("common:missing")}</span>; }`,
|
||||
);
|
||||
const d = findDanglingReferences(srcDir, { enDir });
|
||||
expect(d.some((x) => x.kind === "missing-key" && x.key === "common:missing")).toBe(true);
|
||||
// 已有 key 不应误报
|
||||
expect(d.some((x) => x.key === "common:hello")).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves unqualified keys against the default namespace", () => {
|
||||
writeSrc(
|
||||
"Default.tsx",
|
||||
`import { useTranslation } from "react-i18next";
|
||||
export function C(){ const { t } = useTranslation(); return <span>{t("hello")}</span>; }`,
|
||||
);
|
||||
const d = findDanglingReferences(srcDir, { enDir });
|
||||
expect(d.some((x) => x.key === "hello")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts plural suffixes (count keys)", () => {
|
||||
writeSrc(
|
||||
"Plural.tsx",
|
||||
`import { useTranslation } from "react-i18next";
|
||||
export function C(){ const { t } = useTranslation(); return <span>{t("common:count", { count }) }</span>; }`,
|
||||
);
|
||||
const d = findDanglingReferences(srcDir, { enDir });
|
||||
// common.count 声明为 "{{count}} items",无后缀也匹配(字面量 key 已存在)
|
||||
expect(d.some((x) => x.key === "common:count")).toBe(false);
|
||||
});
|
||||
|
||||
it("flags unknown namespaces", () => {
|
||||
writeSrc(
|
||||
"UnknownNs.tsx",
|
||||
`import { useTranslation } from "react-i18next";
|
||||
export function C(){ const { t } = useTranslation(); return <span>{t("doesNotExist:someth")}</span>; }`,
|
||||
);
|
||||
const d = findDanglingReferences(srcDir, { enDir });
|
||||
expect(d.some((x) => x.kind === "unknown-namespace")).toBe(true);
|
||||
});
|
||||
});
|
||||
112
ui/litellm-dashboard/tests/i18n/check-keys-lib.test.ts
Normal file
112
ui/litellm-dashboard/tests/i18n/check-keys-lib.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { describe, it, expect, afterAll } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
flattenDict,
|
||||
extractInterpolationVars,
|
||||
diffDicts,
|
||||
diffLocaleDirs,
|
||||
hasDiff,
|
||||
} from "../../scripts/i18n/check-keys-lib.mjs";
|
||||
|
||||
describe("flattenDict", () => {
|
||||
it("flattens nested objects with dotted paths", () => {
|
||||
const flat = flattenDict({ a: { b: { c: "x" } }, d: "y" });
|
||||
expect(flat.get("a.b.c")).toEqual({ value: "x", isLeaf: true });
|
||||
expect(flat.get("d")).toEqual({ value: "y", isLeaf: true });
|
||||
// 分支节点也被记录形状
|
||||
expect(flat.get("a")).toEqual({ value: undefined, isLeaf: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractInterpolationVars", () => {
|
||||
it("extracts {{var}} names", () => {
|
||||
expect([...extractInterpolationVars("Hello {{name}}, you have {{count}}")]).toEqual(["name", "count"]);
|
||||
});
|
||||
it("returns empty set for non-strings", () => {
|
||||
expect(extractInterpolationVars(42).size).toBe(0);
|
||||
expect(extractInterpolationVars("no placeholders").size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffDicts", () => {
|
||||
it("reports missing/extra/insert keys and interpolation mismatch", () => {
|
||||
const en = {
|
||||
title: "Hello",
|
||||
name: "Hi {{name}}",
|
||||
nested: { keep: "same", gone: "en only" },
|
||||
};
|
||||
const zh = {
|
||||
title: "你好",
|
||||
name: "你好 {{name}},{{count}}", // 插值变量不一致(多 count)
|
||||
nested: { keep: "相同", added: "zh only" },
|
||||
brandNew: "新增",
|
||||
};
|
||||
const d = diffDicts(en, zh);
|
||||
expect(d.extra.sort()).toEqual(["nested.gone"]); // en 有、zh 无
|
||||
expect(d.missing.sort()).toEqual(["brandNew", "nested.added"]); // zh 有、en 无
|
||||
expect(d.shapeMismatch).toEqual([]);
|
||||
expect(d.interpolationMismatch.map((x) => x.key)).toEqual(["name"]);
|
||||
});
|
||||
|
||||
it("reports shape mismatch when a leaf becomes an object", () => {
|
||||
const d = diffDicts({ key: "x" }, { key: { sub: "y" } });
|
||||
expect(d.shapeMismatch).toEqual(["key"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffLocaleDirs", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "check-keys-test-"));
|
||||
const enDir = join(tmp, "en");
|
||||
const zhDir = join(tmp, "zh-CN");
|
||||
mkdirSync(enDir);
|
||||
mkdirSync(zhDir);
|
||||
|
||||
const write = (dir: string, name: string, obj: unknown) =>
|
||||
writeFileSync(join(dir, `${name}.json`), JSON.stringify(obj));
|
||||
|
||||
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
it("returns no diff when key sets match", () => {
|
||||
write(enDir, "common", { ok: "OK", cancel: "Cancel", greeting: "Hi {{name}}" });
|
||||
write(zhDir, "common", { ok: "确定", cancel: "取消", greeting: "你好 {{name}}" });
|
||||
const d = diffLocaleDirs(enDir, zhDir);
|
||||
expect(hasDiff(d)).toBe(false);
|
||||
});
|
||||
|
||||
it("flags a namespace missing in zh (enOnly)", () => {
|
||||
// en 有 common,zh 没有
|
||||
write(enDir, "common", { ok: "OK" });
|
||||
write(enDir, "onlyEn", { x: "1" });
|
||||
write(zhDir, "common", { ok: "确定" });
|
||||
const d = diffLocaleDirs(enDir, zhDir);
|
||||
expect(d.enOnly).toContain("onlyEn");
|
||||
expect(hasDiff(d)).toBe(true);
|
||||
});
|
||||
|
||||
it("flags interpolation mismatch across dirs", () => {
|
||||
write(enDir, "common", { greeting: "Hi {{name}}" });
|
||||
write(zhDir, "common", { greeting: "你好 {{name}},{{count}}" });
|
||||
const d = diffLocaleDirs(enDir, zhDir);
|
||||
expect(d.namespaces.find((n) => n.namespace === "common").interpolationMismatch.length).toBe(1);
|
||||
expect(hasDiff(d)).toBe(true);
|
||||
});
|
||||
|
||||
it("flags a namespace only present in zh (zhOnly)", () => {
|
||||
write(enDir, "common", { a: "1" });
|
||||
write(zhDir, "common", { a: "1" });
|
||||
write(zhDir, "zhOnlyNs", { b: "2" });
|
||||
const d = diffLocaleDirs(enDir, zhDir);
|
||||
expect(d.zhOnly).toContain("zhOnlyNs");
|
||||
expect(hasDiff(d)).toBe(true);
|
||||
});
|
||||
|
||||
it("flags a missing key across dirs", () => {
|
||||
write(enDir, "common", { ok: "OK", cancel: "Cancel" });
|
||||
write(zhDir, "common", { ok: "确定" }); // 缺 cancel
|
||||
const d = diffLocaleDirs(enDir, zhDir);
|
||||
expect(d.namespaces.find((n) => n.namespace === "common").extra).toContain("cancel");
|
||||
expect(hasDiff(d)).toBe(true);
|
||||
});
|
||||
});
|
||||
100
ui/litellm-dashboard/tests/i18n/scan-hardcoded-lib.test.ts
Normal file
100
ui/litellm-dashboard/tests/i18n/scan-hardcoded-lib.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isLikelyCopy,
|
||||
findJsxText,
|
||||
findCopyAttributes,
|
||||
findMessageEntries,
|
||||
scanContent,
|
||||
} from "../../scripts/i18n/scan-hardcoded-lib.mjs";
|
||||
|
||||
describe("isLikelyCopy", () => {
|
||||
it("accepts user-facing phrases", () => {
|
||||
expect(isLikelyCopy("Save Changes")).toBe(true);
|
||||
expect(isLikelyCopy("Tenant not found")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects numbers/symbols, identifiers, urls, paths, placeholders", () => {
|
||||
expect(isLikelyCopy("123")).toBe(false);
|
||||
expect(isLikelyCopy("42,000")).toBe(false);
|
||||
expect(isLikelyCopy("gpt-4o")).toBe(false); // 模型名
|
||||
expect(isLikelyCopy("api_keys")).toBe(false); // API 字段
|
||||
expect(isLikelyCopy("bg-red-500")).toBe(false); // CSS 类
|
||||
expect(isLikelyCopy("https://example.com")).toBe(false);
|
||||
expect(isLikelyCopy("/api/v1/models")).toBe(false);
|
||||
expect(isLikelyCopy("Hello {{name}}")).toBe(false); // 插值占位
|
||||
expect(isLikelyCopy("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findJsxText", () => {
|
||||
it("extracts visible JSX text between tags", () => {
|
||||
const hits = findJsxText("<Button>Save Changes</Button>");
|
||||
expect(hits.map((h) => h.text)).toEqual(["Save Changes"]);
|
||||
});
|
||||
it("ignores empty and expression containers", () => {
|
||||
expect(findJsxText("<div>{value}</div>").map((h) => h.text)).toEqual([]);
|
||||
expect(findJsxText("<div></div>").map((h) => h.text)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findCopyAttributes", () => {
|
||||
it("extracts a11y/copy attribute literals", () => {
|
||||
const hits = findCopyAttributes(`aria-label="Close dialog" title="More info"`);
|
||||
expect(hits.map((h) => [h.attr, h.text])).toEqual([
|
||||
["aria-label", "Close dialog"],
|
||||
["title", "More info"],
|
||||
]);
|
||||
});
|
||||
it("does not match unrelated attributes", () => {
|
||||
expect(findCopyAttributes(`className="x" data-slot="y"`)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findMessageEntries", () => {
|
||||
it("finds toast/notify string literal args", () => {
|
||||
expect(findMessageEntries(`toast("Saved successfully")`).map((h) => [h.entry, h.text])).toEqual([
|
||||
["toast", "Saved successfully"],
|
||||
]);
|
||||
});
|
||||
it("skips non-string args", () => {
|
||||
expect(findMessageEntries(`toast(errorObj)`)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanContent", () => {
|
||||
const SAMPLE = `
|
||||
import { t } from "i18next";
|
||||
|
||||
export const label = "not-copy"; // identifier-style single token, excluded
|
||||
export const aria = 'aria-label="Close dialog"';
|
||||
function Comp() {
|
||||
return (
|
||||
<div>
|
||||
<button aria-label="Open settings">Open settings</button>
|
||||
<span>{t("common:already.translated")}</span>
|
||||
<span>Tenant unavailable</span>
|
||||
<div data-slot="wrapper">pure</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
toast("Tenant created");
|
||||
`;
|
||||
|
||||
it("finds jsx-text, attributes, and message entries; skips t()/identifiers/data-slot", () => {
|
||||
const hits = scanContent(SAMPLE, "/fake/Comp.tsx");
|
||||
const texts = hits.map((h) => h.text);
|
||||
expect(texts).toContain("Open settings"); // 属性(attr) 与 JSX 文本
|
||||
expect(texts).toContain("Tenant unavailable"); // JSX 文本
|
||||
expect(texts).toContain("Tenant created"); // toast 入口
|
||||
// t() 已国际化、单 token 标识符、data-slot 内部纯词不应被命中
|
||||
expect(texts).not.toContain("common:already.translated");
|
||||
expect(texts).not.toContain("not-copy");
|
||||
expect(texts).not.toContain("pure");
|
||||
// 每个候选都带 file/line/kind
|
||||
for (const h of hits) {
|
||||
expect(h.file).toBe("/fake/Comp.tsx");
|
||||
expect(typeof h.line).toBe("number");
|
||||
expect(typeof h.kind).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue