外观
在代码中使用国际化
字数
5196 字
预计阅读
22 分钟
功能上能做什么,见国际化。这一页讲写代码时怎么做。
一条规则贯穿始终:给用户看的文字都写成翻译键(比如 error.common.duplicate),中文和英文分别写在翻译文件里。.ts 和 .vue 文件里不能出现中文,pnpm verify 会检查。
前端页面里的写法(t()、tx()、切换语言)在权限与翻译中,这里只简单回顾,重点讲后端、共享包和数据库里的内容。
翻译文件放在哪里
| 目录 | 放什么 | 谁会用到 |
|---|---|---|
apps/web/src/locales/<语言>/ | 界面文字、菜单名称 menu.* | 前端 |
apps/server/src/i18n/<语言>/ | 错误信息 error.*、登录日志提示 signin.*、Excel 错误报告的表头 excel.* | 后端 |
packages/shared/src/i18n/<语言>/ | 校验提示 validation.*、字段名 field.*、种子数据的名称 seed.* | 前端和后端都用 |
mobile/src/locales/<语言>/ | 移动端的界面文字 | 移动端 |
<语言> 是 zh-CN 或 en-US。两种语言的文件和键必须一一对应。
文件名决定怎么合并
前端、后端和移动端加载翻译文件时,用的是同一个合并函数(packages/shared/src/common/locale.ts),检查脚本也按同样的规则合并:
ts
export function mergeLocaleFile(messages: Messages, path: string, content: Messages): Messages {
const name = (path.split(/[\\/]/).pop() ?? '').replace(/\.json$/, '')
return mergeMessages(messages, name.includes('.') ? content : { [name]: content })
}- 文件名不带点(
error.json、signin.json):文件名就是命名空间,文件里直接写下一级的键; - 文件名带点(
demo.book.json、messaging.mail.json):这是一个模块的"片段",文件里写完整的顶层结构,和其他文件深度合并。
所以一个模块可以把自己的翻译放进自己的文件,不用去改公共文件。比如邮件模块的错误信息就没有写进 error.json,而是单独一个文件:
jsonc
// apps/server/src/i18n/zh-CN/messaging.mail.json
{
"error": {
"messaging": {
"mail_host_refused": "不允许连接此邮件服务器",
"mail_password_required": "修改服务器地址、端口、用户名或加密方式时须重新输入密码"
}
}
}合并后的键是 error.messaging.mail_host_refused。
代码生成器也是这样做的:一个模块生成两个片段,字段名放在共享包,页面文字和菜单名放在前端:
jsonc
// packages/shared/src/i18n/zh-CN/modules/demo.book.json(节选)
{ "field": { "demo": { "book": { "isbn": "ISBN", "title": "书名", "price": "定价" } } } }jsonc
// apps/web/src/locales/zh-CN/demo.book.json
{
"demo": { "book": { "entity": "图书" } },
"menu": { "demo": { "book": "图书" } }
}同一个键只能定义一次
两个文件定义了同一个键,或者前端、后端的翻译和共享包里的重复,pnpm i18n:check 都会报错。
后端怎么加载
后端用 nestjs-i18n 翻译,占位符的写法和前端 vue-i18n 一样,都是 {name}。翻译文件的目录由 apps/server/src/core/paths.ts 决定:
ts
export const i18nDirs = (): string[] =>
process.env.I18N_DIR
? [fromCwd(process.env.I18N_DIR), fromCwd('../../packages/shared/src/i18n')]
: [fromCwd('dist/i18n')]- 开发和测试时(设置了
I18N_DIR=src/i18n):同时读src/i18n和共享包的packages/shared/src/i18n; - 构建后:
nest build按apps/server/nest-cli.json的配置,把这两处的 JSON 都复制到dist/i18n,运行时只读这一个目录。
apps/server/src/core/i18n/i18n.module.ts 把每种语言目录下的所有 JSON(包括 modules/ 这样的子目录)用上面的 mergeLocaleFile 合并起来。
前端在 apps/web/src/core/form/zod-rules.ts 里读取共享包的翻译(sharedMessages),apps/web/src/core/i18n/index.ts 以它为底,再合并前端自己的翻译文件,然后创建 vue-i18n。所以前端可以直接用 t('field.demo.book.title')、tx('seed.role.root')。
每个请求用哪种语言
apps/server/src/core/i18n/locale.ts:
ts
/**
* Response language (PLAN §3.4): `?lang` → `Accept-Language` → the signed-in user's locale (CLS
* principal, set by the auth guard) → zh-CN.
*/
export const resolveLocale = (req: Req): Locale =>
requestLocale(req) ?? clsGet('principal')?.locale ?? DEFAULT_LOCALE
/** Same chain from the request context (CLS `locale` = the request's explicit choice), for non-HTTP callers. */
export const currentLocale = (): Locale =>
clsGet('locale') ?? clsGet('principal')?.locale ?? DEFAULT_LOCALE顺序是:?lang= 参数 → Accept-Language 请求头 → 登录用户保存的语言(iam_user.locale)→ zh-CN。不支持的语言会被跳过;匹配时先比完整的语言代码,再只比语言部分,所以 en-GB 会匹配到 en-US。
在服务里要知道当前语言,调用 currentLocale(),不需要从控制器传参数。它从"请求上下文"(CLS,每个请求自己的一块存储)里读取。在定时任务这类不属于任何请求的代码里,它返回 zh-CN。
前端的每个请求都带着语言和时区(apps/web/src/core/request/http.ts):
ts
config.headers['Accept-Language'] = currentLocale()
config.headers['X-Timezone'] = Intl.DateTimeFormat().resolvedOptions().timeZone后端需要按时区格式化时间时(比如 Excel),用同一个文件里的 currentTimezone(params):先取请求头 X-Timezone,再取参数 core.default_timezone,最后是 Asia/Shanghai。
错误信息
抛出错误时只带错误码和参数,不写文字:
ts
// apps/server/src/core/excel/excel.service.ts(节选)
if (++valued > maxRows + 1) throw new BizError(Err.EXCEL_TOO_MANY_ROWS, { max: maxRows })翻译里用占位符接收参数:"too_many_rows": "数据行数超过上限 {max} 行"。
全局的异常过滤器(apps/server/src/core/http/http-error.filter.ts)按请求的语言,把错误码对应的翻译键翻译成 msg 返回。怎么新增一个错误码和它的中英文信息,见异常处理。
参数里的种子名称会自动翻译
参数的值如果是以 seed. 开头的字符串,过滤器会先把它翻译成请求的语言:
ts
// apps/server/src/core/http/http-error.filter.ts(节选)
const args =
params &&
Object.fromEntries(
Object.entries(params).map(([k, v]) => [
k,
typeof v === 'string' && v.startsWith('seed.') ? t(v) : v,
]),
)比如工作流找不到审批人时(apps/server/src/modules/workflow/engine/advance.ts):
ts
if (!other.length) throw new BizError(Err.WF_NO_ASSIGNEE, { node: node.name })内置流程的节点名是 seed.wf.node.* 这样的键,返回给用户的仍然是当前语言的节点名;管理员自己起的节点名是普通文字,原样放进提示里。
有测试帮你检查
apps/server/test/e2e/core-envelope.e2e-spec.ts 会检查 Err 里每一个错误码在 zh-CN 和 en-US 中都有错误信息(包括写在模块片段文件里的 error 部分)。
在服务里翻译
需要在后端自己翻译一段文字时,注入 nestjs-i18n 的 I18nService,用 currentLocale() 指定语言。比如导出用户、生成用户导入模板时,部门要显示成"总部 / 研发中心"这样的路径,内置部门的名称要先翻译(apps/server/src/modules/platform/iam/user/user.service.ts,节选):
ts
import { I18nService } from 'nestjs-i18n'
import { currentLocale } from '../../../../core/i18n/locale.js'
constructor(private readonly i18n: I18nService /* , … */) {}
const lang = currentLocale()
const text = (name: string) =>
name.startsWith('seed.') ? String(this.i18n.translate(name, { lang })) : name带参数时传 args:this.i18n.translate('signin.locked', { lang, args: { minutes: 15 } })。
- 找不到这个键时,
translate返回键本身。异常过滤器正是利用这一点判断字段名的翻译存不存在; translate返回的类型比较宽,用String(...)转成字符串。
存键,读的时候再翻译
写进数据库、以后要给人看的提示文字,不要存翻译好的文字,而是存键和参数。这样谁来看,就按谁的语言显示。登录日志就是这样做的:
ts
// apps/server/src/core/auth/auth.service.ts(节选):写入时只存键和参数
const locked = {
userId: cred?.userId ?? null,
ok: false,
msgKey: 'signin.locked',
msgParams: { minutes: security.lockMinutes },
}ts
// apps/server/src/modules/platform/audit/signin-log/signin-log.service.ts(节选):读取时按查看人的语言翻译
private withMsg(row: SigninLog): SigninLogRow {
const args = (row.msgParams ?? undefined) as Record<string, unknown> | undefined
const msg = row.msgKey && this.i18n.translate(row.msgKey, { lang: currentLocale(), args })
return Object.assign(row, { msg: msg ? String(msg) : null })
}翻译写在 apps/server/src/i18n/<语言>/signin.json 中:"locked": "登录失败次数过多,该用户名在此 IP 锁定 {minutes} 分钟"。
校验提示和字段名
zod 规则里不写提示文字,错误会被统一转换成 validation.* 翻译键;提示里的字段名来自 field.<领域>.<属性>。这两类翻译都放在共享包里,前端表单和后端的 400 响应用同一份。详见参数校验 · 错误提示的翻译。
数据库里的内容
数据库里的文字有两种多语言的方式:
| 方式 | 用在哪里 | 数据库里存的是 |
|---|---|---|
| 存翻译键 | 内置菜单、种子数据的名称 | menu.iam.user、seed.role.root |
*_i18n JSON 列 | 菜单 name_i18n、字典 name_i18n、字典项 label_i18n、参数 name_i18n | {"zh-CN": "男", "en-US": "Male"} |
管理员在后台新建的普通数据(角色名、部门名……)就是普通文字,不做多语言。
*_i18n 列
类型和规则都在共享包里:
ts
// packages/shared/src/common/locale.ts
/** Per-locale text stored in `*_i18n` JSON columns (dict labels, admin-created menu names, …). */
export type I18nText = Partial<Record<Locale, string>>ts
// packages/shared/src/common/crud.ts
/** Per-locale texts as stored in a `*_i18n` JSON column (PLAN §3.9 mode B); null = none. */
export const i18nTextVo = z.partialRecord(z.enum(LOCALES), z.string()).nullable()
export const i18nTextInput = (max: number) => {
const text = z.string().trim().max(max).optional()
return z.preprocess(
(v) => {
if (!v || typeof v !== 'object' || Array.isArray(v)) return v
const set = Object.entries(v).filter(([, s]) => typeof s !== 'string' || s.trim())
return set.length ? Object.fromEntries(set) : null
},
z.strictObject({ 'zh-CN': text, 'en-US': text }).nullish(),
)
}- 请求体里用
i18nTextInput(最大长度):空着的语言会被去掉,一种都没填时存null; - 返回给前端的数据结构里用
i18nTextVo。
比如菜单的规则(packages/shared/src/platform/iam/menu.schema.ts):
ts
name: z.string().trim().min(1).max(128),
/** per-locale names; blank ones are left out, none at all = null */
nameI18n: i18nTextInput(128),前端输入用 I18nInput 组件,每种语言一个输入框(它的注释里的用法):
vue
<I18nInput v-model="model.nameI18n" :label="t('field.settings.dict.nameI18n')" :maxlength="100" />前端显示用 localized(多语言文字, 备用文字)(apps/web/src/core/i18n/index.ts):
ts
/** Per-locale DB text (`name_i18n` / `label_i18n`) first, then `tx(fallback)`. */
export const localized = (text: I18nText | null | undefined, fallback: string) =>
text?.[currentLocale()] || tx(fallback)侧边栏、标签页、字典标签都用它:先取当前语言的文字,没有时把备用文字交给 tx(),它是翻译键就翻译,不是就原样显示。
vue
<!-- apps/web/src/views/platform/settings/param/index.vue -->
<template #cell-name="{ row }">{{ localized(row.nameI18n, row.name) }}</template>后端读取时自己取当前语言,比如 Excel 导出字典列(apps/server/src/core/excel/excel.service.ts):
ts
/** [value, label in the request language] of a dict's enabled entries. */
private async dictLabels(code: string): Promise<[string, string][]> {
const lang = currentLocale()
return ((await this.dicts.entries(code))?.entries ?? []).map((e) => [
e.value,
e.labelI18n?.[lang] ?? e.label ?? e.value,
])
}用种子预置这些数据时,写 nameI18n / labelI18n,两种语言都要填:字典见字典 · 定义字典,参数见参数设置 · 新增参数。
种子数据的名称
角色、部门、岗位、流程模型和节点、消息模板、定时任务、存储配置等种子数据,名称写成 seed.* 键,翻译放在 packages/shared/src/i18n/<语言>/seed.json:
jsonc
// packages/shared/src/i18n/zh-CN/seed.json(节选)
{
"role": { "root": "超级管理员", "demo": "演示角色", "member": "注册成员", "staff": "员工" },
"position": { "engLead": "技术负责人", "developer": "开发工程师" }
}文件名不带点,所以键是 seed.role.root、seed.position.developer。种子的写法和前端用 tx() 显示,见种子与菜单 · 种子中的名称和权限与翻译。
还有三件事已经处理好了:
按名称搜索:数据库里存的是键,用户输入的是看到的文字。列表的查询条件要同时匹配"任一语言的译文包含这个词"的种子键(apps/server/src/modules/platform/iam/position/position.service.ts):
ts
import { seedKeysLike } from '../../../../core/i18n/seed-names.js'
if (name) {
// seeded names are keys (seed.position.*): also match the text users see, in any language
const seeded = seedKeysLike('seed.position.', name)
qb.andWhere(
seeded.length ? '(t.name LIKE :name OR t.name IN (:...seeded))' : 't.name LIKE :name',
{ name: contains(name), seeded },
)
}你的模块如果也有种子名称,照这个写法,前缀换成自己的 seed.<分组>.。
编辑表单:useCrudForm 加载数据时,把 seed.* 键换成当前语言的文字显示;保存时如果文字没改,再换回原来的键,所以名称继续跟着语言走。
导出和通知:Excel 的列加上 seedName: true,导出时会翻译种子名称(见Excel 导入导出 · 定义列);通知参数写成 { i18n: 'seed.wf.leave' },按收件人的语言翻译(见消息通知)。
菜单名称
种子里的菜单名称写 menu.* 键,翻译放在前端的 apps/web/src/locales/<语言>/menu.json 或模块的片段文件里,见种子与菜单。管理员在后台新建的菜单用 name_i18n 列。
前端显示菜单名称一律用 localized(node.nameI18n, node.name)。
Excel
ExcelColumn 的 label 是翻译键(一般就是字段名 field.*),导出和导入模板按请求的语言输出表头。导入时,表头按 prop 或任一语言的翻译匹配(apps/server/src/core/excel/excel.service.ts):
ts
for (const c of columns.filter(importable))
for (const text of [c.prop, ...LOCALES.map((lang) => this.t(c.label, lang))])
byHeader.set(text.toLowerCase(), c)列的完整说明见 Excel 导入导出。
消息模板
模板存在数据库里,同一个编码按语言各存一行:未删除的模板中,(code, locale) 不能重复(唯一索引是 (code, locale, alive),删除后可以用同一编码和语言重新创建)。用种子预置模板时调用 upsertTemplates,每种语言都要给出内容,见消息通知 · 用种子预置模板。
发送时给每个收件人选模板(apps/server/src/core/notify/notify.ts):
ts
const template =
rows.find((row) => row.locale === recipient.locale) ??
rows.find((row) => row.locale === DEFAULT_LOCALE) ??
rows[0]!收件人的语言来自 iam_user.locale,没保存过时是 zh-CN;收件人只是一个邮箱或手机号时,用当前请求的语言。
前端回顾
| 要做的事 | 写法 | 详见 |
|---|---|---|
| 组件里翻译 | const { t } = useI18n(),t('crud.action.save') | 权限与翻译 |
| 组件外翻译 | i18n.global.t(...)(从 @/core/i18n 导入) | 同上 |
| 可能是键、也可能是普通文字 | tx(row.name) | 同上 |
*_i18n 文字 | localized(row.nameI18n, row.name) | 本页 |
| 当前语言 | currentLocale()(从 @/core/i18n 导入) | — |
| 切换语言 | useLocaleStore().set('en-US') | 权限与翻译 |
需要后端翻译的内容,切换语言后要重新加载
前端翻译的文字切换语言后会自动更新。但后端已经翻译好再返回的内容不会自己变,所以 流程审批 下的待办、已办等列表(center/use-center-list.ts)、审批详情(center/detail.vue)和 流程管理 → 审批数据(admin/data.vue)监听了语言变化,重新请求一次:
ts
// apps/web/src/views/workflow/center/use-center-list.ts
watch(currentLocale, () => void list.refresh())自动检查:pnpm i18n:check
scripts/i18n-check.mjs,是 pnpm verify 的一部分。它检查五条规则:
| 规则 | 检查什么 |
|---|---|
| 1 两种语言对应 | 前端、后端、共享包、移动端四处翻译目录,每个文件在另一种语言里都有,键完全一样;一个键不能在两个文件里定义;前端和后端的键不能和共享包重复 |
| 2 用到的键存在 | 代码里 t('…')、$t('…')、tx('…')、i18n.global.t('…')、this.i18n.t('…') 的键在两种语言里都存在;共享包里出现的 'validation.…'、'field.…' 字符串也一样 |
| 3 没有中文 | .ts 和 .vue 文件中(注释除外)不能有中文和全角标点(,、(、「 也算) |
| 4 待翻译 | 英文中值为空字符串的键,以及翻译目录根下 __todo.json(比如 apps/web/src/locales/__todo.json)列出的键,统计为"待翻译"。默认只打印数量;I18N_TODO_STRICT=1 pnpm i18n:check 时报错 |
| 5 种子 | 种子文件里出现的 'seed.…'、'menu.…' 字符串必须有翻译;labelI18n、nameI18n、*_i18n 写出的对象,每种语言都要有非空的文字;消息模板必须通过 upsertTemplates 写入,每种语言的内容不能为空(站内信 title、body,邮件 subject、body,短信 body),模板名称必须是 seed.* 键 |
规则 2 和规则 3 不检查这些地方:名为 locales 或 i18n 的目录(包括 core/i18n/ 这样的代码目录)、种子 db/seeds/、迁移 db/migrations/、测试文件。
检查不到的写法
- 键是拼出来的,比如
t(`wf.status.${s}`),规则 2 跳过它。这种写法要自己确认每个可能的键都存在; - 后端的
this.i18n.translate('…')不在规则 2 的范围内,写完要自己核对键名; - 检查用正则实现,去掉注释的扫描器认识三种引号的字符串,但不认识正则字面量和嵌套的模板字符串,遇到这两种写法时结果可能不准。
改了检查脚本,先跑它的自测:node scripts/i18n-check.mjs --self-test。
增加一种语言
以日文 ja-JP 为例,从里到外改:
1. 语言列表:packages/shared/src/common/locale.ts
ts
export const LOCALES = ['zh-CN', 'en-US', 'ja-JP'] as const后端的语言匹配、Accept-Language 解析、模板语言的校验、代码生成器生成的翻译文件,都按这个列表走。
2. 翻译文件:把下面四个目录里的 en-US 各复制一份为 ja-JP 并翻译:
apps/web/src/locales/apps/server/src/i18n/packages/shared/src/i18n/mobile/src/locales/(使用移动端时)
再在 common.language 下加上语言名称(比如 "jaJP": "日本語")。
3. 运行 pnpm -r typecheck:很多映射表的类型是 Record<Locale, …>,少了新语言会报类型错误,按提示补上:
| 文件 | 要补什么 |
|---|---|
apps/web/src/core/i18n/index.ts | Element Plus 语言包 EP、dayjs DAYJS(还要 import 'dayjs/locale/ja')、ECharts ECHARTS、form-create FORM_CREATE |
apps/web/src/core/composables/use-cron.ts | cronstrue 的语言 CRONSTRUE |
LocaleSwitch.vue、I18nInput.vue、views/profile/index.vue、views/platform/codegen/edit.vue | 语言名称 LANGUAGE |
apps/server/src/modules/workflow/admin/wf-data.service.ts | form-create 的语言 FORM_LOCALE |
mobile/src/core/i18n.ts | uni-app 的语言 UNI、语言名称 LANGUAGE_KEY(移动端用 pnpm mobile:verify 检查) |
| 种子里的字典、参数、消息模板 | 字典和参数种子的 nameI18n、labelI18n 类型是 Record<Locale, string>,upsertTemplates 的内容也是每种语言一份,都要补上新语言 |
ECharts 默认只注册了中文和英文(ZH、EN),其他语言要自己注册。
4. 写死了两种语言的地方:类型检查发现不了,要手动改:
| 文件 | 现状 |
|---|---|
packages/shared/src/common/crud.ts | i18nTextInput 用 z.strictObject({ 'zh-CN', 'en-US' }),不改的话带新语言的请求会被拒绝(400) |
packages/shared/src/platform/iam/menu.schema.ts | 菜单返回数据里的 nameI18n 只列了两种语言 |
apps/web/src/views/platform/iam/menu/form.vue | "中文名称""英文名称"两个输入框,以及字段名 field.iam.menu.zh-CN / en-US |
apps/web/src/core/components/RichEditor.vue | 富文本编辑器只区分中文和英文,其他语言显示英文 |
apps/web/src/views/platform/messaging/inbox-template/form.vue | "添加翻译"在中英文之间切换 |
apps/server/src/db/seeds/workflow/process-templates.seed.ts | 内置流程模板的表单文字是 Texts([中文, 英文] 两个元素),option() 只写出 form-create 的 zh-cn 和 en 两种文字(option.language),规则 5 不检查这里。不补的话,新语言下表单标题是空的,审批数据页显示文字 id |
scripts/i18n-check.mjs | LANGS 列表 |
mobile/src/core/i18n.ts | detectLocale() 只识别英文系统;setLocale() 只在 en-US 时把语言包交给 wot-ui,其他语言下 wot-ui 组件自己的文字会显示成键名(要从 @wot-ui/ui/locale/lang/ 导入对应的语言包,比如 ja-JP) |
5. 数据库内容:
- 在
apps/server/src/db/seeds/settings/settings.seed.ts的字典core.locale中加一项,消息模板的"语言"下拉框用的就是它; - 改了检查脚本的
LANGS之后,规则 5 也会检查种子里的每个labelI18n、nameI18n和消息模板都写了新语言; - 已经部署的环境,重新执行
pnpm db:seed:已有的字典和字典项只补上缺少的语言,已有的中英文以数据库为准,管理员改过的文字不受影响(见字典 · 重新执行种子);参数的多语言名称每次都会按种子更新;upsertTemplates按(编码, 语言)查找,新语言的模板还不存在,所以会插入一行,已有的中英文模板不受影响; - 内置流程模板只在第一次执行种子时写入(模型编码或表单名已存在就跳过),已经部署的环境重新执行
pnpm db:seed不会给它们补上新语言,要在表单设计器里补; - 管理员自己新建的消息模板、菜单、字典,需要在后台补上新语言。
6. 最后:pnpm verify(使用移动端时再加 pnpm mobile:verify)全部通过。