来源：https://basicapp.docs.xihanfun.com/frontend/i18n

# 国际化

前端文案用 vue-i18n，**枚举标签的事实源在后端**，时间显示由请求头驱动。三块各有各的规则。

## 语言包

vue-i18n（`legacy: false`），中英双包按模块拆文件，分住两处：`packages/locales/` 只放 shell 自身的命名空间，业务命名空间在 `src/locales/`，启动时由 `registerLocaleMessages()` 合并进同一个 i18n 实例（`setupI18n()` 之后、`app.mount()` 之前调用）。

```text
packages/locales/langs/zh-CN/    # shell 命名空间
├── common.ts        # 通用（按钮、字段、提示）
├── component.ts     # 公共组件文案
├── menu.ts          # 菜单标题
├── header.ts        # 顶栏
├── page.ts          # 页面壳
└── …

src/locales/langs/zh-CN/         # 业务命名空间
├── identity.ts      # 身份权限模块
├── log.ts           # 日志模块
├── message.ts       # 消息模块
└── …
```

键名约定 **`模块.实体.字段或动作`**：

```ts
// src/locales/langs/zh-CN/identity.ts
export default {
  user: {
    page_name: '用户管理',
    col_status: '状态',
    action_reset_password: '重置密码',
  },
}
// 使用：t('identity.user.col_status')
```

切换语言：`useLocale().setLocale('en-US')`。

## 菜单文案的键怎么来

后端 `PageDescriptor.I18nKey` 规则是 **`menu.{页面码中的 . 与 - 换成 _}`**：

| 页面码 | 后端 `I18nKey` | `menu.ts` 里的键 |
| --- | --- | --- |
| `identity.position` | `menu.identity_position` | `identity_position` |
| `log.permission-change` | `menu.log_permission_change` | `log_permission_change` |

缺文案时侧边栏会**直接显示键名**（如 `menu.identity_position`）——看到这个就是漏了这一步。

## 组件库内置文案

日期选择器的星期、分页与下拉的读屏名这类 XiHan.UI 内置文案随应用 locale 切换：`App.vue` 里 `provideXhConfig(useXhUiConfig())` 把语言标记与内建文案交给组件库。文案直接取组件库的内建语言包（`@xihan-ui/vue/locale`），应用语言与语言包的对应表在 `packages/locales/xihan-ui.ts` 的 `xhLocales`；组件库没有语言包的应用语言（目前是 `hi-IN`）取英文语言包，语言标记仍是应用语言，日期时间照常按该语言排。

要带业务语义的个别文案（如穿梭框箭头钮要念出目标栏标题）在组件实例上经 `translations` 覆盖，不改全局表。

加新语言时**别忘了在 `xhLocales` 登记对应的语言包**，否则业务文案切了、组件文案还停在英文；`xh-config.test.ts` 会把漏登记的语言报出来。

## 裸 `@` 会白屏

::: danger
语言包文案里出现**裸 `@`**（如 `联系 @admin`）会触发 vue-i18n 的 linked message 语法，抛 `Invalid linked format` 导致**整页白屏**。

必须转义成 `{'@'}`：

```ts
// ❌ 白屏
contact: '联系 @admin'
// ✅
contact: "联系 {'@'}admin"
```

邮箱、社交账号、装饰性符号都是高发区。新增文案前扫一遍。
:::

## 枚举标签：后端单一事实源

枚举标签**不在前端语言包里维护**——事实源是后端枚举元数据（`Enums.{culture}.json` 全量），按请求头 `X-Language` 返回当前语言的标签。

前端有三条取值路径：

| 场景 | 用法 |
| --- | --- |
| **Schema 页字段** | 字段声明 `dictionaryCode`（枚举名或字典码），`useSchemaDictionaries` 批量拉取注入 `field.options`；单元格按值映射 label，搜索区自动渲染下拉 |
| **非 Schema 的下拉/标签** | `useEnumOptions(enumName, fallback)`（`~/hooks`），返回随语言/数据响应式更新的 `computed` |
| **静态兜底** | 元数据为空（未加载/未部署）时回退传入的 `fallback` 静态选项，**保证绝不出现空下拉** |

### 切语言要响应式

拉取由全局 `useEnumService` **并发去重**，切 locale 时整库重取一次。`useSchemaDictionaries` / `useEnumOptions` 只读响应式状态——**免刷新即随语言更新**，各下拉不各自监听 locale，避免重复请求。

::: warning 两个易错点
1. **`business.ts` 里的 `*_OPTIONS` 常量是写死中文的，仅作兜底**，不要当成事实源使用。
2. **选项的 `value` 要用枚举成员名，不是整数**。后端实例数据经 `JsonStringEnumConverter` 序列化为成员名字符串（如 `"Enabled"`），只有用成员名才能与表格行数据匹配；整数值放在 `valueText` 备用。
:::

## 时区

- 前端在请求拦截器里发 **`X-Timezone`** 头（用户已选时区优先，否则跟随浏览器 `Intl`）。
- 后端**存储恒 UTC**，输出时按该头换算。
- 用户可在偏好里选时区。

「时间显示差几小时」的排查顺序：请求有没有带这个头 → 用户选的时区对不对 → 后端存的是不是 UTC。

## 加一门新语言

| 步骤 | 做什么 |
| --- | --- |
| 1 | `packages/locales/langs/` 与 `src/locales/langs/` 下各加一套语言目录，按模块补齐文案；插件模块在 `src/modules/<模块>/locales/<locale>.ts` 各补一份 |
| 2 | 在 `packages/constants/app.ts` 的 `SUPPORTED_LOCALES`、`packages/locales/index.ts` 的 `messages` 与 `src/locales/index.ts` 里登记新语言，并在 `LocaleSwitcher.vue` 的 `LOCALES`、`LocaleFlag.vue` 的国旗、`packages/locales/xihan-ui.ts` 的 `xhLocales`（组件库有该语言包时）各加一项 |
| 3 | **后端**补一份 `Enums.{culture}.json` 与业务资源文件（`Localization/ApiResponse|Enums|Errors.{culture}.json`） |
| 4 | **后端每个环境**的 `XiHan:Localization:SupportedCultures` 加上该文化——文化中间件只接受列表内的 `X-Language`，不在列内一律回退默认文化；`appsettings.Production.json` 不入库，要在服务器上单独改 |

第 3、4 步最容易漏——前端切了语言，接口返回的 `message` 和枚举标签却没变，要么是后端资源没补，要么是生产环境的 `SupportedCultures` 没登记（de-DE 上线时就漏过一次）。

## 相关页面

- [布局与主题](./theme)：外观定制
- [Schema 驱动页面](./schema-page#字段-schema-listfieldschema)：`dictionaryCode` 用法
- [接口对接指南](../api-guide#json-序列化约定)：枚举与时间的序列化规则
- [常见问题](../faq)：白屏排查
