Next.js 零基础开发博客后台管理系统教程(八):提升用户体验 - 表单状态、加载与基础验证

✨ 引言:更智能的表单

在上一篇中,我们的 Server Action 成功执行并重定向,但用户在等待数据处理时,没有任何反馈。本篇我们将解决这个问题,并为表单添加基础验证,确保数据的有效性。

本篇目标(教程八):

  • 使用 useFormStatus Hook 封装提交按钮,实现加载状态。
  • 使用 useFormState Hook 管理表单提交结果和错误信息。
  • 在 Server Action 中添加基础的服务端验证

一、实现加载状态 (useFormStatus)

我们从最直观的改进开始:当用户点击提交按钮时,按钮应该显示加载中。

1. 创建提交按钮组件

useFormStatus 必须在一个客户端组件中使用,并且该组件必须是其关联 <form> 的子组件。

bash 复制代码
touch components/SubmitButton.tsx

components/SubmitButton.tsx 内容:

tsx 复制代码
// components/SubmitButton.tsx

"use client";

import React from 'react';
import { useFormStatus } from 'react-dom'; // Next.js 自动封装了 React Hook

export default function SubmitButton() {
  const { pending } = useFormStatus(); // 获取表单的提交状态

  return (
    <button 
      type="submit" 
      aria-disabled={pending} // 提交中时禁用按钮
      disabled={pending} 
      className={`
        px-6 py-3 font-semibold rounded-lg shadow-md transition duration-150 flex items-center justify-center
        ${pending 
          ? 'bg-gray-400 cursor-not-allowed' // 加载中样式
          : 'bg-indigo-600 hover:bg-indigo-700' // 默认样式
        }
        text-white
      `}
    >
      {pending ? (
        <>
          <svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
            <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
            <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
          </svg>
          正在发布...
        </>
      ) : (
        '发布文章'
      )}
    </button>
  );
}

二、实现服务端验证 (useFormState)

我们需要修改 Server Action 来返回错误信息,并在客户端使用 useFormState 来接收并显示这些信息。

1. 修改 Server Action 结构

为了返回错误信息,我们修改 lib/actions.ts 中的 createPost 函数。

lib/actions.ts (部分修改):

typescript 复制代码
// lib/actions.ts

"use server"; 
// ... (导入不变)

// 1. 定义表单状态类型,包含错误信息
export type FormState = {
  errors?: {
    title?: string[];
    content?: string[];
    global?: string;
  };
};

// 2. 修改 createPost 函数签名,使其接受 prevState 并返回 FormState | void
const initialState: FormState = {};

export async function createPost(
  prevState: FormState, // useFormState 传递的上一个状态
  formData: FormData
): Promise<FormState> { // 返回新的状态或重定向 (void)
  
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;
  // ... (其他字段获取不变)

  const errors: FormState['errors'] = {};

  // --- 验证逻辑 ---
  if (!title || title.trim().length === 0) {
    errors.title = ['文章标题不能为空。'];
  } else if (title.length > 100) {
    errors.title = ['标题长度不能超过 100 个字符。'];
  }

  if (!content || content.trim().length === 0) {
    errors.content = ['文章内容不能为空。'];
  }

  // --- 检查是否有错误 ---
  if (Object.keys(errors).length > 0) {
    // 如果有错误,返回包含错误信息的 state
    return { errors };
  }
  
  // --- 无错误时继续数据处理 ---
  
  // 模拟数据写入/保存到数据库 (模拟 2 秒延迟)
  console.log(`正在保存文章:${title}`);
  await new Promise(resolve => setTimeout(resolve, 2000));
  
  // 实际的数据库插入操作...
  
  // 成功后重定向
  revalidatePath('/posts'); 
  redirect('/posts');
}

注意:redirect() 被调用时,Server Action 不会返回 FormState,而是直接中断并跳转。

2. 在表单页面使用 useFormState

修改 app/posts/create/page.tsx,将其转换为客户端组件(因为 useFormState 是 Hook)并显示错误信息。

app/posts/create/page.tsx 内容:

tsx 复制代码
// app/posts/create/page.tsx

"use client"; // 声明为客户端组件

import React from 'react';
import { useFormState } from 'react-dom';
import { createPost, FormState } from '@/lib/actions'; 
import SubmitButton from '@/components/SubmitButton'; 

// 定义初始状态
const initialState: FormState = {};

export default function CreatePostPage() {
  // 1. 使用 useFormState 绑定 Server Action 并管理状态
  const [state, formAction] = useFormState(createPost, initialState);

  return (
    <div>
      <h1 className="text-3xl font-bold mb-6 text-gray-800">📝 发布新文章</h1>
      
      {/* 2. 将 formAction 绑定到表单的 action 属性 */}
      <form action={formAction} className="bg-white p-8 rounded-lg shadow-md space-y-6">
        
        {/* 标题输入框 */}
        <div>
          <label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
            文章标题
          </label>
          <input
            id="title"
            name="title"
            type="text"
            required
            className="w-full p-3 border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500"
            placeholder="请输入文章标题"
          />
          {/* 3. 显示标题验证错误 */}
          {state.errors?.title && state.errors.title.map((error: string) => (
            <p key={error} className="text-sm text-red-500 mt-1">{error}</p>
          ))}
        </div>

        {/* 分类选择 (保持不变) */}
        <div>
          {/* ... */}
        </div>

        {/* 内容输入框 */}
        <div>
          <label htmlFor="content" className="block text-sm font-medium text-gray-700 mb-1">
            文章内容
          </label>
          <textarea
            id="content"
            name="content"
            rows={10}
            required
            className="w-full p-3 border border-gray-300 rounded-lg focus:ring-indigo-500 focus:border-indigo-500"
            placeholder="在这里撰写您的文章内容..."
          />
          {/* 4. 显示内容验证错误 */}
          {state.errors?.content && state.errors.content.map((error: string) => (
            <p key={error} className="text-sm text-red-500 mt-1">{error}</p>
          ))}
        </div>

        {/* 提交按钮:使用封装好的 SubmitButton */}
        <div className="flex justify-end">
          <SubmitButton />
        </div>

        {/* 5. 显示全局错误 (如果需要) */}
        {state.errors?.global && (
          <p className="text-center text-sm text-red-600 border border-red-200 p-2 rounded-lg bg-red-50">{state.errors.global}</p>
        )}
      </form>
    </div>
  );
}

3. 运行与验证

  1. 运行 npm run dev
  2. 导航到 /posts/create
  3. 验证加载状态: 正常填写表单,点击提交。按钮会显示"正在发布..."和加载图标,且按钮被禁用,直到重定向完成。
  4. 验证错误状态: 尝试留空标题或内容字段,然后点击提交。按钮会短暂显示加载状态,然后 Server Action 返回错误,页面停留在当前表单,并在对应的字段下方显示红色错误信息。

💡 总结与预告

在本篇教程中,我们完成了:

  • ✅ 使用 useFormStatus 实现了表单提交时的加载状态和按钮禁用。
  • ✅ 使用 useFormState 管理表单的提交状态和 Server Action 返回的错误信息。
  • ✅ 在服务端 Server Action 中添加了基础的字段验证逻辑。

至此,我们的文章管理模块已经具备了完整的 CRUD 功能和良好的用户体验。在下一篇教程中,我们将为后台系统添加另一个基础模块:用户管理 ,并介绍路由组的概念,以便更好地组织后台系统的路由结构。

相关推荐
有意义38 分钟前
从日常使用到代码实现:B 站签名编辑的 OOP 封装思路与实践
javascript·代码规范·ecmascript 6
nvd1139 分钟前
SSE 流式输出与 Markdown 渲染实现详解
javascript·python
电商API大数据接口开发Cris41 分钟前
淘宝 API 关键词搜索接口深度解析:请求参数、签名机制与性能优化
前端·数据挖掘·api
小周同学42 分钟前
vue3 上传文件,图片,视频组件
前端·vue.js
细心细心再细心43 分钟前
runtime-dom记录备忘
前端
小猪努力学前端1 小时前
基于PixiJS的小游戏广告开发
前端·webgl·游戏开发
哆啦A梦15881 小时前
62 对接支付宝沙箱
前端·javascript·vue.js·node.js
Tzarevich1 小时前
用 OOP 思维打造可复用的就地编辑组件:EditInPlace 实战解析
javascript·前端框架
用户8168694747251 小时前
Lane 优先级模型与时间切片调度
前端·react.js