表单与验证

在本章中,你首先将添加让已登录用户创建新帖子的能力。然后,你将用相同的模式让用户对已有帖子发表评论。在此过程中,你将接触 AdonisJS 的验证层,并学习如何使用针对不同资源的独立控制器来组织代码。

Note

本教程涵盖基础的表单处理与验证。有关自定义验证规则、条件验证、错误消息自定义以及文件上传等进阶主题,请参阅 验证指南VineJS 文档

概述

到目前为止,在 DevShow 教程中,你构建了一个展示数据库中帖子的应用。但如何创建新帖子呢?这正是表单的用武之地。

处理表单涉及三个主要步骤:

  1. 显示一个表单来收集用户输入。
  2. 在服务端验证该输入,确保满足你的要求。
  3. 最后将已验证的数据保存到数据库。

AdonisJS 提供了 VineJS 来定义验证规则,而 Inertia 的 Form 组件则负责处理表单提交并自动进行错误处理。

添加帖子创建

让我们先添加让用户创建新帖子的能力。我们需要一个控制器方法来显示表单、将一切连接起来的路由,以及用于表单本身的 React 组件。

  1. 添加控制器方法

    首先,让我们向你的 PostsController 添加一个 create 方法,用于渲染创建新帖子的表单。我们还会先搁置一个 store 方法,稍后再实现它来处理表单提交。

    app/controllers/posts_controller.ts
    import type { HttpContext } from '@adonisjs/core/http'
    import Post from '#models/post'
    import PostTransformer from '#transformers/post_transformer'
    
    export default class PostsController {
      // ... 已有方法(index、show)
    
      /**
       * 显示创建新帖子的表单
       */
      async create({ inertia }: HttpContext) {
        return inertia.render('posts/create', {})
      }
    
      /**
       * 处理创建新帖子的表单提交
       */
      async store({}: HttpContext) {
        // 我们稍后实现它
      }
    }
  2. 注册路由

    现在让我们把路由接好。我们需要两条:一条用于显示表单,另一条用于处理提交。两者都应仅对已登录用户开放。

    Warning

    /posts/create 路由必须定义在 /posts/:id 路由之前。

    start/routes.ts
    import router from '@adonisjs/core/services/router'
    import { middleware } from '#start/kernel'
    import { controllers } from '#generated/controllers'
    
    router.get('/posts', [controllers.Posts, 'index'])
    
    router.get('/posts/create', [controllers.Posts, 'create']).use(middleware.auth())
    router.post('/posts', [controllers.Posts, 'store']).use(middleware.auth())
    
    router.get('/posts/:id', [controllers.Posts, 'show'])

    auth() 中间件确保只有已登录用户才能访问这些路由。未通过身份验证的访客将被重定向到登录页。

  3. 创建表单组件

    使用 Ace CLI 创建表单的 React 组件。

    node ace make:page posts/create

    这会创建 inertia/pages/posts/create.tsx。打开它并添加以下表单:

    inertia/pages/posts/create.tsx
    import { Form } from '@adonisjs/inertia/react'
    
    export default function PostsCreate() {
      return (
        <div className="form-container">
          <div>
            <h1>Share your creation</h1>
            <p>Share the URL and a short summary of your creation</p>
          </div>
    
          <div>
            <Form route="posts.store">
              {({ errors }) => (
                <>
                  <div>
                    <label htmlFor="title">Post title</label>
                    <input
                      type="text"
                      name="title"
                      id="title"
                      placeholder="Title of your creation"
                      data-invalid={errors.title ? 'true' : undefined}
                    />
                    {errors.title && <div>{errors.title}</div>}
                  </div>
    
                  <div>
                    <label htmlFor="url">URL</label>
                    <input
                      type="url"
                      name="url"
                      id="url"
                      placeholder="https://example.com/my-creation"
                      data-invalid={errors.url ? 'true' : undefined}
                    />
                    {errors.url && <div>{errors.url}</div>}
                  </div>
    
                  <div>
                    <label htmlFor="summary">Short summary</label>
                    <textarea
                      name="summary"
                      id="summary"
                      rows={4}
                      placeholder="Briefly describe what you are sharing"
                      data-invalid={errors.summary ? 'true' : undefined}
                    />
                    {errors.summary && <div>{errors.summary}</div>}
                  </div>
    
                  <div>
                    <button type="submit" className="button">
                      Publish
                    </button>
                  </div>
                </>
              )}
            </Form>
          </div>
        </div>
      )
    }

    来自 @adonisjs/inertia/reactForm 组件负责处理表单提交。它接受一个 route 属性(要提交到的命名路由),并通过 render prop 模式提供一个 errors 对象。当你提交表单时,Inertia 会将请求发送到你的后端,并自动处理响应,包括展示验证错误。

  4. 创建验证器

    在处理表单提交之前,我们需要定义验证规则。AdonisJS 使用 VineJS 进行验证 ,这是一个基于 schema 的验证库,让你可以为数据定义规则。

    使用 Ace CLI 创建验证器。

    node ace make:validator post

    这会创建 app/validators/post.ts。添加一个 createPostValidator 来验证帖子的创建。

    app/validators/post.ts
    import vine from '@vinejs/vine'
    
    /**
     * 验证帖子的创建表单
     */
    export const createPostValidator = vine.create({
      title: vine.string().minLength(3).maxLength(255),
      url: vine.string().url(),
      summary: vine.string().minLength(80).maxLength(500),
    })

    vine.create() 方法从一个 schema 创建一个预编译的验证器。在内部,我们为每个字段定义其类型和规则。

    • title 字段必须是 3-255 个字符之间的字符串。
    • url 字段必须是字符串且格式为 URL。
    • summary 字段必须在 80-500 个字符之间。
  5. 实现 store 方法

    现在让我们实现 store 方法来验证数据、创建帖子,并重定向用户。

    app/controllers/posts_controller.ts
    import type { HttpContext } from '@adonisjs/core/http'
    import Post from '#models/post'
    import PostTransformer from '#transformers/post_transformer'
    import { createPostValidator } from '#validators/post'
    
    export default class PostsController {
      // ... 已有方法
    
      async store({ request, auth, response }: HttpContext) {
        const payload = await request.validateUsing(createPostValidator)
    
        await Post.create({
          ...payload,
          userId: auth.user!.id,
        })
    
        return response.redirect().toRoute('posts.index')
      }
    }

    当表单提交时,request.validateUsing() 会验证数据。如果验证失败,用户会被自动重定向回原页面,相关字段旁会显示错误。如果验证成功,我们创建帖子并使用 auth.user!.id(通过 HTTP 上下文可用)将其与已登录用户关联,然后重定向到帖子索引页。

    现在访问 /posts/create ,填写表单并提交。你的新帖子应该会出现在帖子页面上!尝试提交无效数据(如过短的摘要或无效的 URL)来实地查看验证错误。

为帖子添加评论

既然你可以创建帖子了,让我们添加让用户发表评论的能力。我们将为评论创建一个独立的控制器。在 AdonisJS 中,每个资源对应一个控制器是推荐的做法。

  1. 创建评论验证器

    让我们先为评论定义验证规则。

    node ace make:validator comment

    由于评论只有一个 content 字段,验证很简单。

    app/validators/comment.ts
    import vine from '@vinejs/vine'
    
    /**
     * 验证评论的创建表单
     */
    export const createCommentValidator = vine.create({
      content: vine.string().trim().minLength(1),
    })
  2. 创建 CommentsController

    使用 Ace CLI 生成一个新的控制器。

    node ace make:controller comments

    这会创建 app/controllers/comments_controller.ts。添加一个 store 方法来处理评论提交。

    app/controllers/comments_controller.ts
    import type { HttpContext } from '@adonisjs/core/http'
    import Comment from '#models/comment'
    import { createCommentValidator } from '#validators/comment'
    
    export default class CommentsController {
      /**
       * 处理创建新评论的表单提交
       */
      async store({ request, auth, params, response }: HttpContext) {
        // 验证评论内容
        const payload = await request.validateUsing(createCommentValidator)
    
        // 创建评论并将其与帖子和用户关联
        await Comment.create({
          ...payload,
          postId: params.id,
          userId: auth.user!.id,
        })
    
        // 重定向回帖子页面
        return response.redirect().back()
      }
    }

    我们使用 params.id 从路由参数获取帖子 ID,并通过 postId 用它把评论关联到帖子。response.redirect().back() 将用户送回帖子页面。

  3. 注册评论路由

    添加一条用于创建评论的路由,同样受 auth 中间件保护。

    start/routes.ts
    import router from '@adonisjs/core/services/router'
    import { middleware } from '#start/kernel'
    import { controllers } from '#generated/controllers'
    
    router.on('/').renderInertia('home')
    router.get('/posts', [controllers.Posts, 'index'])
    router.get('/posts/create', [controllers.Posts, 'create']).use(middleware.auth())
    router.post('/posts', [controllers.Posts, 'store']).use(middleware.auth())
    router.get('/posts/:id', [controllers.Posts, 'show'])
    
    router.post('/posts/:id/comments', [controllers.Comments, 'store']).use(middleware.auth())
  4. 添加评论表单

    打开你的 inertia/pages/posts/show.tsx 组件并添加评论表单。

    inertia/pages/posts/show.tsx
    import { InertiaProps } from '~/types'
    import { Data } from '~/generated/data'
    import { Form } from '@adonisjs/inertia/react'
    
    type PageProps = InertiaProps<{
      post: Data.Post
    }>
    
    export default function PostsShow(props: PageProps) {
      const { post } = props
    
      return (
        <div className="container">
          <div>
            <h1>{post.title}</h1>
          </div>
    
          <div className="post">
            <div className="post-meta">
              <div>By {post.author.fullName}</div>
    
              <span>.</span>
              <div>
                <a href={post.url} target="_blank" rel="noreferrer">
                  {post.url}
                </a>
              </div>
            </div>
    
            <div className="post-summary">{post.summary}</div>
    
            <div className="post-comments">
              <h2>Comments</h2>
    
              <div className="post-comment-form">
                <Form route="comments.store" routeParams={{ id: post.id }}>
                  {({ errors }) => (
                    <>
                      <div>
                        <textarea
                          name="content"
                          rows={3}
                          placeholder="Share your thoughts..."
                          data-invalid={errors.content ? 'true' : undefined}
                        />
                        {errors.content && <div>{errors.content}</div>}
                      </div>
    
                      <div>
                        <button type="submit" className="button">
                          Post comment
                        </button>
                      </div>
                    </>
                  )}
                </Form>
              </div>
    
              {post.comments && post.comments.length > 0 ? (
                post.comments.map((comment) => (
                  <div key={comment.id} className="comment-item">
                    <p>{comment.content}</p>
                    <div className="comment-meta">
                      By {comment.author.fullName} on{' '}
                      {comment.createdAt && new Date(comment.createdAt).toLocaleDateString('en-US', {
                        month: 'short',
                        day: 'numeric',
                        year: 'numeric',
                      })}
                    </div>
                  </div>
                ))
              ) : (
                <p>No comments yet.</p>
              )}
            </div>
          </div>
        </div>
      )
    }

    Form 组件接受 routeParams 来将帖子 ID 传递给路由。路由 comments.store 对应我们的 CommentsController.store 方法,我们传入 { id: post.id } 来填充路由 /posts/:id/comments 中的 :id 参数。

    现在登录后访问任意帖子页面并尝试发表评论。提交后,你将被重定向回页面,在列表中看到你的评论。

你学到了什么

你现在已为 DevShow 应用添加了完整的表单处理与验证。以下是你达成的成果:

  • 使用来自 @adonisjs/inertia/reactForm 组件创建表单
  • 使用 VineJS 验证器定义验证规则
  • 在控制器中使用 request.validateUsing() 验证表单提交
  • 使用 auth() 中间件保护路由,确保只有已登录用户才能创建内容
  • 使用 auth.user!.id 将帖子和评论与用户关联
  • 通过为不同资源创建独立控制器(PostsController 和 CommentsController)来组织代码
  • 通过 Form 组件的 render prop 模式自动处理表单错误