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

如何验证用于创建多条记录的动态生成多输入项表单?

这确实是IHP批量表单处理里的一个常见痛点——官方示例大多聚焦单条记录,批量验证并收集所有错误需要换个思路。我来给你梳理下最优的实现方式:

核心思路

我们不能再依赖单条记录的ifValid逻辑(它会在第一个错误触发时直接返回Left分支),而是要:

  1. 从请求中提取所有批量输入项的数据
  2. 遍历每个输入项单独做验证
  3. 收集所有验证错误(而不是遇到第一个就中断)
  4. 根据错误情况,要么带着错误和原始数据回显表单,要么批量创建所有合法记录

具体代码实现

第一步:处理批量表单的Action逻辑

假设你的前端表单用item.title[]、item.body[]这种数组式命名(方便IHP批量获取参数),对应的Action代码如下:

{-# LANGUAGE OverloadedStrings #-}
module Web.View.Posts.CreatePosts where
import Web.View.Prelude
import IHP.Validation.Validation (toEither)
import Data.List (intercalate)

action CreatePostsAction = do
    -- 从请求中批量提取所有标题和内容
    let titles = paramList @Text "item.title"
        bodies = paramList @Text "item.body"
        -- 配对成完整的条目列表,先检查长度是否一致
        items = if length titles == length bodies
                then zip titles bodies
                else [] -- 长度不一致时视为无效输入

    -- 先处理长度不匹配的情况
    when (length titles /= length bodies) $ do
        setErrorMessage "所有条目必须同时填写标题和内容"
        render NewPostsView { titles, bodies }

    -- 定义单个条目的验证逻辑
    let validateItem :: (Text, Text) -> Either [ValidationError] Post
        validateItem (title, body) =
            newRecord @Post
                |> set #title title
                |> set #body body
                |> validateField #title nonEmpty
                |> validateField #body nonEmpty
                |> toEither -- 把Validation类型转成Either,方便收集错误

        -- 给每个条目加上索引,方便后续定位错误
        indexedResults = map (\(idx, item) -> (idx, validateItem item)) (zip [0..] items)
        -- 分离错误和合法记录
        itemErrors = [(idx, errs) | (idx, Left errs) <- indexedResults]
        validPosts = [post | (_, Right post) <- indexedResults]

    -- 根据错误情况分支处理
    if not (null itemErrors)
        then do
            -- 把错误信息传递给视图,同时保留用户的原始输入
            render NewPostsView { titles, bodies, itemErrors }
        else do
            -- 批量创建记录,用runDB包裹实现事务回滚
            runDB $ mapM_ createRecord validPosts
            setSuccessMessage $ "成功创建" <> show (length validPosts) <> "条记录"
            redirectTo PostsAction

第二步:视图层回显错误和原始数据

视图需要支持动态生成输入项,并且在验证失败时,给对应的输入项显示错误提示:

module Web.View.Posts.NewPosts where
import Web.View.Prelude

data NewPostsView = NewPostsView
    { titles :: [Text]
    , bodies :: [Text]
    , itemErrors :: [(Int, [ValidationError])] -- 存储索引对应的错误
    }

-- 默认视图(首次加载时没有错误)
instance View NewPostsView where
    html view@NewPostsView { titles, bodies, itemErrors } = [hsx|
        <div class="container">
            <h1>批量创建帖子</h1>
            {renderFlashMessages}
            <form method="POST" action={CreatePostsAction}>
                {forEach (zipWith3 (\idx title body -> renderItem idx title body itemErrors) [0..] titles bodies)}
                <button type="button" onclick="addNewItem()" class="btn btn-secondary mt-2">添加新条目</button>
                <button type="submit" class="btn btn-primary mt-3">提交所有</button>
            </form>
        </div>
        <script>{addItemScript}</script>
    |]

-- 渲染单个输入条目,包含错误提示
renderItem :: Int -> Text -> Text -> [(Int, [ValidationError])] -> Html
renderItem idx title body errors = [hsx|
    <div class="row mb-3">
        <div class="col">
            <input type="text" name="item.title[]" value={title} placeholder="标题" class="form-control {isInvalid idx}">
            {renderFieldErrors idx "title"}
        </div>
        <div class="col">
            <textarea name="item.body[]" placeholder="内容" class="form-control {isInvalid idx}">{body}</textarea>
            {renderFieldErrors idx "body"}
        </div>
    </div>
|]
    where
        -- 判断当前条目是否有错误
        isInvalid idx = if any (\(i, _) -> i == idx) errors then "is-invalid" else ""
        -- 渲染对应字段的错误信息
        renderFieldErrors idx fieldName =
            case lookup idx errors of
                Just errs -> [hsx|
                    <div class="invalid-feedback">
                        {forEach errs (\err -> if err.fieldName == Just fieldName then [hsx|<p>{err.message}</p>|] else mempty)}
                    </div>
                |]
                Nothing -> mempty

-- 前端动态添加新输入项的脚本
addItemScript :: Text
addItemScript = [js|
    function addNewItem() {
        const form = document.querySelector('form');
        const idx = document.querySelectorAll('.row.mb-3').length;
        const newItem = `
            <div class="row mb-3">
                <div class="col">
                    <input type="text" name="item.title[]" placeholder="标题" class="form-control">
                </div>
                <div class="col">
                    <textarea name="item.body[]" placeholder="内容" class="form-control"></textarea>
                </div>
            </div>
        `;
        form.insertAdjacentHTML('beforeend', newItem);
    }
|]

-- 首次加载视图的默认值
instance Default NewPostsView where
    def = NewPostsView { titles = ["", ""], bodies = ["", ""], itemErrors = [] }

关键细节说明

  1. 参数命名规范:前端必须用item.title[]这种数组式命名,IHP的paramList才能正确批量提取所有输入值
  2. 事务支持:用runDB $ mapM_ createRecord validPosts包裹批量创建逻辑,确保如果某条记录创建失败,所有操作都会回滚
  3. 错误定位:给每个条目加上索引,能精准地在对应的输入项下方显示错误,提升用户体验
  4. 原始数据保留:验证失败时把用户输入的titles和bodies传递回视图,避免用户重新输入

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.04.30 11:12:38