You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Django菜谱应用:实现横向食材输入组及动态添加字段

方案选择建议

不推荐用ArrayField,虽然它能存储数组,但关系型数据库中用关联模型更符合设计规范,后续扩展(比如食材分类、批量修改)也更方便。结合Django的InlineFormSet和前端JavaScript是最简洁的方案——FormSet帮你处理多表单的验证与保存逻辑,JS负责动态添加输入行,完全适配你的需求。


问题a:实现横向排列的食材输入组

利用Tailwind的flex布局,把食材名、数量、单位三个输入框放在同一行,具体步骤如下:

1. 调整数据模型(推荐方案)

创建Ingredient模型关联Recipe,单位用下拉选项保证输入规范:

# models.py
from django.db import models
from django.contrib.auth.models import User

class Recipe(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(User, on_delete=models.CASCADE)
    description = models.TextField()

# 定义常用单位选项
UNIT_CHOICES = [
    ('g', '克'),
    ('kg', '千克'),
    ('ml', '毫升'),
    ('l', '升'),
    ('个', '个'),
    ('根', '根'),
]
class Ingredient(models.Model):
    recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, related_name='ingredients')
    name = models.CharField(max_length=100, verbose_name='食材名')
    quantity = models.IntegerField(verbose_name='数量')
    unit = models.CharField(max_length=20, choices=UNIT_CHOICES, verbose_name='单位')

2. 视图中配置InlineFormSet

用InlineFormSet生成多食材输入表单,默认显示5行:

# views.py
from django.forms import inlineformset_factory
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic.edit import CreateView
from .models import Recipe, Ingredient

class RecipeCreateView(LoginRequiredMixin, CreateView):
    model = Recipe
    fields = ['title', 'description']

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        # 生成InlineFormSet,默认显示5行,暂时关闭删除功能
        IngredientFormSet = inlineformset_factory(
            Recipe, Ingredient,
            fields=('name', 'quantity', 'unit'),
            extra=5,
            can_delete=False
        )
        if self.request.POST:
            context['formset'] = IngredientFormSet(self.request.POST, instance=self.object)
        else:
            context['formset'] = IngredientFormSet(instance=self.object)
        return context

    def form_valid(self, form):
        self.object = form.save(commit=False)
        self.object.author = self.request.user
        self.object.save()
        # 处理食材表单集的保存
        formset = self.get_context_data()['formset']
        if formset.is_valid():
            formset.instance = self.object
            formset.save()
        return super().form_valid(form)

3. 模板中实现横向布局

用Tailwind的flex类让三个输入框并排显示:

<!-- recipe_form.html -->
{% extends 'recipes/base.html' %}
{% load tailwind_filters %}

{% block content %}
<div>
    <h1 class="text-2xl my-4">添加菜谱</h1>
    <form method="POST">
        {% csrf_token %}
        {{ form|crispy }}

        <div class="mb-6">
            <h3 class="text-lg font-semibold mb-3">食材列表</h3>
            {{ formset.management_form }} <!-- 表单集必需的管理字段,自动隐藏 -->
            
            {% for form in formset %}
                <div class="ingredient-row flex gap-3 mb-2">
                    <!-- 每个输入框占等宽,用flex-1控制 -->
                    <div class="flex-1">{{ form.name|as_crispy_field }}</div>
                    <div class="flex-1">{{ form.quantity|as_crispy_field }}</div>
                    <div class="flex-1">{{ form.unit|as_crispy_field }}</div>
                    {{ form.id }} <!-- 表单集需要的隐藏ID字段 -->
                </div>
            {% endfor %}

            <button type="button" id="add-ingredient" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">添加食材</button>
        </div>

        <input class="px-8 py-2 text-white bg-orange-600 rounded-lg hover:bg-orange-700 hover:cursor-pointer transition-colors" type="submit" value="保存菜谱">
    </form>
    <div class="border-top pt-3 mt-4">
        <a href="{% url 'recipes-home' %}" class="text-blue-600 hover:underline">取消</a>
    </div>
</div>
{% endblock content %}

问题b:动态添加输入行的实现

在模板末尾添加JS代码,实现点击按钮新增食材输入行:

<!-- 放在recipe_form.html的{% endblock content %}之前 -->
<script>
document.addEventListener('DOMContentLoaded', function() {
    const addBtn = document.getElementById('add-ingredient');
    const ingredientContainer = document.querySelector('.mb-6');
    const totalFormsInput = document.getElementById('id_form-TOTAL_FORMS');

    addBtn.addEventListener('click', function() {
        const currentFormCount = parseInt(totalFormsInput.value);
        // 克隆最后一行输入组
        const lastRow = document.querySelector('.ingredient-row:last-child');
        const newRow = lastRow.cloneNode(true);

        // 更新新行的字段name、ID和label关联,适配表单集命名规则
        const elements = newRow.querySelectorAll('input, select, label');
        elements.forEach(element => {
            if (element.name) {
                element.name = element.name.replace(`-${currentFormCount-1}-`, `-${currentFormCount}-`);
            }
            if (element.id) {
                element.id = element.id.replace(`-${currentFormCount-1}-`, `-${currentFormCount}-`);
            }
            if (element.tagName === 'LABEL') {
                element.setAttribute('for', element.getAttribute('for').replace(`-${currentFormCount-1}-`, `-${currentFormCount}-`));
            }
            // 清空输入值(隐藏字段除外)
            if (element.tagName === 'INPUT' && element.type !== 'hidden') {
                element.value = '';
            }
            if (element.tagName === 'SELECT') {
                element.selectedIndex = 0;
            }
        });

        // 添加新行到容器
        ingredientContainer.appendChild(newRow);
        // 更新总表单数,让后端识别新增的表单
        totalFormsInput.value = currentFormCount + 1;
    });
});
</script>

核心逻辑:

  1. 读取表单集的TOTAL_FORMS隐藏字段,记录当前表单数量
  2. 克隆最后一行输入组,修改所有字段的命名索引(比如从form-0-name改为form-1-name)
  3. 清空新行的输入值,添加到页面中
  4. 更新TOTAL_FORMS数值,保证后端能正确处理新增的表单数据

替代方案:用ArrayField(不推荐)

如果坚持用ArrayField,需要:

  1. 在Recipe模型中把ingredients改为ArrayField(models.JSONField()),存储每个食材的JSON对象
  2. 前端用JS收集所有输入行的数据,拼成JSON数组提交
  3. 手动处理表单数据的验证与保存

这种方案代码量少,但后续维护和扩展难度大(比如筛选含特定食材的菜谱会非常麻烦),因此优先推荐关联模型+InlineFormSet的方案。

内容的提问来源于stack exchange,提问作者C0untV

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.21 01:32:03