蓝图:我如何用 Python 字典替换了数百个模板
一套蓝图系统,将 Python 字典转化为完整的 admin 页面——表格、筛选器、表单、动作、权限和菜单——完全无需 HTML 模板。看一个装饰器、一个 TypedDict 和一份 JSON 契约如何消除了一整类重复的前端工作。
问题所在: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.Form 或 ModelForm 类。有了 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 就都成了框架的问题,而不再是你的问题。