国际化(i18n)
如果你已经熟悉 Pinmok 的多语言功能,可以直接展开快速开始查阅要点。
快速开始
按 Django 标准启用多语言
请按 Django 规范启用国际化设置,padmin 界面语言会自动跟随当前语言环境,无需额外配置。
多语言模型基类
Pinmok 提供了两个模型基类,适用于简单的主表 + 翻译表场景:
TranslatableModel:主表基类,存放与语言无关的字段TranslationModel:翻译表基类,存放各语言的文字内容。子类须满足三个约束:指向主表的外键related_name必须为'translations'(推荐使用常量TRANSLATION_RELATED_NAME);必须定义UniqueConstraint;必须实现get_display_text()方法
from pinmok.core.translatable import TranslatableModel, TranslationModel
from pinmok.core.constants import TRANSLATION_RELATED_NAME
class Article(TranslatableModel): # 主表
sort_order = models.IntegerField(default=0) # 与语言无关的字段放主表
class ArticleTranslation(TranslationModel): # 翻译表
article = models.ForeignKey(
Article,
on_delete=models.CASCADE,
related_name=TRANSLATION_RELATED_NAME, # 必须为 'translations'
)
title = models.CharField(max_length=255)
class Meta(TranslationModel.Meta):
constraints = [ # 必须定义,防止同一语言重复写入
models.UniqueConstraint(
fields=['article', 'language'],
name='uniq_article_language'
)
]
def get_display_text(self): # 必须实现,返回代表该对象的主要字段
return self.title
读取翻译:
# 列表查询,避免 N+1
articles = Article.with_translations()
# 读取 `title` 字段的当前语言内容
article.translation.title
清理 .po 文件冗余条目(可选)
makemessages 生成的 .po 文件可能包含重复条目;Django 内置 app 已自带翻译,项目中无需重复维护。
运行以下命令一次性清理:
python manage.py dedupe_po [-l LANGUAGE_CODE] [-d {django,djangojs}] [--dir SCAN_DIRECTORY]
[--no-backup] [--no-comments]
| 选项 | 默认值 | 说明 |
|---|---|---|
-l, --lang |
zh-hans |
目标语言代码 |
-d, --domain |
全部 | 限定 domain,可选 django / djangojs,可多选 |
--dir |
当前目录 | 指定扫描根目录 |
--no-backup |
— | 不创建备份(默认创建 .bak 文件) |
--no-comments |
— | 不保留注释 |
Pinmok 的多语言完全建立在 Django 的 i18n 体系之上,不引入任何额外的配置层。padmin 已内置简体中文(zh-hans)和英文(en)翻译,后台界面语言跟随
Django 当前语言环境,无需额外设置。如需其他语言,参照 locale/ 目录下的现有翻译文件自行添加即可。
Pinmok 的多语言支持,分以下三种情况:
- 固定语言:在 settings.py 中设置 LANGUAGE_CODE,后台界面使用该语言,不随用户偏好变化。padmin 内置简体中文(zh-hans)和英文(en)两种翻译, 如需其他语言,参照 locale/ 目录下的现有翻译文件自行添加即可。
- 自动切换:开启 USE_I18N 并添加 LocaleMiddleware,Django 会按优先级依次从 URL 前缀、会话、Cookie、请求头(Accept-Language) 中识别用户语言偏好并自动切换,无需用户手动操作。
- 用户手动切换:在第二种情况的基础上,在 urls.py 中添加 i18n/ 路由,后台顶栏将显示语言切换菜单,用户可明确指定界面语言。
由于用户手动切换涵盖全部配置,以下仅对该场景的配置步骤进行说明。其余情况请参阅Django 官方文档。
显示语言切换
padmin 的后台模板已内置语言切换控件,显示条件为 USE_I18N 已开启且 set_language 路由可访问(Django 默认开启 USE_I18N,无需额外配置)。
要让切换按钮正常工作,需完成以下两步:
1. 添加 LocaleMiddleware
在 settings.py 的 MIDDLEWARE 中添加 django.middleware.locale.LocaleMiddleware,位置须在 SessionMiddleware 之后、CommonMiddleware 之前:
MIDDLEWARE = [
...
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware', # 在此位置
'django.middleware.common.CommonMiddleware',
...
]
Django 会依次从会话、Cookie、请求头(Accept-Language )中推断用户语言偏好。
详见 Django 语言偏好发现机制。
2. 添加 i18n 路由
在项目的 urls.py 中添加:
from django.urls import path, include
urlpatterns = [
...
path("i18n/", include("django.conf.urls.i18n")),
...
]
这会注册 Django 内置的 set_language() 视图,该视图负责设置用户语言首选项并重定向回上一页。完成后,后台顶栏将显示语言切换菜单。
关于 LANGUAGES
切换菜单中的语言列表取自 settings.py 中的 LANGUAGES。若未配置,Django 会列出所有内置支持的语言(数量庞大)。建议明确指定:
LANGUAGES = [
('zh-hans', '简体中文'),
('en', 'English'),
]
界面翻译
界面文字的翻译(按钮、标签、提示信息等)走 Django 标准流程:在代码中使用 gettext 或 gettext_lazy 标记待翻译字符串,用 makemessages 提取,编辑
.po 文件,再用 compilemessages 编译。
Pinmok app 的翻译文件遵循 Django 约定,放在各 app 的 locale/ 目录下,Django 能自动发现。语言切换、URL 前缀、用户偏好存储等均按 Django 标准实现,
Pinmok 不做任何约束。Django i18n 的完整文档参见 Django 官方文档。
除 Django 标准 i18n 之外,Pinmok 提供两个补充工具解决 Django 标准方案覆盖不到的场景:多语言模型(TranslatableModel / TranslationModel)
用于数据库内容的多语言存储与读取;PO 去重工具(dedupe_po)用于清理 .po 文件中的冗余条目。
多语言模型
Django 的 i18n 体系(.po / .mo 文件)解决的是界面文字的翻译问题——这些文字在代码里静态定义,在构建时已知。但对于存储在数据库中、由用户录入的内容,
例如商品名称、文章标题、分类标签、导航菜单项,.po 文件无能为力,因为这些内容在运行时才存在。
处理这类内容的常见方案是主表 + 翻译表:主表存储与语言无关的属性(排序、状态、时间戳等),翻译表存储各语言的文字内容,通过外键关联到主表,每条记录对应一种语言。 这个结构本身并不复杂,但在实际项目中会产生大量重复代码:每个模型都要实现相同的「读取当前语言」逻辑、相同的 fallback 策略、相同的 N+1 防护。
TranslatableModel 和 TranslationModel 将这些通用逻辑集中实现,开发者只需定义翻译表的具体字段,其余部分开箱即用。
基类提供了什么
TranslatableModel(主表基类)提供以下能力:
translation属性:返回当前语言的翻译对象,找不到时按 fallback 策略降级,模板和序列化器可以直接用obj.translation.name的形式访问,不需要任何额外代码get_translation(language=None):显式指定语言,返回对应的翻译对象with_translations(queryset=None):类方法,用prefetch_related预加载翻译数据,解决列表查询的 N+1 问题invalidate_translation_cache():在同一请求中更新翻译后手动刷新实例缓存__str__返回当前语言的get_display_text()结果,在 admin 列表、下拉选择框等场景下直接显示对应语言的文字
当请求的语言找不到精确匹配时,get_translation() 按以下优先级依次 fallback:
- 当前语言精确匹配(如
zh-hans) - 语言基础码匹配(
zh-hans→zh) settings.LANGUAGE_CODE指定的默认语言- 翻译表中第一条可用记录
TranslationModel(翻译表基类)提供:
language字段(CharField,max_length=10),choices取自settings.LANGUAGES,默认值为settings.LANGUAGE_CODE,带数据库索引get_display_text()(抽象方法,子类必须实现):基类中该方法抛出NotImplementedError,主表和翻译表的__str__均依赖它__str__返回[{language}] {get_display_text()},例如[zh-hans] 技术分类
翻译表的具体内容字段(名称、描述、正文等)由开发者在子类中自行定义。子类须满足以下约束:指向主表的外键 related_name 必须为 'translations'
(推荐使用常量 TRANSLATION_RELATED_NAME);必须在 Meta 中定义 UniqueConstraint,约束 [fk字段, 'language'] 的唯一性,防止同一语言重复写入。
定义模型
主表继承 TranslatableModel,翻译表继承 TranslationModel:
from django.db import models
from pinmok.core.translatable import TranslatableModel, TranslationModel
from pinmok.core.constants import TRANSLATION_RELATED_NAME
# 分类主表
class Category(TranslatableModel):
sort_order = models.IntegerField(default=0)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ['sort_order']
# 分类翻译表
class CategoryTranslation(TranslationModel):
# 外键,关联名必须是字符串 "translations" 或者常量 TRANSLATION_RELATED_NAME
category = models.ForeignKey(
Category,
on_delete=models.CASCADE,
related_name=TRANSLATION_RELATED_NAME,
)
name = models.CharField(max_length=255)
description = models.CharField(max_length=200, blank=True, default='')
class Meta(TranslationModel.Meta):
# 定义约束,防止出现重复
constraints = [
models.UniqueConstraint(
fields=['category', 'language'],
name='uniq_category_language'
)
]
# 实现抽象方法,本例中返回分类名称
def get_display_text(self):
return self.name
读取翻译
继承之后,在视图、模板、序列化器中直接访问翻译内容,无需额外代码。如:
category = Category.objects.get(pk=1)
# 读取当前语言的翻译
category.translation.name
category.translation.description
# 指定语言
category.get_translation('en').name
# __str__ 自动返回当前语言
str(category)
translation 属性和 get_translation() 内部会首先检查是否有 prefetch_related 缓存,有则直接使用;否则在实例上缓存一次查询结果,同一实例生命周期内不会重复查询数据库。
批量查询与 N+1 问题
在列表页或批量处理场景中,如果不预加载翻译数据,每次访问 translation 属性都会触发一次额外的数据库查询,产生经典的 N+1 问题。使用
with_translations() 通过 prefetch_related 一次性加载全部翻译数据:
# 加载全部记录的翻译
categories = Category.with_translations()
# 在已有 queryset 上使用
categories = Category.with_translations(
Category.objects.filter(is_active=True)
)
# 预加载后,遍历时不再触发额外查询
for category in categories:
print(category.translation.name)
更新翻译后刷新缓存
get_translation() 首次调用后会将翻译数据缓存在实例上,以避免同一请求内的重复查询。在同一请求中保存翻译记录后,如需立即读取新值,调用
invalidate_translation_cache() 清除缓存:
translation.save()
category.invalidate_translation_cache()
# 下次访问 category.translation 会重新查询数据库
在 Admin 中管理翻译
主表和翻译表是两张独立的表,如果分开注册,编辑一条记录就要跳两个页面。将翻译表以 Inline 嵌入主表的编辑页,可以在同一页面里同时维护主表字段和各语言翻译,
是推荐的管理方式。内容字段较多时用 PinmokStackedInline 更清晰,字段少时用 PinmokTabularInline 更紧凑。
from pinmok import padmin
from pinmok.padmin.options import PinmokModelAdmin, PinmokStackedInline
from .models import Category, CategoryTranslation
# 翻译表 Inline,嵌入主表编辑页
class CategoryTranslationInline(PinmokStackedInline):
model = CategoryTranslation
extra = 1
# 主表 ModelAdmin,通过 inlines 关联翻译表
@padmin.register(Category)
class CategoryAdmin(PinmokModelAdmin):
inlines = [CategoryTranslationInline]
PO 去重工具
在维护 .po 翻译文件的过程中,会出现两类冗余条目:
自身重复:makemessages 在某些情况下(例如多次运行、合并分支、模板变更)会在同一个 .po 文件中产生 msgid
相同的重复条目。这类重复不影响编译,但会造成维护混乱,翻译工具也可能在重复条目间产生冲突。
与系统翻译重复:django.contrib.admin、django.contrib.auth等,这些 Django 内置 app 已经在自己的 locale/
目录中维护了完整的翻译,Django 在运行时会自动加载它们。如果项目的 .po 文件中也包含相同的 msgid,就是无意义的重复维护——而且一旦
Django 版本升级更新了这些翻译,项目里的旧译文反而会覆盖掉更新后的版本。
dedupe_po 同时处理这两类冗余:先去掉文件内的自身重复,再与系统翻译对比,移除已由 Django 内置 app 覆盖的条目。
命令行用法
在项目根目录运行:
# 处理默认语言(zh-hans),所有 domain
python manage.py dedupe_po
# 指定语言
python manage.py dedupe_po -l en
# 只处理 djangojs domain
python manage.py dedupe_po -d djangojs
# 指定扫描根目录
python manage.py dedupe_po --dir /path/to/project
工具会递归扫描指定目录下所有 app 的 locale/<lang>/LC_MESSAGES/ 目录,统一处理,无需逐个 app 单独运行。
命令行选项:
| 选项 | 默认值 | 说明 |
|---|---|---|
-l, --lang |
zh-hans |
目标语言代码 |
-d, --domain |
全部 | 限定 domain,可选 django / djangojs,可多选 |
--dir |
当前目录 | 指定扫描根目录 |
--no-backup |
— | 不创建备份(默认会创建 .bak 文件) |
--no-comments |
— | 不保留注释 |
程序化调用
除命令行外,PoDeduplicator 也可以在代码中直接实例化调用。适合以下场景:需要在部署脚本中自动化执行去重、需要捕获处理结果做进一步处理
(如记录日志、发送通知)、或需要根据条件动态决定处理哪些语言和 domain。
from pinmok.core.utils.po_deduplicator import PoDeduplicator, PoDedupeResult
from pinmok.core.libs.console import MessageLevel
# emit_progress 回调函数
def my_progress(level: MessageLevel, message: str, metadata: dict) -> None:
print(f"[{level}] {message}", metadata)
deduper = PoDeduplicator(
lang='zh-hans', # 目标语言代码,默认 'zh-hans'
domain=None, # 'django'、'djangojs'、列表或 None(全部 domain)
base_dir='/path/to/project', # 扫描根目录,默认为当前目录
backup=True, # 是否在修改前创建 .bak 备份,默认 True
keep_comments=True, # 是否保留 .po 文件中的注释,默认 True
emit_progress=my_progress, # 进度回调,不传则静默运行
)
results: list[PoDedupeResult] = deduper.handle()
进度回调
emit_progress 是可选的进度回调,每个处理节点都会触发一次,包括:扫描到文件、开始处理某个 domain、处理每个文件前后、创建备份、全部完成等。该函数的定义签名如下:
def my_progress(level: MessageLevel, message: str, metadata: dict) -> None:
level:MessageLevel枚举,表示事件级别,包含LOG、INFO、SUCCESS、WARNING、ERROR、DEBUG、STEP、STEP_DONE、STEP_FAIL几个值message:事件的文字描述metadata:该事件的附加数据字典,内容随事件类型而不同,例如处理文件时包含文件路径,处理完成时包含处理数量等
不传 emit_progress 时工具静默运行。
处理结果
handle() 返回每个处理过的文件的结果列表,每项为一个 PoDedupeResult:
| 字段 | 类型 | 说明 |
|---|---|---|
file |
str |
.po 文件的绝对路径 |
original |
int |
处理前的条目总数 |
removed |
int |
移除的条目数 |
remaining |
int |
处理后的条目数 |
backup_path |
str |
备份文件路径,未创建备份时为空字符串 |
backup_created |
bool |
是否创建了备份 |
备份
默认情况下,每次处理前会在同目录生成时间戳备份文件:
django_250601120000.bak
只有在文件内容实际发生变化时才会创建备份,未发现冗余条目的文件不会生成备份。确认结果无误后可手动删除备份文件,或在运行时加 --no-backup
参数跳过备份。