🌐 多语言系统
SunAdmin 多语言能力把"原文翻译"和"业务实体翻译"两条链路拆开维护:UI 文案统一走 language_texts + language_translations 通用表,菜单 / 字典等强 ID 绑定的业务数据走各自的翻译子表。前端用 $t('保存') 写中文当 key,找不到译文时天然回退原文。整个系统由 configs.basics.language 单一开关托管,关闭时零开销。
🎯 本页目标
读完本章节后,应能掌握:
- 项目的多语言由哪些表 / 接口 / 缓存层支撑,开 / 关如何切换。
- 后端代码里如何用
__t() / __tIn()翻译文案、如何拿到当前请求的语言。 - 前端 Admin / uni-app 里
$t()怎么用、X-Language头怎么传、缓存策略是怎样的。 - 菜单、面包屑、Tab、登录页等框架界面在切换语言后如何同步更新。
- 运营如何用扫描命令 + 翻译管理页 + 业务实体多语言面板维护译文。
- 切换语言后常见的几类排错思路。
🧭 设计原则
| # | 决策 | 衍生影响 |
|---|---|---|
| 1 | API 驱动,不维护本地语言包 | 没有 zh-CN.ts / lang/zh_CN/*.php,全部走数据库 + API |
| 2 | $t('中文') 原文即 Key | vue-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_SERVER | 1 | 服务端:后端校验消息、接口 message、接口返回数据中的展示文案、邮件、推送 |
LanguageText::TARGET_ADMIN | 2 | 后台端:Admin Vue 工程 |
LanguageText::TARGET_API | 3 | 前台端:uni-app 工程 |
🔌 总开关:configs.basics.language
| 状态 | 行为 |
|---|---|
0(关闭,默认) | isLanguageEnabled() 返回 false:后端 __t() 直接返回原文、前端 getMessage 返回 {}、菜单 / 字典查询不 join 翻译表。零数据库查询 |
1(开启) | 完整链路生效,只在 getMessage 缓存 miss 时一次查询 |
前端表现:开关关闭时顶栏隐藏语言切换器,菜单 / 字典编辑表单不展开多语言录入面板(由 languageStore.isEnabled 控制)。
🧩 后端用法
翻译 UI 文案
// 自动从调用栈推断 module(Modules\Booking\... → 'Booking')
echo __t('保存');
// 指定模块空间,找不到时回退全局空间
echo __t('订单已创建', 'Booking');
// 按多个模块空间依次查找,适合跨模块复用能力
echo __tIn('优惠券不可用', ['Booking', 'Coupon']);
// 带变量的服务端文案
echo __t('监控路径不可用:{path}', '', ['path' => $path]);后端接口返回给前端直接展示的标签、状态、说明等文案,也应在返回前用 __t() 处理。例如概览页的服务器信息、系统监控标签、状态说明会根据 X-Language 返回对应语言。枚举值建议统一走 enumLabel(),避免每个接口手写状态翻译。
翻译业务实体
通过对应模型的 translations() 关系直接读取:
$menu->load('translations');
$displayName = $menu->localizedName(); // 内部读 translations,回退 name当前请求语言:getLanguage()
$locale = getLanguage(); // 'zh-CN' | 'en-US' | ...优先级:
- 请求头
X-Language(前端切换语言时显式传递,主路径) - 请求头
Accept-Language中匹配到的已启用语言(浏览器系统偏好,仅在前端未传时兜底) - 默认
zh-CN
不能只靠 Accept-Language
Accept-Language 由浏览器写入,反映用户系统偏好,不会随用户在 UI 上切换语言而变化。前端必须通过独立的 X-Language 头把当前选定语言传给后端。
getLanguage() 只会返回已启用语言,支持 en 匹配到 en-US、zh 匹配到 zh-CN 这类主语言兜底。请求头为空、所有请求头候选语言都未命中、语言表读取失败时,统一返回 zh-CN。
SetLocaleMiddleware 还会把 getLanguage() 的返回值(IETF 形式 zh-CN)转成 Laravel 习惯的下划线形式(zh_CN),让内置 validation / auth 错误消息也按当前 locale 输出。
🖥️ 前端用法(Admin)
在模板里使用 $t()
<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()
import { $t } from '@/lang'
await feedback.confirm($t('确定要删除?'))
feedback.msgSuccess($t('删除成功'))切换语言
import { changeLanguage } from '@/lang'
await changeLanguage('en-US')changeLanguage 内部会:
- 写
localStorage.language = 'en-US' - 设置
i18n.global.locale.value - 强制刷新(
forceRefresh=true)从/adminapi/system/language/getMessage拉译文 - 加载 element-plus 对应语言包
- 已登录时重新拉取菜单权限,让菜单、面包屑和 Tab 标题使用当前语言
- 触发视图重新挂载(
appStore.refreshView())
语言切换器是公共组件,后台顶部栏和登录页共用同一套逻辑。未登录时只切换当前页面文案和 element-plus 语言;已登录后会额外刷新菜单与路由标题。
请求自动携带 X-Language
utils/request/index.ts 拦截器从 cache.get('language') 读取当前语言写入 X-Language 头:
const language = cache.get('language')
if (language) {
headers['X-Language'] = language
}所有 axios 请求都会带上,后端 getLanguage() 据此返回对应语种译文。
📱 前端用法(uni-app)
<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.ts 从 uni.getStorageSync('language') 读取并写入 X-Language 头。
🛠️ 翻译数据维护
自动扫描注册:language:scan-frontend
把前端工程里所有 $t('中文') 调用扫描并注册到 language_texts。可选 --seed-common 补齐常用英文译文,--prune-unused 清理扫描范围内不再使用的前端 UI 翻译:
# 仅扫描注册原文
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返回非空datamap - [ ] 系统按钮文案(查询 / 重置 / 保存 / 新增 / 编辑 / 删除…)已翻译
- [ ] 表头、面板标题、表单 label、placeholder 已翻译
- [ ] 菜单、面包屑、Tab 标题已按当前语言更新;菜单页面以菜单接口返回的语言为准
- [ ] 登录页语言切换器可用,切换后表单文案即时更新
- [ ] 接口返回给页面直接展示的状态、标签、说明已翻译
- [ ] element-plus 内置组件(DatePicker、Pagination…)按目标语言显示
- [ ] 切回中文后所有文案立即恢复
🩺 常见排错
切换语言后只有 element-plus 变了,业务文案仍是中文
根因:请求没带 X-Language 头,后端按 Accept-Language(浏览器偏好)解析仍是 zh-CN,返回空 map。
排查:
- 看 axios 拦截器是否注入了
X-Language(admin:utils/request/index.ts;uniapp:utils/request.ts) localStorage.language是否写入了目标语言
切换语言后菜单已变化,但面包屑或 Tab 还是旧语言
根因:菜单页面的面包屑和 Tab 标题来自菜单接口返回的路由 meta.title,静态页面才走 $t()。如果切换语言后没有重新拉菜单、没有刷新已打开 Tab 的标题快照,或菜单翻译没有维护,就会继续显示旧语言。
排查:
changeLanguage()是否执行了userStore.refreshUserAccess()- 已打开 Tab 是否执行了标题刷新逻辑
- 当前菜单接口返回的菜单名是否已经是目标语言
菜单标题与 UI 文案可能不同
菜单、面包屑和 Tab 对菜单页面以菜单翻译为准。例如菜单翻译把 文章列表 维护为 Articles,页面内普通标题 $t('文章列表') 可以翻译为 Article list。两者不是同一套数据,运营上应按展示场景分别维护。
后端接口返回的数据仍是中文
根因:接口里返回了直接展示的中文标签、状态或说明,但代码没有在返回前调用 __t() / enumLabel()。
做法:
- 确认请求头带有
X-Language - 在后端返回前用
__t('中文原文')包裹展示文案 - 执行
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,或者注册了但没维护译文。
做法:
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.php | getLanguage() / isLanguageEnabled() / currentModule() / __t() / __tIn() |
app/Core/Http/Middleware/SetLocaleMiddleware.php | 按 getLanguage() 设置 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.ts | Admin vue-i18n 初始化、$t 重写、changeLanguage |
frontend/admin/src/stores/modules/language.ts | 语言列表、译文缓存(内存 + localStorage) |
frontend/admin/src/utils/request/index.ts | Admin 注入 X-Language 头 |
frontend/uniapp/src/utils/i18n.ts | uni-app i18n 初始化 |
frontend/uniapp/src/utils/request.ts | uni-app 注入 X-Language 头 |
