国际化与本地化
让 Agent 用 ICU MessageFormat、RTL 布局与区域格式处理文案,约定键名与回退链,避免拼接式翻译与硬编码语种。
本页为 Agent 提供国际化完整实施参考:i18next/vue-i18n 完整配置、英文/中文/阿拉伯语复数规则处理、Intl API 日期数字货币格式化、i18next-scanner 提取工具配置,以及 RTL 支持的 CSS 逻辑属性对比。
键名与默认语言文件结构在 SKILL 中约定;禁止散落硬编码字符串;动态插值使用命名占位符({name})以便译者重排语序;复数用库内置 plural categories。
- 语言切换持久化与 SEO hreflang 策略分开说明。
- 图片与法律文案若不可译需列例外与责任方。
- 翻译记忆与术语表版本与代码分支对齐。
概述与原则
对 Agent:先读仓库约定的资源目录(如 locales/、messages/)与默认语言,再改文案或补键;禁止把可见字符串写回组件而不走资源层。
- 键名稳定、语义化,与路由/特性域对齐,避免把英文句子当键。
- 同一语义在不同界面复用同一键,减少 TM 碎片与不一致。
- CI:缺失键、占位符不一致、非法 BCP 47 标签应可自动失败。
i18next/vue-i18n 配置与复数规则
i18next 完整配置:
// i18n/index.ts
import i18next from 'i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import HttpBackend from 'i18next-http-backend'
i18next
.use(HttpBackend)
.use(LanguageDetector)
.init({
fallbackLng: 'en',
supportedLngs: ['en', 'zh', 'ar'],
ns: ['common', 'errors', 'dashboard'],
defaultNS: 'common',
backend: { loadPath: '/locales/{{lng}}/{{ns}}.json' },
detection: { order: ['localStorage', 'navigator'], caches: ['localStorage'] },
interpolation: { escapeValue: false }, // React/Vue 自行转义
})
复数规则处理(英文/中文/阿拉伯语的不同规则):
// locales/en/common.json — 英文:one / other
{
"message_count": "{{count}} message",
"message_count_plural": "{{count}} messages"
}
// locales/zh/common.json — 中文:无复数形式(只有 other)
{
"message_count": "{{count}} 条消息",
"message_count_plural": "{{count}} 条消息"
}
// locales/ar/common.json — 阿拉伯语:zero/one/two/few/many/other
{
"message_count_zero": "لا رسائل",
"message_count_one": "رسالة واحدة",
"message_count_two": "رسالتان",
"message_count_few": "{{count}} رسائل",
"message_count_many": "{{count}} رسالة",
"message_count_other": "{{count}} رسالة"
}
// 使用(i18next 自动按 locale 选分支)
t('message_count', { count: 5 }) // → "5 messages"
Intl API 日期/数字/货币格式化:
// utils/formatters.ts
export function formatDate(date: Date, locale: string): string {
return new Intl.DateTimeFormat(locale, {
year: 'numeric', month: 'long', day: 'numeric'
}).format(date)
// zh-CN: "2026年4月11日" | en-US: "April 11, 2026"
}
export function formatCurrency(amount: number, locale: string, currency: string): string {
return new Intl.NumberFormat(locale, {
style: 'currency', currency,
minimumFractionDigits: 2,
}).format(amount)
// zh-CN + CNY: "¥1,234.56" | en-US + USD: "$1,234.56"
// ar-SA + SAR: "١٬٢٣٤٫٥٦ ر.س."
}
export function formatRelativeTime(diffMs: number, locale: string): string {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
const diffSec = Math.round(diffMs / 1000)
if (Math.abs(diffSec) < 60) return rtf.format(diffSec, 'second')
const diffMin = Math.round(diffSec / 60)
if (Math.abs(diffMin) < 60) return rtf.format(diffMin, 'minute')
return rtf.format(Math.round(diffMin / 60), 'hour')
}
i18next-scanner 配置文件(自动提取翻译键):
// i18next-scanner.config.js
module.exports = {
input: ['src/**/*.{ts,tsx,vue}'],
output: './',
options: {
func: { list: ['t', 'i18next.t', '$t'], extensions: ['.ts', '.tsx', '.vue'] },
lngs: ['en', 'zh', 'ar'],
ns: ['common', 'errors', 'dashboard'],
defaultLng: 'en',
defaultNS: 'common',
resource: {
loadPath: 'public/locales/{{lng}}/{{ns}}.json',
savePath: 'public/locales/{{lng}}/{{ns}}.json',
},
defaultValue: '__STRING_NOT_TRANSLATED__',
keySeparator: '.', nsSeparator: ':',
},
}
// 运行:npx i18next-scanner
RTL 支持:CSS 逻辑属性 vs 物理属性
CSS 逻辑属性(推荐)vs 物理属性(避免):
/* ❌ 物理属性:RTL 下方向错误,需额外覆盖 */
.card {
margin-left: 1rem;
padding-right: 0.5rem;
border-left: 2px solid blue;
text-align: left;
}
[dir="rtl"] .card { /* 需要额外写 RTL 覆盖 */
margin-left: 0;
margin-right: 1rem;
padding-right: 0;
padding-left: 0.5rem;
border-left: none;
border-right: 2px solid blue;
text-align: right;
}
/* ✅ 逻辑属性:自动适应 dir="rtl",无需覆盖 */
.card {
margin-inline-start: 1rem; /* LTR = left, RTL = right */
padding-inline-end: 0.5rem; /* LTR = right, RTL = left */
border-inline-start: 2px solid blue;
text-align: start; /* 随书写方向自动对齐 */
}
/* RTL 完整对照表 */
/* margin-left → margin-inline-start */
/* margin-right → margin-inline-end */
/* padding-left → padding-inline-start */
/* padding-right → padding-inline-end */
/* left / right → inset-inline-start / inset-inline-end */
/* border-left → border-inline-start */
/* float: left → float: inline-start */
HTML dir 属性与动态切换:
// 动态设置文档方向
const RTL_LANGS = ['ar', 'he', 'fa', 'ur']
function applyLocale(locale: string) {
const isRtl = RTL_LANGS.some(l => locale.startsWith(l))
document.documentElement.setAttribute('dir', isRtl ? 'rtl' : 'ltr')
document.documentElement.setAttribute('lang', locale)
}
// 禁止自动镜像的例外(如播放控件、进度条)
// 用 [dir="rtl"] .no-flip { transform: scaleX(-1) scaleX(-1) = 不翻转 }
// 或直接 writing-mode 控制
.playback-icon { unicode-bidi: normal; }
- 混合方向字符串用
unicode-bidi: isolate或<bdi>包裹数字/用户名。 - 表单、时间轴、图表坐标轴在 RTL 下需单独验收。
回退链与缺失键
约定显式顺序,例如 zh-TW → zh → en 或 pt-BR → pt → en;开发环境可警告、生产环境可记录遥测,避免静默显示键名。
- 缺失键:构建或运行时列出键路径;禁止把英文默认值藏在组件里作为「隐形回退」。
- 部分更新:新键先合入默认语言再发翻译包,避免其它语种空文件被误当成「已翻译」。
- 服务端渲染:首屏 locale 与客户端 hydration 一致,防止闪语或 SEO 错语。
fallbackLocale / 等价配置名称对齐。
抽取与校验流程
将「读约定 → 改资源 → 占位符/键检查 → 伪本地化或 CI」写进 SKILL,便于模型按顺序执行。
[ 读 SKILL:键命名、目录、默认语言、回退链 ]
│
▼
[ 扫描:硬编码可见串 / 未走 i18n API ]
│
▼
[ 抽取或补键;ICU 表达复数与格式 ]
│
┌────────┴────────┐
▼ ▼
[ RTL:dir + 逻辑属性 ] [ 区域:日期货币时区一致 ]
│ │
└────────┬──────────┘
▼
[ 占位符集合:基准语言 vs 译文对齐 ]
│
▼
[ CI / 本地:缺失键、伪本地化、截断 ]
│
┌────────┴────────┐
▼ ▼
[ 通过 ] [ 修键或修译文再跑 ]
键名与占位符小工具
下方为轻量校验:键名建议为点分小写段(可含数字);占位符比对简单 ICU 风格命名占位 {name}(不解析完整 plural 块)。仅作辅助,以项目真实 linter 为准。
SKILL 片段
可直接复制为技能正文骨架,再替换为仓库真实路径与脚本。
---
name: i18n-l10n-cn
description: 抽取文案、ICU 复数与区域格式,校验键名、占位符与回退链
---
# 规则
- 禁止硬编码用户可见字符串;全部通过 t(key) / $t(key) 调用
- 键名点分小写,按功能域分 namespace(common/errors/dashboard)
- 复数用库 plural categories;禁止代码里写 if (n === 1)
- 货币/日期/数字用 Intl API 或库内置格式化方法
- RTL 布局用 CSS 逻辑属性(margin-inline-start 等),不写 dir 覆盖
- 回退链:zh-TW → zh → en;缺失键在开发环境警告,生产记遥测
# 步骤
1. 读 SKILL:键命名约定、namespace 目录、默认语言、回退链配置
2. 运行 i18next-scanner 扫描未抽取字符串
3. 提取/补键到 locales/{lng}/{ns}.json
4. 复数:按 CLDR plural rules 写 _zero/_one/_two/_few/_many/_other 变体
5. 日期/货币:用 Intl.DateTimeFormat / Intl.NumberFormat 替换硬编码格式
6. RTL:将 margin-left/right 改为 margin-inline-start/end;验证 dir=rtl 布局
7. CI:运行占位符一致性检查,阻断缺失键合并