管理后台数据国际化:不建翻译表、一列 JSON、后端零改动

一个管理后台要做国际化,文案其实分两种。

第一种是界面文案:「保存」「操作成功」「确定要删除吗」。它们写在代码里,解决方案很成熟------语言包文件,按 key 取值,切语言换一套文件。第二个是数据文案:菜单叫「用户管理」,这条数据存在 sys_menu 表里;字典「性别」下面挂着「男」和「女」,存在 sys_dict_item 里;部门、岗位、公告标题,全在各自的表里。它们不是代码,是数据,语言包救不了它们。

数据文案的主流解法有两种:建一张翻译表,或者每种语言加一列。我们两种都没选------在现成的 JSON 扩展列里加了一个 i18n 键。这篇文章把这笔账算清楚:三种做法各自把钱花在哪、为什么管理后台这个场景我们选了第三种,以及从数据库设计、接口处理到前端逻辑,这条链路每一环长什么样。菜单管理已经全链路落地,字典管理的设计同构推广,一并讲。

一、三种做法,成本都花在哪

1.1 翻译表:通用性最强,管道最长

翻译表的做法,就是把「翻译」本身做成数据。典型结构:

sql 复制代码
CREATE TABLE sys_translation (
  table_name  varchar(64),   -- 哪张表
  record_id   bigint,        -- 哪一行
  field_name  varchar(64),   -- 哪个字段
  locale      varchar(16),   -- 哪种语言
  text_value  varchar(500),  -- 译成什么
  ...
);

它的理论能力是三者最强的:任何表、任何字段、任意多语言,都不用动别人的表结构;翻译内容可以整表导出,交给专业翻译公司流水线作业,译完导回来。多语言电商、内容平台这类「字段多、语言多、翻译外包」的场景,这是正解。

但把这套搬到管理后台,成本会落在四个地方:

  • 读侧每一刀都要补一刀。 列表页查出 20 行菜单,还得再去翻译表捞当前语言的译文------JOIN 进主查询,或者查完主表二次查询。分页列表页最伤,索引和查询计划都要专门照顾。
  • 孤儿数据。 主表删了一行,翻译表里那几行不会自己消失,靠级联删除或者定时清理兜着。
  • 维护入口是割裂的。 做一张通用「翻译维护页」,用户得先选表、再选行、再选语言,改一个菜单名要点三层;把翻译字段嵌进每个业务表单,则每个模块都要单独开发一遍。
  • 兜底逻辑到处散落。 「这种语言没译文就回退默认值」这个判断,每个消费点都要写一遍,漏一处就是一个裸空值。

1.2 每语言一列:查询最爽,语言焊死

第二种做法直白:name_zh、name_en、name_ja......每种语言一列。

它的优点也是真的:查询零开销,译文就在行内;管理表单就是多几个输入框;每列都有完整的类型约束。语言集合固定、表数量可控的中英双语系统,这么做完全合理。

代价在「加语言」这个动作上。要支持第三种语言,意味着:所有涉及表来一轮 DDL,实体、DTO、查询、表单、导出模板全链跟着改一遍------语言集合被焊死在数据库结构里 。而且列数随语言数线性膨胀,「默认语言」的语义只能靠团队约定:裸的 name 列到底算哪种语言,没有结构层面的答案。

1.3 主列 + JSON 扩展键:我们的选择

第三种做法:主列照旧存默认语言,译文塞进行内一个 JSON 扩展列的 i18n 键里。以菜单为例,sys_menu.variable 列里的一行数据长这样:

json 复制代码
{
  "activePath": "/sys/user",
  "i18n": {
    "zh-CN": "用户管理",
    "en-US": "User Management"
  }
}

activePath 是这列本来就在挂的扩展参数------i18n 不是一张新表、一个新列,只是既有 JSON 里的一个新键。四个特性直接对应 1.1 和 1.2 的四个成本点:

  • 加语言是数据操作,不是 DDL。 支持日语 = 往 JSON 里加 "ja-JP" 键,一行 UPDATE 的事,表结构一动不动。
  • 读侧零额外查询。 译文跟着行走,查菜单本来就是一次查询,译文顺路带出来。
  • 零迁移。 没有任何回填脚本:没有 i18n 键的行,读取时回退主列原值。老库升级完,行为和升级前完全一致,译文由管理员在界面上逐步维护。
  • 兜底只有一处。 回退逻辑收在读取侧一个工具函数里,全系统就这一份。

代价也说清楚,两条:

  • JSON 里的文案进不了高效检索。 想按英文译文做 WHERE,只能对 JSON 串做模糊匹配。但数据文案本来就不是检索键------用户搜菜单是按中文名搜的,这条路没伤到真实场景。
  • 数据库不帮你校验结构。 我们的运行环境是 MySQL 5.7,原生 JSON 类型要 8.0 才好用,所以列类型就是 text,形状靠读写两端同一套工具函数收口,键名写错不会报错、只会走兜底。

三种做法放在一起:

维度 翻译表 每语言一列 主列 + JSON 键
加一门语言 插数据 全链 DDL 一轮 插数据
读侧开销 JOIN / 二次查询 无 无
存量数据迁移 要回填或双读 要回填 不需要(无键回退主列)
管理端维护 独立翻译页或逐表开发 表单加列 同一个分组组件,全表复用
按译文检索 好 好 弱(走主列搜索)
适合场景 字段多、语言多、翻译外包 语言固定、表少 字段少、语言渐增、管理员顺手维护

没有谁对谁错,是成本结构不同。管理后台的数据文案有个很具体的画像:每张表就一个主显示字段,语言就两三种且慢慢加,翻译由管理员在维护业务数据时顺手填写,不存在外包流水线。对着这个画像,前两种的强项(任意字段、检索译文、专业翻译协作)一样都用不上,成本却一项不少。第三种的弱项(译文检索、结构校验)在这个画像里恰好不疼。

二、数据库设计:一列和一条规约

机制落到数据库侧,其实只有两件事:一张基线表 (菜单,已落地),一条推广规约(其余表照抄)。

2.1 载体是既有的扩展列

sys_menu.variable(varchar(5000))在框架里存在很久了,一直是「额外参数」的容器,activePath、affixTab 这些前端渲染参数都挂在里面。i18n 选它做载体,等于零 DDL 拿到一处现成的、已经在读写链路上的扩展点。

把这套推广到其他表,就一条规约:

主显示字段的译文,挂在本行 variable 列的 i18n 键里;没有 variable 列的表,加一列。

两个细节是拍板过的:

  • 主显示字段按表定死 ,不搞通用映射:菜单和字典是 name,低代码的表/字段台账是 remark(表注释就是低代码页面上的显示名),公告和站内信是 title。每张表哪个字段「露脸」, schema 里写清楚,不做运行时推断。
  • 新增列统一 text,可空、无默认值:
sql 复制代码
ALTER TABLE sys_dict      ADD COLUMN variable text NULL COMMENT '扩展属性JSON';
ALTER TABLE sys_dict_item ADD COLUMN variable text NULL COMMENT '扩展属性JSON';

不用 MySQL 原生 JSON 类型,前面说了是 5.7 的原因;列类型选 text 还有一个顺带的好处------它是多语言框架栈里 ORM 的自然类型,十几套技术栈各自生成实体时不用为这一列做特殊映射。

升级脚本两条纪律:幂等 (MySQL 5.7 没有 ADD COLUMN IF NOT EXISTS,用 information_schema.COLUMNS 计数做守卫,加列语句走 PREPARE/EXECUTE 动态执行,重复跑第二遍零副作用);只加不改(不动任何既有列和既有 JSON 键,老代码读主列完全不受影响)。

2.2 零迁移是怎么成立的

传统结构变更最重的成本是存量数据回填,这个设计里它直接不存在:

  • 主列语义不变,继续存默认语言(中文)值;
  • variable 没有 i18n 键 = 「还没有维护译文」,读取侧回退主列;
  • 所以一行 UPDATE 回填脚本都没有,en 译文由管理员在管理端按需逐步维护;
  • 一张从未维护过译文的老库,升级后每个页面显示的东西和升级前逐字节相同。

语言键的集合也不是写死的:前端有一份支持语言常量(BCP 47 格式,当前 zh-CN / en-US),数据库里的键按这个集合填写。将来加日语,是常量数组加一项、界面表单自动长出日语输入框、数据库里的行逐行多一个键------三层都不需要 DDL。

三、接口处理:后端刻意什么都不懂

这条链路上我最想强调的设计决定是:后端不参与翻译。

具体说,接口不读 Accept-Language,不根据请求头决定返回哪种文案,variable.i18n 原样透传给前端,翻译的决策权全部在渲染层。理由有三个:

  1. 接口缓存和契约测试会碎。 同一个接口对不同请求头返回不同文案,缓存键就要带上语言维度,契约测试要对每种语言各断言一遍------13 套技术栈的框架,每套都要长出这套逻辑。
  2. 排查日志会碎。 排问题时看到接口返回「User Management」,你得先反查这条记录的主列存的是什么。原文一直在线、译文只是附注,排查时信息是稳定的。
  3. 界面文案已经有一套语言包机制了。 后端再按语言翻数据文案,一套系统里就有两套翻译决策路径,行为对不齐时用户看到半中半英,还说不清哪边的问题。

后端唯一要做的事,是把 variable 列变成结构化的 ext 字段透传出去。以 Java 栈为例,整个「接口处理」就这么多:

java 复制代码
@Data
public class MenuVO extends Menu {
    @Schema(description = "扩展参数")
    private Dict ext = Dict.create();

    public Dict getExt() {
        if (JSONUtil.isTypeJSON(getVariable())) {
            ext = JsonTool.fromJson(getVariable(), Dict.class);
        }
        return ext;
    }
}

读取时 variable 列的 JSON 反序列化成 ext 对象随 VO 出去;保存时反过来,入参里的 ext 序列化回 variable 落库。前端拿到的是 ext.i18n["en-US"],库里存的是 {"i18n":{"en-US":...}},中间没有任何一方「理解」过这段数据。

值得说明的是,这条 variable ↔ ext 的双向链路不是为 i18n 新写的------低代码模块的元数据表(dev_schema)一直走它,菜单 i18n 只是搭了现有管道的车。这也是为什么十几套技术栈能低成本跟齐:每套栈只要保证「ext 进得去、出得来」这一个既有能力在位,i18n 就是纯数据的事。

四、前端处理:一个 transform、一条兜底链、一个动态表单

4.1 读取侧:菜单回包上跑一次替换

前端拿到菜单接口的回包后、交给路由系统之前,跑一个小 transform,逐节点看 meta.i18n 里有没有当前语言的译文,有就替换显示标题:

ts 复制代码
export function applyMenuI18n(menus, locale) {
  const walk = (nodes) => {
    for (const node of nodes) {
      const text = node.meta?.i18n?.[locale];
      if (node.meta && typeof text === 'string' && text.length > 0) {
        node.meta.title = text;
      }
      if (Array.isArray(node.children)) walk(node.children);
    }
  };
  walk(menus);
  return menus;
}

兜底链在这里收口,四层:当前语言译文 → zh-CN 译文 → 主列原值 → 走既有渲染逻辑。某行只维护了英文没维护中文?回退主列。键名拼错了?回退主列。整行没碰过 i18n?和升级前一模一样。用户在任何一步都看不到裸 JSON。

菜单名其实有两类来源,处理方式不同但殊途同归:

  • 路由同步进来的 :前端路由文件里的 title 本来就是语言包的 key(如 page.sys.user),菜单管理同步时按各语言包解析成每种语言的文本,写进 i18n 键------这是「数据里存 key」的设计,漏译的语言还能自动回退到中文包;
  • 手填的字面量 :管理员直接在菜单表单里填的名字,原样进 i18n 键。

两类在渲染侧是同一条路径,i18n 键里存的是最终文本,不用关心里面曾经是 key 还是字面量。

4.2 管理端:表单字段由语言常量生成

管理员在哪维护这些译文?就在菜单管理的编辑表单里,一个「多语言」分组:

这个分组的字段不是手写死的,是从支持语言常量映射出来的:

ts 复制代码
...SUPPORT_LANGUAGES.map((lang) => ({
  fieldName: `ext.i18n.${lang.value}`,
  label: `菜单名称(${lang.value})`,
  component: 'Input',
})),

SUPPORT_LANGUAGES 加一项日语,所有表的编辑表单自动长出日语输入框,保存链路自动带上 ja-JP 键------和「加语言不用改表结构」对偶:加语言也不用改表单代码。清空某个语言的输入框保存,该键从 JSON 里整体移除,不留空串脏数据。

一个诚实的取舍:切语言目前是硬刷新,不是热切换。表单里的字段常量在模块首次加载时求值,框架的软刷新救不了它们;权衡下来,管理后台切语言是低频操作,硬刷新一秒换干净,比为了热切换把全部 schema 常量改成工厂函数划算。

右上角切到 English,侧栏、页签、面包屑整条变英文:

这张英文态截图里还有两处「没翻」,恰好都是设计的一部分:类型列的「目录 / 菜单 / 按钮」标签是数据字典,它的多语言正是第五节要讲的设计;列表里几行还显示着编码原文(page.wf.title 这种)------那些菜单还没维护英文译文,读取时按兜底链回退了主列原值,不会显示成空白或裸 JSON。

五、字典管理:同一张图纸

菜单走通了全链路,字典是同一个设计往下铺。这一节主要讲设计------数据库的动作已经做完(sys_dict、sys_dict_item 各加了一列 variable text),代码侧按下面的图纸推进。

5.1 形状:与菜单逐字同构

字典的主显示字段是 name,一行字典数据未来的样子:

json 复制代码
{ "i18n": { "zh-CN": "流程分类", "en-US": "Process Category" } }

和菜单的 JSON 形状完全一致,键的含义、兜底链、维护方式全部照抄。这不是偷懒,是刻意为之:同构意味着读侧只需要一个工具函数,管理端只需要一个分组组件,文档只需要写一遍。新同事理解了菜单的 i18n,字典的部分不需要重新解释。

5.2 读取侧设计:搭字典缓存的便车

字典在框架里的消费路径和菜单不同:它是接口整包拉的(业务表里存编码,渲染时编码换标签),而且后端有 Redis 缓存。i18n 进来会不会把缓存搞复杂?不会,这正是挂 JSON 键这个设计的一个红利:

  • 缓存存的是整行数据 ,variable 字段跟着行走,缓存结构零变化;
  • 译文改了,走的还是字典既有的「更新落库、清缓存」链路,不需要第二套缓存失效逻辑;
  • 前端字典组件渲染标签时按当前语言取值,兜底链与菜单共用同一个工具函数。

也就是说,字典 i18n 在读取侧的全部工作,是字典组件渲染那一层多一次「按 locale 取值」,数据管道一行不用动。

5.3 管理端设计:同一个分组组件

字典和字典项的编辑表单,各加一个「多语言名称」分组,组件逻辑与菜单表单完全同款:SUPPORT_LANGUAGES 驱动、ext.i18n.{lang} 字段名、空值整键移除。

5.4 边界:哪些东西明确不翻

推广规约时最容易犯的错是把「所有 varchar」都当文案。我们的清单里,下面这些明确排除:

不翻 为什么
人名、联系方式(real_name、phone) 不是文案,是事实数据
配置项(sys_config 的 name/content) name 是管理信息,content 是配置值,都不露给最终用户的「文案层」
公告正文(content) 只译标题。正文级的多语言是内容业务(该做翻译版本管理),不是配置业务
代码内枚举标签(@DictEnum 的 name) 那是代码里的文案,走前端语言包,和数据库里的数据字典是两码事
短信模板(content) 每种语言建一行模板,用行隔离,不塞 JSON 键

这份清单的价值不在「不做什么」,而在划清一条线:进数据库 JSON 键的,是「管理员维护、跟着业务数据走」的显示文案;在代码里的走语言包,是内容本身的走内容方案。 三种机制各管一段,谁也不越界。

顺带一提,公告和站内信两张表的 variable 列本来就存在(历史上装过别的扩展参数),纳入 i18n 连 DDL 都不用------规约里「已有列的表直接复用」这条,第一次推广就省了两张表的动作。

结尾

回头看,这套东西全部的「结构」就是:一列 JSON、一条规约、一个 transform、一个动态表单。没有新表、没有 JOIN、没有迁移脚本、没有后端语言协商------数据文案的翻译被安排在它唯一该在的地方:数据旁边,渲染的时候取用。

variable 这列 JSON 还会继续长:框架下一个要挂的扩展属性,落的是同一个容器。第一次为 i18n 加的那几列,将来装别的东西也不用第二次 DDL------这可能才是「不建专用结构」最大的回报:专用结构只为一个问题服务,而一个足够通用的容器,为所有下一个问题留着位置。

演示站可看:切换右上角语言,侧栏菜单、页签、面包屑即时换语。

参考资料

相关推荐
Eric的技术杂货铺1 小时前
YsTable 使用:字典管理主从表页面完整实现(列配置、查询、权限按钮、主从联动、增删改查)
前端·vue.js
易朵朵1 小时前
package.json 中的 `vue-router` 详解
前端·vue.js
CopyCode1 小时前
ref、reactive、toRefs 到底该用哪个?我把三个反例都写了一遍
前端·vue.js
用户42528244567501 小时前
Vue3 复杂页面状态怎么管?别什么都往 Pinia 里塞了!
vue.js
牧艺1 小时前
cos-design 4.0:91 个特效组件一次捅成 React / Vue / Web Components / Core
前端·vue.js·web components
ikun_文1 小时前
Vue3框架项目初始化
vue.js·element·vue-router
_少年游1 小时前
Vue 3 中 watch 与 watchEffect 的深度解析:从用法差异到源码实现
vue.js
卤蛋fg61 小时前
vue 表格组件 vxe-table 鼠标滑动选择多行
vue.js
A黄俊辉A5 天前
uniapp webview中实现 app和内嵌的H5双向通信
vue.js·json