怎么样在Django里实现联动下拉列表选项?

从属或链接下拉列表是一个特殊字段,它依赖于先前选择的字段,以显示已过滤选项的列表。最常见的地方就是通过地址里选择省份,然后显示出该省份下所有城市列表。

以下面的应用程序为例:

models.py

from django.db import models
class Country(models.Model):
    name = models.CharField(max_length=30)
    def __str__(self):
        return self.name
class City(models.Model):
    country = models.ForeignKey(Country, on_delete=models.CASCADE)
    name = models.CharField(max_length=30)
    def __str__(self):
        return self.name
class Person(models.Model):
    name = models.CharField(max_length=100)
    birthdate = models.DateField(null=True, blank=True)
    country = models.ForeignKey(Country, on_delete=models.SET_NULL, null=True)
    city = models.ForeignKey(City, on_delete=models.SET_NULL, null=True)
    def __str__(self):
        return self.name

在应用程序中,我们将创建一个简单的表单处理来创建和更新人物对象。这个联动列表用来选择人物的国家和所在城市。

urls.py

from django.urls import include, path
from . import views
urlpatterns = [
    path('', views.PersonListView.as_view(), name='person_changelist'),
    path('add/', views.PersonCreateView.as_view(), name='person_add'),
    path('<int:pk>/', views.PersonUpdateView.as_view(), name='person_change'),
]

创建三个视图函数:

views.py

from django.views.generic import ListView, CreateView, UpdateView
from django.urls import reverse_lazy
from .models import Person
class PersonListView(ListView):
    model = Person
    context_object_name = 'people'
class PersonCreateView(CreateView):
    model = Person
    fields = ('name', 'birthdate', 'country', 'city')
    success_url = reverse_lazy('person_changelist')
class PersonUpdateView(UpdateView):
    model = Person
    fields = ('name', 'birthdate', 'country', 'city')
    success_url = reverse_lazy('person_changelist')

我们运行它,发现这个功能和我们想要要的不太一样,我们是想要根据选择国家,然后显示出该国家下的所有城市。但现在程序把所有国家的城市都给列出来了。

dependent-dropdown-1.png

用一个简单的HTML代码把效果显示出来:

{% extends 'base.html' %}
{% block content %}
  <h2>Person Form</h2>
  <form method="post" novalidate>
    {% csrf_token %}
    <table>
      {{ form.as_table }}
    </table>
    <button type="submit">Save</button>
    <a href="{% url 'person_changelist' %}">Nevermind</a>
  </form>
{% endblock %}

实现它的最佳方法是创建一个模型表单。通过这种方式,我们可以非常灵活地实现我们想要的功能。

forms.py

from django import forms
from .models import Person, City
class PersonForm(forms.ModelForm):
    class Meta:
        model = Person
        fields = ('name', 'birthdate', 'country', 'city')
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields['city'].queryset = City.objects.none()

上面的示例有一个比较重要细的地方,我们简单的定义表单:现我们将覆盖默认的init方法,并将city字段的queryset设置为空的列表:

dependent-dropdown-2.png


PS:不要忘记修改视图定义,使用我们的新表单类:
views.py

class PersonCreateView(CreateView):
    model = Person
    form_class = PersonForm
    success_url = reverse_lazy('person_changelist')
class PersonUpdateView(UpdateView):
    model = Person
    form_class = PersonForm
    success_url = reverse_lazy('person_changelist')


现在我们需要创建一个视图来返回给定国家/地区的城市列表。该视图将通过使用AJAX进行请求。

views.py

def load_cities(request):
    country_id = request.GET.get('country')
    cities = City.objects.filter(country_id=country_id).order_by('name')
    return render(request, 'hr/city_dropdown_list_options.html', {'cities': cities})

用这样简单的基于函数的视图就可以非常好的实现。下面是我们的HTML模板

templates/hr/city_dropdown_list_options.html

<option value="">---------</option>
{% for city in cities %}
<option value="{{ city.pk }}">{{ city.name }}</option>
{% endfor %}

然后我们给之前的视图函数创建一个URL:

urls.py

from django.urls import include, path
from . import views
urlpatterns = [
    path('', views.PersonListView.as_view(), name='person_changelist'),
    path('add/', views.PersonCreateView.as_view(), name='person_add'),
    path('<int:pk>/', views.PersonUpdateView.as_view(), name='person_change'),
    path('ajax/load-cities/', views.load_cities, name='ajax_load_cities'),  # <--新增这个
]

现在建一个AJAX请求。在下面的示例中,我使用的是jQuery,但可以使用任何JavaScript框架(或只是简单的JavaScript)来创建异步请求:

templates/person_form.html

{% extends 'base.html' %}
{% block content %}
  <h2>Person Form</h2>
  <form method="post" id="personForm" data-cities-url="{% url 'ajax_load_cities' %}" novalidate>
    {% csrf_token %}
    <table>
      {{ form.as_table }}
    </table>
    <button type="submit">Save</button>
    <a href="{% url 'person_changelist' %}">Nevermind</a>
  </form>
  <script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
  <script>
    $("#id_country").change(function () {
      var url = $("#personForm").attr("data-cities-url");  // 获取 `load_cities` view
      var countryId = $(this).val();  // 在HTML中获取选取的国家ID
      $.ajax({                       // 初始化 AJAX 请求
        url: url,                    //设置请求地址(= localhost:8000/hr/ajax/load-cities/)
        data: {
          'country': countryId       // countryId传到GET参数
        },
        success: function (data) {   //  视图函数 `load_cities`返回到`data`
          $("#id_city").html(data);  // 用返回的数据替换城市输入框的内容
        }
      });
    });
  </script>
{% endblock %}

首先,我为表单(personForm)添加了一个ID,以便我们可以更容易地访问它。之后,我将数据属性添加到专用章data-cities-url。

然后,在此之后,我们在国家/地区下拉列表中有一个监听器,设置id_country标识。此ID由Django自动生成。当它发生变化时,它会向服务器发出一个AJAX请求,并将所选的国家ID传递给我们的视图。

请求成功后,我们的脚本将在cities下拉列表中添加由load_cities视图呈现的HTML,由HTML ID id_city标识。

dependent-dropdown-3.png

现在前端已经做好了,但是后端并没有按预期工作。如果我们现在提交表单,我们将看到以下错误消息:

dependent-dropdown-4.png

那是因为我们在表单定义中列出了空的城市列表。这个错误消息是我特意向你显示的,因为它实际上非常有用。它将帮助我们保持形式的一致性。这意味着Django表单检查查询集中是否存在提供的值。

修改一下代码:

forms.py

from django import forms
from .models import Person, City
class PersonForm(forms.ModelForm):
    class Meta:
        model = Person
        fields = ('name', 'birthdate', 'country', 'city')
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields['city'].queryset = City.objects.none()
        if 'country' in self.data:
            try:
                country_id = int(self.data.get('country'))
                self.fields['city'].queryset = City.objects.filter(country_id=country_id).order_by('name')
            except (ValueError, TypeError):
                pass  # invalid input from the client; ignore and fallback to empty City queryset
        elif self.instance.pk:
            self.fields['city'].queryset = self.instance.country.city_set.order_by('name')

这个表单将提供一个非常好的行为。如果有表单POST数据(数据不是无),它将使用收到的表单的国家ID加载城市列表。如果它是无效输入,只需丢弃它,表单将为用户提示错误信息。如果没有POST数据但表单中有实例(意味着正在使用该表单更新现有人员),请使用所选国家/地区的城市列表。如果没有,只需返回一个空的城市列表,因为它是一个全新的形式(self.fields ['city']已经设置为空的查询集,还记得吗?)。

或者,您可以从表单定义中完全删除country字段,因为它与城市无关。但我更喜欢将它保留在那里,因为在某些情况下您需要保存这两个值,并且在上面的示例中,您可以了解如何实现它而不必干扰表单的呈现过程。

最好的学习方法是自己尝试。检查GitHub上的代码并在本地尝试。修改这个例子,把它变成你的。如果您有任何疑问,请在下面的评论中留言!

本文例子效果:dependent-dropdown-example.herokuapp.com

代码:github.com/sibtc/dependent-dropdown-example/


这里一个已经写好的插件,主要是在DjangoAdmin里使用。下载地址:https://github.com/digi604/django-smart-selects

dependent-dropdown-4.png

文章评论 2

  • 请教下,正好有个类似的demo,前面字段选择后后面字段的内容联动更新,这个参考楼主的内容后已完成,但我想要的是后面字段的内容实现多选。实现多选的时候呢样式上就是一大个框,如果用了select多选的样式呢,下拉框内的选项又无法实现代码中的联动选择。请问能否提供一下思路呢, 谢谢!