Architecture 2026-04-24·Ievgenii Svyryd

蓝图:我如何用 Python 字典替换了数百个模板

一套蓝图系统,将 Python 字典转化为完整的 admin 页面——表格、筛选器、表单、动作、权限和菜单——完全无需 HTML 模板。看一个装饰器、一个 TypedDict 和一份 JSON 契约如何消除了一整类重复的前端工作。

蓝图:我如何用 Python 字典替换了数百个模板

问题所在:40 个管理页面,一个开发者

我当时在为一个交易平台构建后台系统。凡是构建过内部工具的人都会对这些需求感到熟悉:账户、余额、订单、佣金、报表、风险仪表盘 —— 大约 40 个不同的数据视图,每一个都带有筛选、排序、操作、内联编辑和基于角色的权限。

传统的 Django 做法会是:为每个页面写一个模板、一个视图、若干 URL 模式,为每个操作写表单类,为每次交互写 JavaScript。把这些乘以 40,你面对的就是数月的重复劳动,以及一个改动一列表格就意味着要在三个不同文件里编辑 HTML、JavaScript 和 Python 的代码库。

我已经见识过 Django admin 借助模型内省能做到什么。但这不是一个标准的后台 —— 它是一个带有复杂业务逻辑、自定义权限、批量操作和 API 驱动数据表格的定制后台。Django admin 的 ModelAdmin 灵活性不够。我需要的东西要能给我同样的“声明即渲染”的能力,但面向的是任意的 API 驱动视图。

于是我构建了 blueprint 系统。

核心思想:页面不过是一个字典

最根本的洞察在于:每一个管理页面都遵循相同的模式——从 API 获取数据,用带列的表格展示,让用户筛选和排序,为每一行提供操作,并可选地允许行内编辑。如果模式始终不变,那么页面之间唯一变化的就是配置

蓝图是一个 Python 字典,它描述了页面的方方面面:获取什么数据、显示哪些列、提供哪些筛选器、有哪些可用操作,以及谁有权限看到什么。一个通用模板加一个 JavaScript 模块就能渲染每一个页面——蓝图只是告诉它们该渲染什么。

from frontend.blueprints import register_blueprint
from frontend.types import BlueprintDict
from frontend.filters import Filter
from django.urls import reverse_lazy


@register_blueprint(
    name="accounts",
    parent_menu="Accounts Management",
    menu_title="Account Management",
    menu_order=1,
)
def accounts_bp(request) -> BlueprintDict:
    return {
        "page_title": "Accounts",
        "data_url": reverse_lazy("account:accounts_v1"),
        "headers": {
            "ID": "id",
            "Account Name": "acc_name",
            "Currency": "currency.asset_name",
            "Group": "group.name",
            "Tariff Plan": "tariff_plan.name",
            "Is Active": "is_active",
        },
        "formatters": {"Is Active": "ToBool"},
        "filters": [
            Filter.DROPDOWN("Currency", "currency__id", "assets-filter"),
            Filter.DROPDOWN("Group", "group__id", "account-groups"),
            Filter.BOOLEAN("Is Active", "is_active", default_value=True),
        ],
        "actions": [
            {
                "title": "Edit",
                "color": "warning",
                "action": "openFormModal",
                "form_blueprint": "account_edit",
                "permission": "accounts_can_update",
            }
        ],
        "global_search": True,
    }

那就是一个完整的 admin 页面。带六列的表格、三个筛选器、一个带权限控制的编辑按钮、全局搜索,以及自动的菜单注册。没有模板文件。没有要写的 URL 模式。没有要配置的 JavaScript。装饰器负责菜单摆放与 URL 生成。通用视图负责渲染。

蓝图注册表:构建导航的装饰器

@register_blueprint 装饰器所做的远不止把一个函数存进字典。它构建了整个应用的导航结构:

# 全局蓝图注册表
BLUEPRINTS: Dict[str, Callable[..., BlueprintDict]] = {}


def register_blueprint(
    name: str,
    parent_menu: Optional[str] = None,
    menu_title: Optional[str] = None,
    menu_order: int = 100,
    menu_type: str = "section",
):
    def decorator(func):
        # 1. 注册到全局注册表
        BLUEPRINTS[name] = func

        # 2. 自动生成 URL:"accounts" -> /int-data/accounts/
        url_name = name.replace("_", "-")
        url = reverse_lazy("int_view", args=[url_name])

        # 3. 注册到菜单层级中
        if parent_menu:
            register_blueprint_in_menu(
                blueprint_name=name,
                group_id=parent_menu.lower().replace(" ", "_"),
                title=menu_title or name.replace("_", " ").title(),
                url=url,
                order=menu_order,
            )
        return func

    return decorator

当某个新的应用团队添加一份蓝图时,侧边栏菜单会自动更新。无需手动配置菜单,也无需维护路由表。蓝图本身即是注册。这与 Django admin 的 admin.site.register() 原理相同,只是被应用到了整个应用的导航系统上。

类型安全:将 TypedDict 作为活文档

有一个决定立刻就收回了成本,那就是为每一个 blueprint 结构使用 TypedDict。与其传递普通字典、并寄望键都是正确的,不如让每个 blueprint 都遵循一个 IDE 能够理解的带类型的 schema:

from typing import Dict, List, Optional, Union
from typing_extensions import NotRequired, TypedDict


class HeaderFieldDict(TypedDict):
    field: str
    permission: NotRequired[Optional[str]]
    is_editable: NotRequired[Optional[bool]]
    round: NotRequired[Optional[bool]]
    formatter_args: NotRequired[Optional[tuple]]


class BlueprintDict(TypedDict):
    page_title: str
    data_url: str
    headers: Dict[str, Union[str, HeaderFieldDict]]
    formatters: NotRequired[Optional[Dict[str, str]]]
    filters: NotRequired[Optional[List[FilterDict]]]
    actions: NotRequired[Optional[List[ActionDict]]]
    static_actions: NotRequired[Optional[Dict[str, StaticActionDict]]]
    global_actions: NotRequired[Optional[Dict[str, GlobalActionDict]]]
    allow_update: NotRequired[Optional[bool]]
    global_search: NotRequired[Optional[bool]]

TypedDict 定义身兼三职:它们是 mypy 的类型注解、IDE 的自动补全提示,以及给开发者的文档。当有人编写新蓝图时,编辑器会告诉他们每一个可用选项、其类型以及是否必填。拼错某个键?类型检查器会在页面加载之前就抓到它。

用普通字典无法做到这一点。用 YAML 或 JSON 配置文件也做不到——一旦离开 Python,你就失去了 IDE 集成。将蓝图保持为带类型的 Python 字典,让你既拥有配置的灵活性,又拥有代码的安全性。

过滤系统:可组合的查询构建器

过滤器是任何数据表中最繁琐的部分之一。每个过滤器都需要一个 UI 组件、一个查询参数以及后端处理逻辑。将其乘以我们支持的 20 多种过滤器类型,再乘以使用它们的 40 个页面,你就会得到重复代码的组合式爆炸。

蓝图过滤器系统通过一个 Filter 类解决了这个问题,它为每种过滤器类型提供了静态方法:

from frontend.filters import Filter

"filters": [
    # Dropdown - fetches options from API endpoint
    Filter.DROPDOWN("Account", "account__id", "accounts"),

    # Boolean - Yes/No selection with optional default
    Filter.BOOLEAN("Is Active", "is_active", default_value=True),

    # Number range - returns TWO filter configs (min + max)
    *Filter.NUMBER_RANGE("Amount", "amount"),

    # Date range - from/to pair
    *Filter.DATE_FROM_TO("Created", "created_at"),

    # Datetime range with apply button
    Filter.DATETIME_RANGE("Time Range", "transaction_time"),

    # Fixed date range - predefined periods
    Filter.FIXED_DATE_RANGE("Period", "created_at"),
    # Options: today, yesterday, this_week, last_7_days,
    # last_30_days, this_month, last_month, this_year, last_year

    # Static choices
    Filter.CHOICE("Status", "status", [
        {"id": "active", "name": "Active"},
        {"id": "pending", "name": "Pending"},
    ]),

    # Enum to choices conversion
    Filter.CHOICE("Risk", "risk_status",
        Filter.CHOICES_FROM_ENUM(RiskStatus)),

    # Multi-select dropdown
    Filter.MULTI_SELECT_DROPDOWN("Symbol", "symbol__in", "instruments"),
]

每个 Filter 方法都返回一个字典,JavaScript 表格渲染器知道如何处理它。开发者永远不用写筛选器的 HTML,永远不用挂接事件处理器,永远不用拼接查询字符串。他们只需声明想要筛选什么,其余的交给系统处理。展开运算符(*Filter.NUMBER_RANGE())尤其令人满意——一次调用就展开成分别用于最小值和最大值的两个独立筛选器配置。

Form Blueprints:无需 Form 类的 Django 表单

表格蓝图负责数据展示。但管理页面还需要表单——创建、编辑和批量操作表单。传统的 Django 做法是为每个操作编写一个 forms.FormModelForm 类。有了 40 个页面、每个页面又有多个操作,这就意味着大量的表单类。

表单蓝图彻底消除了它们。表单被定义为一个带有字段规格的字典,而一个 DynamicForm 类会在运行时生成 Django 表单:

from frontend.forms.forms import InputsFields, InputWidgets
from frontend.forms.blueprints import FORM_BLUEPRINTS, FormBlueprint

ACCOUNT_CREATE_BLUEPRINT: FormBlueprint = {
    "name": "account_create",
    "title": "Create Account",
    "description": "Create a new account",
    "column_count": 2,
    "modal_size": "xl",
    "method": "POST",
    "api_config": "account:accounts_v1",
    "success_message": "Account created successfully",
    "fields": [
        {
            "name": "acc_name",
            "type": InputsFields.CHAR_FIELD,
            "widget": InputWidgets.TEXT_INPUT,
            "label": "Account Name",
            "required": True,
            "max_length": 255,
            "row_class": "col-md-6",
        },
        {
            "name": "currency",
            "type": InputsFields.CHOICE_FIELD,
            "label": "Currency",
            "permission": "account_currency_update",
            "api_choices": "common:unified_search",
            "api_choices_args": ["assets-v1"],
            "api_choices_autocomplete": True,
            "row_class": "col-md-6",
        },
    ],
}

FORM_BLUEPRINTS["account_create"] = ACCOUNT_CREATE_BLUEPRINT

DynamicForm 类读取蓝图并创建真正的 Django 表单字段,配以恰当的控件、验证和布局。它将 InputsFields.CHAR_FIELD 映射为 forms.CharField,将 InputWidgets.TEXT_INPUT 映射为 forms.TextInput,并通过 Select2 处理由 API 驱动的自动补全下拉框。每个字段上的 permission 键控制当前用户能否编辑该字段——如果用户缺少相应权限,字段会渲染为只读,而不是被隐藏。

DynamicForm 引擎:从蓝图到 Django Form

魔法发生在 DynamicForm 类中,它继承自 Django 的 forms.Form,并根据蓝图配置动态创建字段:

class DynamicForm(forms.Form):
    FIELD_MAP = {
        InputsFields.CHAR_FIELD: forms.CharField,
        InputsFields.CHOICE_FIELD: forms.ChoiceField,
        InputsFields.INTEGER_FIELD: forms.IntegerField,
        InputsFields.FLOAT_FIELD: forms.FloatField,
        InputsFields.BOOLEAN_FIELD: forms.BooleanField,
        InputsFields.DATE_FIELD: forms.DateField,
        # ... 16 field types total
    }

    WIDGET_MAP = {
        InputWidgets.TEXT_INPUT: forms.TextInput,
        InputWidgets.SELECT: forms.Select,
        InputWidgets.SELECT2: Select2Widget,
        InputWidgets.CHECKBOX_INPUT: forms.CheckboxInput,
        # ... 15 widget types total
    }

    def __init__(self, *args, **kwargs):
        self.request = kwargs.pop("request", None)
        self.submit_url = kwargs.pop("submit_url", None)
        super().__init__(*args, **kwargs)
        self.load_fields_from_config()  # Blueprint -> Django fields
        self._setup_crispy_form_helper()  # Layout with Crispy Forms

    def load_fields_from_config(self):
        for field_config in self.scheme.get("fields", []):
            field_class = self.FIELD_MAP[field_config["type"]]
            widget_class = self.WIDGET_MAP.get(field_config.get("widget"))
            # Build kwargs: required, max_length, label, help_text...
            self.fields[field_config["name"]] = field_class(**kwargs)

随后 FormRenderer 类接管这个表单,应用基于权限的字段限制(对缺少所需权限的用户将字段设为只读),从蓝图的 api_config 构建提交 URL,并通过 Crispy Forms 将一切渲染进一个 Bootstrap 模态框。整条流水线——从字典到带 AJAX 提交的已渲染模态框——无需开发者编写任何模板代码。

请求流:一个视图服务 40 个页面

整个 blueprint 系统只通过一个 Django 视图进行路由。后台中的每一个页面都命中相同的 URL 模式、相同的视图函数以及相同的模板:

# urls.py —— 用一条 URL 规则处理一切
urlpatterns = [
    path("int-data/<str:view_blueprint_name>/",
         views.int_view, name="int_view"),
]

# views.py —— 用一个视图函数处理一切
@login_required
def int_view(request, view_blueprint_name) -> HttpResponse:
    blueprint_key = view_blueprint_name.replace("-", "_")

    # 查找对应的蓝图函数
    blueprint = BLUEPRINTS.get(blueprint_key)
    if not blueprint:
        raise PermissionDenied(f"Blueprint '{blueprint_key}' not found")

    # 校验区块级别的权限
    if not request.user.is_superuser:
        user_access = get_user_section_access(request.user, blueprint_key)
        if not user_access.get("can_read", False):
            raise PermissionDenied

    # 执行蓝图函数并渲染
    context = blueprint(request)
    return render(request, "base_table_page.html", context)

URL /int-data/accounts/ 调用 accounts_bp(request)。URL /int-data/balances/ 调用 balances_bp(request)。相同的视图,相同的模板,不同的数据。模板将蓝图字典作为上下文接收,把它序列化进 JSON 脚本标签,接下来便由 JavaScript 表格渲染器接管。

各层级的权限控制

蓝图系统在三个层级上强制执行权限,而且它们全都在蓝图配置中声明——而不是散落在各个视图里的独立权限检查:

  • 区块层级:用户到底能否访问这个页面?在蓝图函数被调用之前,就已在 int_view 中检查。
  • 操作层级:用户能看到「编辑」按钮吗?「批准」按钮呢?蓝图中的每个操作都有一个 permission 键。操作会在客户端根据用户的角色进行过滤。
  • 字段层级:用户能编辑货币字段吗?佣金比率呢?每个表单字段都有一个可选的 permission 键。如果用户缺少该权限,字段会渲染为带有视觉指示的只读状态——而不是隐藏,因为他们仍然应当能看到数据。

这种分层方式意味着单个蓝图定义同时处理了界面和访问控制。没有需要维护的独立权限文件,没有视图上的装饰器堆栈,也没有检查 {% if perms.app.can_edit %} 的模板条件判断。

嵌套字段访问与格式化器

真实世界的数据从来都不是扁平的。一个账户有一种货币,货币又有一个名称。一个订单有一位交易员,交易员又有一个邮箱。蓝图头部系统用点号表示法处理嵌套字段访问:

"headers": {
    "ID": "id",                          # 简单字段
    "Currency": "currency.asset_name",    # 嵌套:obj.currency.asset_name
    "Group": "group.name",               # 嵌套:obj.group.name
    "Risk Profile": "group.risk_profile.name",  # 三层嵌套
    "Is Active": {                        # 高级配置
        "field": "is_active",
        "is_editable": True,              # 支持行内编辑
        "permission": "accounts_can_update",
    },
    "Amount": {
        "field": "amount",
        "round": True,                    # 对数值取整
        "formatter_args": (2,),           # 保留 2 位小数
    },
},

# 格式化器将原始值转换为展示值
"formatters": {
    "Is Active": "ToBool",       # true/false -> 对勾/叉号
    "Account": "ToAccountLink",  # ID -> 可点击链接
    "Created At": "ToDateTime",  # ISO 字符串 -> 格式化日期
}

JavaScript 表格渲染器通过遍历 API 响应中的嵌套对象来解析 "currency.asset_name"。格式化器是按名称注册的 JavaScript 函数——蓝图只需声明某一列使用哪个格式化器。添加一个新格式化器只需定义一个函数,随后它便立即可供系统中的每一个蓝图使用。

全局操作:从配置驱动的批量操作

最强大的功能之一是全局操作——作用于所选行的批量操作。用户勾选多个账户,点击"更改风险状态",填写一个模态表单,然后所有选中的账户就在一次 API 调用中被更新。

整个流程都在蓝图中配置:

"global_actions": {
    "risk_status": {
        "permission": "account_risk_update",
        "title": "Change Risk Status",
        "description": "Update risk status for selected accounts",
        "form": serialize_blueprint_form("account_risk_status_bulk_action"),
        "app": "account",
        "url_name": reverse_lazy("account:bulk_updates_v1"),
        "method": "PATCH",
    },
    "activate": {
        "permission": "account_activate",
        "title": "Activate / Deactivate",
        "description": "Toggle active status",
        "form": serialize_blueprint_form("account_is_active_bulk_action"),
        "app": "account",
        "url_name": reverse_lazy("account:bulk_updates_v1"),
        "method": "PATCH",
    },
}

serialize_blueprint_form 函数接收一个表单蓝图名称,生成 Django 表单,将其渲染为 HTML,并序列化进页面上下文。当用户点击该操作时,JavaScript 会打开一个带有预渲染表单的模态框,收集选中的行 ID,并通过 AJAX 提交所有内容。账户页面上有八个批量操作,每个都有自己的表单,而每个操作唯一特定的代码就是它的蓝图定义。

这套架构消除了什么

在用蓝图构建了 40 多个页面之后,以下是这个代码库中包含的东西:

  • 零个页面专属模板——每个 admin 页面都使用 base_table_page.html
  • 零个页面专属 JavaScript——表格渲染器、过滤处理器和操作管理器都是通用的
  • 零个页面专属 URL 模式——一个带有动态参数的 URL 模式服务于一切
  • 零个表单类文件——表单由蓝图生成
  • 零个手动菜单注册——装饰器自动构建侧边栏

添加一个新的 admin 页面只需一个 Python 文件,其中包含一个返回字典的、被装饰的函数。没有新模板、没有新 URL、没有新 JavaScript、没有菜单配置。第 41 个页面所需的工作量与第 2 个相同。

Django Admin 的传承脉络

这套系统是 Django admin 理念的直系后裔。Django admin 说:“给我一个模型,我就把 UI 搭出来。” 而 Blueprints 说:“给我一个字典,我就把 UI 搭出来。”

区别在于,Django admin 与 Django 模型和 ORM 紧耦合。而 Blueprints 与数据源解耦——它们可以配合任何 API 端点、任何数据格式、任何后端工作。你可以让一个 blueprint 指向 REST API、GraphQL 端点,或一个静态 JSON 文件,渲染结果都会完全一致。

但核心洞见是相同的:只要你描述清楚数据结构和交互模式,框架就能把界面搭建出来。你不需要手工打造每一张表格、每一个筛选器、每一个表单、每一次权限检查。你只需描述你想要什么,让系统去渲染它。

Django admin 在 2005 年就证明了这是可行的。Blueprints 则证明它能在 2025 年扩展到复杂、定制、由 API 驱动的后台应用。

蓝图不是一条捷径——它是一个论断:admin 页面是数据,而非代码。一旦你接受了这一点,模板、URL 模式、表单类以及 JavaScript 就都成了框架的问题,而不再是你的问题。

联系我 →