Skip to content

🌐 多语言系统

SunAdmin 多语言能力把"原文翻译"和"业务实体翻译"两条链路拆开维护:UI 文案统一走 language_texts + language_translations 通用表,菜单 / 字典等强 ID 绑定的业务数据走各自的翻译子表。前端用 $t('保存') 写中文当 key,找不到译文时天然回退原文。整个系统由 configs.basics.language 单一开关托管,关闭时零开销。

🎯 本页目标

读完本章节后,应能掌握:

  • 项目的多语言由哪些表 / 接口 / 缓存层支撑,开 / 关如何切换。
  • 后端代码里如何用 __t() / __tIn() 翻译文案、如何拿到当前请求的语言。
  • 前端 Admin / uni-app 里 $t() 怎么用、X-Language 头怎么传、缓存策略是怎样的。
  • 菜单、面包屑、Tab、登录页等框架界面在切换语言后如何同步更新。
  • 运营如何用扫描命令 + 翻译管理页 + 业务实体多语言面板维护译文。
  • 切换语言后常见的几类排错思路。

🧭 设计原则

#决策衍生影响
1API 驱动,不维护本地语言包没有 zh-CN.ts / lang/zh_CN/*.php,全部走数据库 + API
2$t('中文') 原文即 Keyvue-i18n 找不到 key 时返回 key 本身(对源码阅读友好)
3模块优先 / 全局兜底language_texts.module 区分模块空间,模块查不到回退到全局
4业务实体走独立子表菜单 / 字典等强 ID 绑定数据用业务子表,与 UI 文案分离
5总开关零开销isLanguageEnabled() 关闭时所有翻译链路短路返回原文

🏗️ 整体架构

┌──────────────────────────────────────────────────────┐
│  ① UI 文案翻译(按钮 / 表头 / 提示 / 表单 label)        │
│                                                      │
│    language_texts        ──→  原文表(按模块 + 端)     │
│    language_translations ──→  各语种译文               │
│                                                      │
│    后端:__t() / __tIn()                              │
│    前端:$t('原文即 key')                              │
└──────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────┐
│  ② 业务实体翻译(业务子表)                            │
│                                                      │
│    system_menu_translations                          │
│    dict_type_translations / dict_data_translations    │
│                                                      │
│    业务接口 read/list 时 join 子表 → 返回本地化字段     │
└──────────────────────────────────────────────────────┘

📊 数据库结构

用途
language_codes语言代码字典(zh-CN、en-US…),含启停状态与排序
language_texts原文表,唯一键 (module, target, text)
language_translations译文表,按 text_id + code 唯一
system_menu_translations菜单名称翻译,外键 menu_id
dict_type_translations字典类型名称翻译,外键 type_id
dict_data_translations字典数据名称翻译,外键 data_id

language_texts.target 区分三端:

常量含义
LanguageText::TARGET_SERVER1服务端:后端校验消息、接口 message、接口返回数据中的展示文案、邮件、推送
LanguageText::TARGET_ADMIN2后台端:Admin Vue 工程
LanguageText::TARGET_API3前台端:uni-app 工程

🔌 总开关:configs.basics.language

状态行为
0(关闭,默认)isLanguageEnabled() 返回 false:后端 __t() 直接返回原文、前端 getMessage 返回 {}、菜单 / 字典查询不 join 翻译表。零数据库查询
1(开启)完整链路生效,只在 getMessage 缓存 miss 时一次查询

前端表现:开关关闭时顶栏隐藏语言切换器,菜单 / 字典编辑表单不展开多语言录入面板(由 languageStore.isEnabled 控制)。

🧩 后端用法

翻译 UI 文案

php
// 自动从调用栈推断 module(Modules\Booking\... → 'Booking')
echo __t('保存');

// 指定模块空间,找不到时回退全局空间
echo __t('订单已创建', 'Booking');

// 按多个模块空间依次查找,适合跨模块复用能力
echo __tIn('优惠券不可用', ['Booking', 'Coupon']);

// 带变量的服务端文案
echo __t('监控路径不可用:{path}', '', ['path' => $path]);

后端接口返回给前端直接展示的标签、状态、说明等文案,也应在返回前用 __t() 处理。例如概览页的服务器信息、系统监控标签、状态说明会根据 X-Language 返回对应语言。枚举值建议统一走 enumLabel(),避免每个接口手写状态翻译。

翻译业务实体

通过对应模型的 translations() 关系直接读取:

php
$menu->load('translations');
$displayName = $menu->localizedName(); // 内部读 translations,回退 name

当前请求语言:getLanguage()

php
$locale = getLanguage(); // 'zh-CN' | 'en-US' | ...

优先级

  1. 请求头 X-Language(前端切换语言时显式传递,主路径
  2. 请求头 Accept-Language 中匹配到的已启用语言(浏览器系统偏好,仅在前端未传时兜底)
  3. 默认 zh-CN

不能只靠 Accept-Language

Accept-Language 由浏览器写入,反映用户系统偏好,不会随用户在 UI 上切换语言而变化。前端必须通过独立的 X-Language 头把当前选定语言传给后端。

getLanguage() 只会返回已启用语言,支持 en 匹配到 en-USzh 匹配到 zh-CN 这类主语言兜底。请求头为空、所有请求头候选语言都未命中、语言表读取失败时,统一返回 zh-CN

SetLocaleMiddleware 还会把 getLanguage() 的返回值(IETF 形式 zh-CN)转成 Laravel 习惯的下划线形式(zh_CN),让内置 validation / auth 错误消息也按当前 locale 输出。

🖥️ 前端用法(Admin)

在模板里使用 $t()

vue
<template>
  <el-button type="primary" @click="resetPage">{{ $t('查询') }}</el-button>
  <el-button @click="resetParams">{{ $t('重置') }}</el-button>

  <el-input :placeholder="$t('请输入关键字')" />

  <admin-surface-card :title="$t('筛选条件')" :description="$t('按时间筛选订单。')" />
</template>

在脚本里使用 $t()

ts
import { $t } from '@/lang'

await feedback.confirm($t('确定要删除?'))
feedback.msgSuccess($t('删除成功'))

切换语言

ts
import { changeLanguage } from '@/lang'

await changeLanguage('en-US')

changeLanguage 内部会:

  1. localStorage.language = 'en-US'
  2. 设置 i18n.global.locale.value
  3. 强制刷新(forceRefresh=true)从 /adminapi/system/language/getMessage 拉译文
  4. 加载 element-plus 对应语言包
  5. 已登录时重新拉取菜单权限,让菜单、面包屑和 Tab 标题使用当前语言
  6. 触发视图重新挂载(appStore.refreshView()

语言切换器是公共组件,后台顶部栏和登录页共用同一套逻辑。未登录时只切换当前页面文案和 element-plus 语言;已登录后会额外刷新菜单与路由标题。

请求自动携带 X-Language

utils/request/index.ts 拦截器从 cache.get('language') 读取当前语言写入 X-Language 头:

ts
const language = cache.get('language')
if (language) {
  headers['X-Language'] = language
}

所有 axios 请求都会带上,后端 getLanguage() 据此返回对应语种译文。

📱 前端用法(uni-app)

vue
<template>
  <view>{{ $t('保存') }}</view>
</template>

<script setup>
import { $t } from '@/utils/i18n'

const tip = $t('订单已创建')
</script>

切换语言:调用 i18nStore.changeLanguage('en-US'),内部走 /api/system/language/bulkMessage 一次拉取全局 + 各业务模块的译文 map。

utils/request.tsuni.getStorageSync('language') 读取并写入 X-Language 头。

🛠️ 翻译数据维护

自动扫描注册:language:scan-frontend

把前端工程里所有 $t('中文') 调用扫描并注册到 language_texts。可选 --seed-common 补齐常用英文译文,--prune-unused 清理扫描范围内不再使用的前端 UI 翻译:

bash
# 仅扫描注册原文
php artisan language:scan-frontend

# 扫描 + 把仍在使用的常用 UI 词和服务端展示文案补齐英文译文
php artisan language:scan-frontend --seed-common

# 扫描 + 软删除扫描范围内已经不再被 $t() 引用的前端 UI 翻译
php artisan language:scan-frontend --prune-unused

扫描范围与归属

扫描路径入库 module入库 target
frontend/admin/src(排除 views/<模块> 子目录)''(全局)2
frontend/uniapp/src(排除 modules/''(全局)3
modules/<Module>/Resources/admin/src/**<Module>2
modules/<Module>/Resources/uniapp/src/**<Module>3

正则匹配 $t('xxx') / $t("xxx") / i18n.t('xxx')只采集第一参数是字面量且包含中文的字符串,避免把动态拼接和英文 placeholder 误录。

执行后自动 CacheVersionManager::bump('language_message'),让 TranslateService 立刻读到新数据。

--seed-common 的边界:

  • server 端会补齐通用接口提示和服务端展示文案,例如 登录成功负载率操作系统
  • admin / uni-app 端只给已经存在的 $t() 原文补英文译文,不会额外写入当前代码未引用的 UI 词。

--prune-unused 的边界:

  • 只清理本次扫描范围内、当前代码已经不再出现的 $t('中文') 原文。
  • 采用软删除,同时软删除对应 language_translations
  • 不清理菜单、字典等业务实体翻译,也不清理服务端 __t() 文案;这几类数据不是通过前端 $t() 扫描判断是否使用。

后台翻译管理页

入口:系统 → 多语言 → 翻译管理/system/language/translation

  • 按"语言分组(target)+ 所属模块 + 关键词 + 当前语言"筛选条目
  • 所属模块下拉只列当前项目中已安装的模块;选择"框架全局"时维护 module = '' 的公共文案
  • 直接维护原文与每条原文对应的目标语言译文
  • 分页接口 /adminapi/system/language/translations

业务实体翻译

  • 菜单:编辑菜单时面板里"多语言译文"折叠区可填每种已启用语言的菜单名
  • 字典类型 / 字典数据:编辑时同理

折叠区显示条件:languageStore.isEnabled === true && translatableLanguages.length > 0

✅ 验收清单

切换到目标语言后,以下点都应通过:

  • [ ] Network 中所有 /adminapi/* 请求头含 X-Language: <code>
  • [ ] /adminapi/system/language/getMessage 返回非空 data map
  • [ ] 系统按钮文案(查询 / 重置 / 保存 / 新增 / 编辑 / 删除…)已翻译
  • [ ] 表头、面板标题、表单 label、placeholder 已翻译
  • [ ] 菜单、面包屑、Tab 标题已按当前语言更新;菜单页面以菜单接口返回的语言为准
  • [ ] 登录页语言切换器可用,切换后表单文案即时更新
  • [ ] 接口返回给页面直接展示的状态、标签、说明已翻译
  • [ ] element-plus 内置组件(DatePicker、Pagination…)按目标语言显示
  • [ ] 切回中文后所有文案立即恢复

🩺 常见排错

切换语言后只有 element-plus 变了,业务文案仍是中文

根因:请求没带 X-Language 头,后端按 Accept-Language(浏览器偏好)解析仍是 zh-CN,返回空 map。

排查

  1. 看 axios 拦截器是否注入了 X-Language(admin: utils/request/index.ts;uniapp: utils/request.ts
  2. localStorage.language 是否写入了目标语言

切换语言后菜单已变化,但面包屑或 Tab 还是旧语言

根因:菜单页面的面包屑和 Tab 标题来自菜单接口返回的路由 meta.title,静态页面才走 $t()。如果切换语言后没有重新拉菜单、没有刷新已打开 Tab 的标题快照,或菜单翻译没有维护,就会继续显示旧语言。

排查

  1. changeLanguage() 是否执行了 userStore.refreshUserAccess()
  2. 已打开 Tab 是否执行了标题刷新逻辑
  3. 当前菜单接口返回的菜单名是否已经是目标语言

菜单标题与 UI 文案可能不同

菜单、面包屑和 Tab 对菜单页面以菜单翻译为准。例如菜单翻译把 文章列表 维护为 Articles,页面内普通标题 $t('文章列表') 可以翻译为 Article list。两者不是同一套数据,运营上应按展示场景分别维护。

后端接口返回的数据仍是中文

根因:接口里返回了直接展示的中文标签、状态或说明,但代码没有在返回前调用 __t() / enumLabel()

做法

  1. 确认请求头带有 X-Language
  2. 在后端返回前用 __t('中文原文') 包裹展示文案
  3. 执行 php artisan language:scan-frontend --seed-common 补齐常用服务端译文;业务模块文案在翻译管理页维护对应模块空间

getMessage 返回 {}

可能原因

  • 总开关关了:configs.basics.language = 0
  • 目标语言未在 language_codes 启用:status = 1
  • 译文表中没有该语言的记录:SELECT COUNT(*) FROM language_translations WHERE code = 'en-US'

新写的 $t('xxx') 切换语言后还是显示原文

根因:原文还没注册到 language_texts,或者注册了但没维护译文。

做法

bash
php artisan language:scan-frontend

然后到翻译管理页补译文。

模块业务文案污染了全局翻译空间

根因:模块 Vue 文件没放在 modules/<Module>/Resources/admin/src/ 下,扫描器把它当成全局文案。

做法:模块代码按规范放在模块自己的 Resources/admin/src/views/<Module>/,扫描器会按模块名入库。

⚡ 性能与缓存

  • 译文 map 查询走 Cache::remember,TTL 由 sunadmin.hot_cache.language_message_ttl(默认 600 秒)控制。
  • 缓存 key 形如 sunadmin:hot:language-message:v{n}:_global:2:<md5(code)>,带版本号。
  • php artisan language:scan-frontend 完成后自动 CacheVersionManager::bump('language_message'),让所有节点立刻读到新数据。
  • 总开关关闭时整个链路短路返回 [],零数据库 / 缓存开销。

📎 关键代码位置

路径角色
app/Support/helpers.phpgetLanguage() / isLanguageEnabled() / currentModule() / __t() / __tIn()
app/Core/Http/Middleware/SetLocaleMiddleware.phpgetLanguage() 设置 Laravel app locale
app/Support/Language/FrontendI18nScanner.php前端 $t() 扫描器
app/Support/Language/CommonUiTranslationSeeder.php常用 UI 词英文译文种子
modules/System/Application/Services/Language/TranslateService.php译文查询与缓存
modules/System/Application/Actions/Admin/Language/LanguageAction.php后台接口动作层
modules/System/Http/Controllers/Admin/Language/LanguageController.php/adminapi/system/language/* 接口入口
frontend/admin/src/lang/index.tsAdmin vue-i18n 初始化、$t 重写、changeLanguage
frontend/admin/src/stores/modules/language.ts语言列表、译文缓存(内存 + localStorage)
frontend/admin/src/utils/request/index.tsAdmin 注入 X-Language
frontend/uniapp/src/utils/i18n.tsuni-app i18n 初始化
frontend/uniapp/src/utils/request.tsuni-app 注入 X-Language