mvc一对多模型表单的快速构建
功能需求描述
Q:在实际的开发中,经常会遇到一个模型中包含有多个条目的表单。如何将数据提交到后台?
A: 以数组的形式提交到后台就Ok了(真的那么简单么,如果再嵌套一层呢?)
A2:拆分多个模型,映射就没啥问题了。但......有点麻烦啊~~
接下来说说如何将下面的模型提交到后台
///
/// 计划模型
///
public class PlanModel
{
public int Id{ get; set; }
///
/// 计划名称
///
public string PlanName { get; set; }
///
/// 描述
///
public string Remark { get; set; }
///
/// 方案集合
///
public List Cases { get; set; }
}
///
/// 方案模型
///
public class CaseModel
{
public int Id{ get; set; }
///
/// 标题
///
public string Title { get; set; }
///
/// 描述
///
public string Description { get; set; }
///
/// 作者
///
public string Author { get; set; }
}
根据此模型,编辑的页面会如下图所示,一些基本信息加上可增可减的条目

实现效果

如何实现这个功能(asp.net mvc)
- 新建视图页面(略)
- 条目的显示增加删除
控制器代码
public class HomeController : Controller
{
[HttpGet]
public ActionResult Index()
{
var model = new PlanModel() {};
return View(model);
}
public ActionResult CaseRow()
{
return View("_CaseRow", new CaseModel());
}
[HttpPost]
public ActionResult Form(PlanModel model)
{
return Json(model);
}
}
编辑页条目显示代码
方案名称
方案作者
方案描述
操作
@if (Model.Cases != null)
{
foreach (var item in Model.Cases)
{
Html.RenderPartial("_CaseRow", item);
}
}
页面增加/删按钮js代码 + 验证
_CaseRow.cshtml分部视图代码
若要以集合/数组的形式提交到后台,须以name[]的格式提交,所以我能想到的就是这样去写(这种方案不可取!!)
但是这样写的话且不说太麻烦,验证也不行,一不小心也就写错了。所以这种方案并不可取
@{
Layout = null;
KeyValuePair keyValuePair = new KeyValuePair("Cases", Guid.NewGuid().ToString("N"));
var prefix = keyValuePair.Key+"["+keyValuePair.Value+"].";
}
@model MvcDemo.Models.CaseModel
@Html.TextBox(prefix+nameof(Model.Author),Model.Author, new { @class = "form-control" })
@Html.TextBox(prefix + nameof(Model.Description), Model.Description, new { @class = "form-control" })
而后发现大神写的一个HtmlPrefixScopeExtensions扩展类,可自动生成的表单前缀标识,使用方便,也能够使用验证
只需将表单包裹在@using (Html.BeginCollectionItem("子集合的属性名称")){}中即可,文末分享
@{
Layout = null;
}
@model MvcDemo.Models.CaseModel
@using MvcDemo.Extensions
@using (Html.BeginCollectionItem("Cases"))
{
@Html.HiddenFor(e => e.Id)
@Html.TextBoxFor(e => e.Title, new { @class = "form-control" })
@Html.ValidationMessageFor(e => e.Title)
@Html.TextBoxFor(e => e.Author, new { @class = "form-control" })
@Html.TextBoxFor(e => e.Description, new { @class = "form-control" })
}
然后提交表单可以发现格式如下,并能取到数据

MvcDemo.Extensions命名空间下的HtmlPrefixScopeExtensions扩展类
命名空间自行引用
- asp.net mvc版本
public static class HtmlPrefixScopeExtensions
{
private const string IdsToReuseKey = "__htmlPrefixScopeExtensions_IdsToReuse_";
///
///
///
///
///
/// 是否使用虚拟表单,为了解决上下文中不存在表单,无法生成验证信息
///
public static IDisposable BeginCollectionItem(this HtmlHelper html, string collectionName,
bool createDummyForm = false, bool clientValidationEnabled = false)
{
if (clientValidationEnabled == true)
html.ViewContext.ClientValidationEnabled = true;
if (createDummyForm == true)
{
if (html.ViewContext != null && html.ViewContext.FormContext == null)
{
var dummyFormContext = new FormContext();
html.ViewContext.FormContext = dummyFormContext;
}
}
return BeginCollectionItem(html, collectionName, html.ViewContext.Writer);
}
private static IDisposable BeginCollectionItem(this HtmlHelper html, string collectionName, TextWriter writer)
{
var idsToReuse = GetIdsToReuse(html.ViewContext.HttpContext, collectionName);
var itemIndex = idsToReuse.Count > 0 ? idsToReuse.Dequeue() : Guid.NewGuid().GetHashCode().ToString("x");
writer.WriteLine(
"",
collectionName, html.Encode(itemIndex));
return BeginHtmlFieldPrefixScope(html, string.Format("{0}[{1}]", collectionName, itemIndex));
}
private static IDisposable BeginHtmlFieldPrefixScope(this HtmlHelper html, string htmlFieldPrefix)
{
return new HtmlFieldPrefixScope(html.ViewData.TemplateInfo, htmlFieldPrefix);
}
private static Queue GetIdsToReuse(HttpContextBase httpContext, string collectionName)
{
var key = IdsToReuseKey + collectionName;
var queue = (Queue)httpContext.Items[key];
if (queue == null)
{
httpContext.Items[key] = queue = new Queue();
var previouslyUsedIds = httpContext.Request[collectionName + ".index"];
if (!string.IsNullOrEmpty(previouslyUsedIds))
foreach (var previouslyUsedId in previouslyUsedIds.Split(','))
queue.Enqueue(previouslyUsedId);
}
return queue;
}
internal class HtmlFieldPrefixScope : IDisposable
{
internal readonly TemplateInfo TemplateInfo;
internal readonly string PreviousHtmlFieldPrefix;
public HtmlFieldPrefixScope(TemplateInfo templateInfo, string htmlFieldPrefix)
{
TemplateInfo = templateInfo;
PreviousHtmlFieldPrefix = TemplateInfo.HtmlFieldPrefix;
TemplateInfo.HtmlFieldPrefix = htmlFieldPrefix;
}
public void Dispose()
{
TemplateInfo.HtmlFieldPrefix = PreviousHtmlFieldPrefix;
}
}
}
- asp.net core版本
public static class HtmlPrefixScopeExtensions
{
private const string IdsToReuseKey = "__htmlPrefixScopeExtensions_IdsToReuse_";
public static IDisposable BeginCollectionItem(this IHtmlHelper html, string collectionName)
{
return BeginCollectionItem(html, collectionName, html.ViewContext.Writer);
}
private static IDisposable BeginCollectionItem(this IHtmlHelper html, string collectionName, TextWriter writer)
{
if (html.ViewData["ContainerPrefix"] != null)
collectionName = string.Concat(html.ViewData["ContainerPrefix"], ".", collectionName);
var idsToReuse = GetIdsToReuse(html.ViewContext.HttpContext, collectionName);
var itemIndex = idsToReuse.Count > 0 ? idsToReuse.Dequeue() : Guid.NewGuid().ToString();
string htmlFieldPrefix = $"{collectionName}[{itemIndex}]";
html.ViewData["ContainerPrefix"] = htmlFieldPrefix;
/*
* html.Name(); has been removed
* because of incorrect naming of collection items
* e.g.
* let collectionName = "Collection"
* the first item's name was Collection[0].Collection[]
* instead of Collection[]
*/
string indexInputName = $"{collectionName}.index";
// autocomplete="off" is needed to work around a very annoying Chrome behaviour
// whereby it reuses old values after the user clicks "Back", which causes the
// xyz.index and xyz[...] values to get out of sync.
writer.WriteLine($@"");
return BeginHtmlFieldPrefixScope(html, htmlFieldPrefix);
}
private static IDisposable BeginHtmlFieldPrefixScope(this IHtmlHelper html, string htmlFieldPrefix)
{
return new HtmlFieldPrefixScope(html.ViewData.TemplateInfo, htmlFieldPrefix);
}
private static Queue GetIdsToReuse(HttpContext httpContext, string collectionName)
{
// We need to use the same sequence of IDs following a server-side validation failure,
// otherwise the framework won't render the validation error messages next to each item.
var key = IdsToReuseKey + collectionName;
var queue = (Queue)httpContext.Items[key];
if (queue == null)
{
httpContext.Items[key] = queue = new Queue();
if (httpContext.Request.Method == "POST" && httpContext.Request.HasFormContentType)
{
StringValues previouslyUsedIds = httpContext.Request.Form[collectionName + ".index"];
if (!string.IsNullOrEmpty(previouslyUsedIds))
foreach (var previouslyUsedId in previouslyUsedIds)
queue.Enqueue(previouslyUsedId);
}
}
return queue;
}
internal class HtmlFieldPrefixScope : IDisposable
{
internal readonly TemplateInfo TemplateInfo;
internal readonly string PreviousHtmlFieldPrefix;
public HtmlFieldPrefixScope(TemplateInfo templateInfo, string htmlFieldPrefix)
{
TemplateInfo = templateInfo;
PreviousHtmlFieldPrefix = TemplateInfo.HtmlFieldPrefix;
TemplateInfo.HtmlFieldPrefix = htmlFieldPrefix;
}
public void Dispose()
{
TemplateInfo.HtmlFieldPrefix = PreviousHtmlFieldPrefix;
}
}
}
命名空间自行引用~~
End
完整源码:https://coding.net/u/yimocoding/p/WeDemo/git/tree/MvcFormExt/MvcFormExt/MvcDemo