跳转至

国际化(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 的多语言支持,分以下三种情况:

  1. 固定语言:在 settings.py 中设置 LANGUAGE_CODE,后台界面使用该语言,不随用户偏好变化。padmin 内置简体中文(zh-hans)和英文(en)两种翻译, 如需其他语言,参照 locale/ 目录下的现有翻译文件自行添加即可。
  2. 自动切换:开启 USE_I18N 并添加 LocaleMiddleware,Django 会按优先级依次从 URL 前缀、会话、Cookie、请求头(Accept-Language) 中识别用户语言偏好并自动切换,无需用户手动操作。
  3. 用户手动切换:在第二种情况的基础上,在 urls.py 中添加 i18n/ 路由,后台顶栏将显示语言切换菜单,用户可明确指定界面语言。

由于用户手动切换涵盖全部配置,以下仅对该场景的配置步骤进行说明。其余情况请参阅Django 官方文档


显示语言切换

padmin 的后台模板已内置语言切换控件,显示条件为 USE_I18N 已开启且 set_language 路由可访问(Django 默认开启 USE_I18N,无需额外配置)。 要让切换按钮正常工作,需完成以下两步:

1. 添加 LocaleMiddleware

settings.pyMIDDLEWARE 中添加 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 标准流程:在代码中使用 gettextgettext_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 防护。

TranslatableModelTranslationModel 将这些通用逻辑集中实现,开发者只需定义翻译表的具体字段,其余部分开箱即用。

基类提供了什么

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:

  1. 当前语言精确匹配(如 zh-hans
  2. 语言基础码匹配(zh-hanszh
  3. settings.LANGUAGE_CODE 指定的默认语言
  4. 翻译表中第一条可用记录

TranslationModel(翻译表基类)提供:

  • language 字段(CharFieldmax_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.admindjango.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:
  • levelMessageLevel 枚举,表示事件级别,包含 LOGINFOSUCCESSWARNINGERRORDEBUGSTEPSTEP_DONESTEP_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 参数跳过备份。