PAYATHON 2026

Django Bootstrap 表单如何显示 Confirm Password 校验错误?

技术老齐

可以在 ModelForm 中声明 password2,在 clean() 中比较两次密码,再用 self.add_error("password2", ...) 将错误绑定到确认密码字段。模板渲染 {{ form.password2 }} 及其错误信息即可,无须在 model 中增加字段。

用于用户注册时,建议使用 Django 的 UserCreationForm。它已经处理了密码确认、密码规则校验和密码哈希保存,能减少重复代码。

为什么现有代码没有产生确认密码错误

Meta.widgets 只配置表单字段使用的控件,不会创建字段。因此,在其中添加 'password2' 并不会让 form.password2 自动存在。

需要先在表单类中声明 password2,并在字段定义中设置 widget 和 error_messages。Meta 中的这些配置只作用于自动生成的字段。参见 Django ModelForm 字段配置说明。

原模板还有一处判断:

{% if form.errors %} is-invalid {% endif %}

这会让确认密码框在表单任何位置出现错误时都标红。判断字段状态时,应使用该字段自己的 errors。

在现有 ModelForm 中添加确认密码校验

# forms.py
from django import forms

from .models import UserMaster


class UserMasterForm(forms.ModelForm):
    password2 = forms.CharField(
        label="Confirm Password",
        strip=False,
        widget=forms.PasswordInput(
            attrs={
                "placeholder": "Confirm Password",
                "autocomplete": "new-password",
            }
        ),
        error_messages={
            "required": "请再次输入密码。",
        },
    )

    class Meta:
        model = UserMaster
        fields = ["username", "password"]

        widgets = {
            "username": forms.TextInput(
                attrs={"placeholder": "Enter User Name"}
            ),
            "password": forms.PasswordInput(
                attrs={
                    "placeholder": "Enter Password",
                    "autocomplete": "new-password",
                }
            ),
        }

        error_messages = {
            "username": {
                "required": "请输入用户名。",
                "unique": "该用户名已被使用。",
            },
            "password": {
                "required": "请输入密码。",
            },
        }

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        # 密码中的首尾空白应参与比较。
        self.fields["password"].strip = False

        for field in self.fields.values():
            field.widget.attrs["class"] = "form-control"

    def clean(self):
        cleaned_data = super().clean()
        password = cleaned_data.get("password")
        password2 = cleaned_data.get("password2")

        if (
            password is not None
            and password2 is not None
            and password != password2
        ):
            self.add_error(
                "password2",
                forms.ValidationError(
                    "两次输入的密码不一致。",
                    code="password_mismatch",
                ),
            )

        return cleaned_data

password2 是显式声明的表单字段,即使没有列入这里的 Meta.fields,也会参与表单校验。它没有对应的模型字段,因此不会自动保存到数据库。

校验时有几处需要注意:

  • 字段默认必填,确认密码为空时会自动产生 required 错误。
  • 字段校验失败后,可能不在 cleaned_data 中,因此使用 cleaned_data.get() 取值。
  • 两项都通过字段校验后才比较,避免在“必填”错误之外又出现“不一致”错误。
  • 保留 super().clean(),以维持 ModelForm 原有的校验流程,包括唯一性校验。

在 clean() 中直接抛出 ValidationError,通常会产生表单级错误。要把错误明确放到确认密码字段下,应使用 add_error("password2", ...)。参见 Django 表单校验文档。

这段代码只处理字段校验。原来的 UserMaster 是普通模型,直接调用该表单的 save() 会保存明文密码,不能直接用于真实账户注册。

注册用户时优先使用 UserCreationForm

如果项目使用 Django 默认用户模型,可以改用:

# forms.py
from django.contrib.auth.forms import UserCreationForm


class RegisterForm(UserCreationForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        for field in self.fields.values():
            field.widget.attrs["class"] = "form-control"

UserCreationForm 已经包含以下字段:

  • username
  • password1
  • password2

两次密码不一致时,错误会绑定到 password2。表单还会执行配置的密码校验器,并在保存时对密码进行哈希处理。参见 Django 用户创建表单说明。

这里的密码字段名是 password1。如果模板单独渲染密码字段,需要相应修改;后面的循环模板可以直接复用。

这个方案使用 Django 认证系统的用户模型,不能直接将其替换为题目中的普通 UserMaster 模型。项目若已有自定义用户模型,需要根据模型结构调整注册表单。

在校验失败后统一添加 Bootstrap 错误样式

密码比较继续使用服务器端校验即可,无须再用 JavaScript 实现一遍。Bootstrap 5 可以通过 .is-invalid 和 .invalid-feedback 展示服务器返回的错误,无须额外添加 .was-validated。参见 Bootstrap 5 校验样式说明。

使用 RegisterForm 的视图可以这样写:

# views.py
from django.shortcuts import redirect, render

from .forms import RegisterForm


def change_class_for_error(form):
    for field in form.visible_fields():
        if not field.errors:
            continue

        attrs = field.field.widget.attrs
        classes = attrs.get("class", "").split()

        if "is-invalid" not in classes:
            classes.append("is-invalid")

        attrs["class"] = " ".join(classes)
        attrs["aria-invalid"] = "true"

        describedby = attrs.get("aria-describedby", "").split()

        if field.help_text:
            describedby.append(f"{field.auto_id}_helptext")

        describedby.append(f"{field.auto_id}_error")
        attrs["aria-describedby"] = " ".join(
            dict.fromkeys(describedby)
        )


def register_view(request):
    if request.method == "POST":
        form = RegisterForm(request.POST)

        if form.is_valid():
            form.save()
            return redirect("login")

        change_class_for_error(form)
    else:
        form = RegisterForm()

    return render(
        request,
        "mainapp/register.html",
        {"form": form},
    )

change_class_for_error 会保留控件原有的 CSS 类,并将错误说明与对应输入框关联。它也适用于前面的 UserMasterForm,但密码保存逻辑仍需另行处理。

校验失败后,要将同一个绑定了 POST 数据的 form 返回模板;重新创建空表单会丢失错误信息。

用循环统一渲染字段和错误

用户名、密码和确认密码的展示结构相同,可以用循环渲染,新增字段时也不用复制整段 HTML。

使用以下模板前,页面需要加载 Bootstrap 5 CSS,项目也需要配置名为 login 的路由:

{# mainapp/register.html #}
<form method="post" class="mx-2" novalidate>
  {% csrf_token %}

  {% for error in form.non_field_errors %}
    <div class="alert alert-danger" role="alert">
      {{ error }}
    </div>
  {% endfor %}

  {% for hidden in form.hidden_fields %}
    {{ hidden }}
    {% for error in hidden.errors %}
      <div class="alert alert-danger" role="alert">
        {{ error }}
      </div>
    {% endfor %}
  {% endfor %}

  <div class="row g-3">
    {% for field in form.visible_fields %}
      <div class="col-md-6">
        <label
          for="{{ field.id_for_label }}"
          class="form-label"
        >
          {{ field.label }}
        </label>

        {{ field }}

        {% if field.errors %}
          <div
            id="{{ field.auto_id }}_error"
            class="invalid-feedback"
          >
            {% for error in field.errors %}
              <div>{{ error }}</div>
            {% endfor %}
          </div>
        {% endif %}

        {% if field.help_text %}
          <div
            id="{{ field.auto_id }}_helptext"
            class="form-text"
          >
            {{ field.help_text }}
          </div>
        {% endif %}
      </div>
    {% endfor %}
  </div>

  <div class="d-flex gap-3 mt-3 mb-2">
    <button type="submit" class="btn btn-primary">
      Save
    </button>

    <a href="{% url 'login' %}" class="btn btn-warning">
      Cancel
    </a>
  </div>
</form>

错误消息的标签需要在循环内正确闭合,原模板中的这一问题已在上面修正。取消操作使用独立的 <a>,避免在 <button> 中嵌套 <a>。

还需要注意什么

novalidate 只关闭浏览器提交时的原生表单校验,Django 的服务器端校验仍会执行。保留它,可以让必填等错误统一由服务器返回。

PasswordInput 默认不会在校验失败后回填密码。因此,即使只是用户名重复,用户也可能需要重新输入两次密码。

题目中的 password = models.CharField(max_length=15) 也需要调整。密码哈希字符串通常超过 15 个字符,单纯将明文密码换成哈希值,仍会遇到存储长度问题。使用 Django 认证用户模型及其密码 API,可以避免自行处理这些细节;普通 UserMaster 并不自带 set_password()。参见 Django 密码管理文档。

备注:内容仅供参考。