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

实现FluentValidation错误字段映射展示及React Query去useEffect优化

解决方案:FluentValidation错误映射至Fluent UI表单+React Query优化

问题背景

  • API验证失败时返回400错误,后端通过ModelState返回FluentValidation错误,但错误键为Validator类名(如NotEmptyValidator),无法直接对应到表单输入字段
  • 前端使用React Query+Axios,但依赖useEffect处理请求成功/错误逻辑,未充分利用React Query原生能力

步骤1:后端调整 - 让错误键对应表单字段名

当前后端用error.Code(Validator类名)作为ModelState的键,需要改为表单字段名。可通过正则从错误描述中提取字段名,确保前端能直接映射到对应输入框。

修改后端端点代码

using Ardalis.ApiEndpoints;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using QueryStore.Application.Queries;
using System.Text.RegularExpressions;

namespace QueryStore.API.Endpoints.Queries;

public sealed class CreateQueryEndpoint : EndpointBaseAsync
    .WithRequest<CreateQueryCommand>
    .WithActionResult<int>
{
    private readonly IMediator _mediator;

    public CreateQueryEndpoint(IMediator mediator)
    {
        _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
    }

    [HttpPost("/queries")]
    public override async Task<ActionResult<int>> HandleAsync([FromBody] CreateQueryCommand request, CancellationToken cancellationToken = default)
    {
        var result = await _mediator.Send(request, cancellationToken);

        if (!result.IsSuccess)
        {
            foreach (var error in result.Errors)
            {
                // 从错误描述中提取字段名,例如"'Name' must not be empty." -> "Name"
                var fieldNameMatch = Regex.Match(error.Description, @"^'([^']+)'");
                var fieldName = fieldNameMatch.Success ? fieldNameMatch.Groups[1].Value : error.Code;
                
                ModelState.AddModelError(fieldName, error.Description);
            }

            return BadRequest(ModelState);
        }

        var getByIdUrl = Url.RouteUrl(GetQueryByIdEndpoint.RouteName) ?? string.Empty;
        return Created(getByIdUrl, result);
    }
}

步骤2:前端优化 - 移除useEffect,用React Query原生回调+错误映射至Fluent UI表单

核心优化点

  1. 用React Query useMutation的onSuccess/onError/onMutate替代useEffect处理状态
  2. 将后端返回的错误直接映射到对应表单字段,通过Fluent UI的Field组件展示字段级错误
  3. 优化用户交互:输入时清除字段错误、请求中禁用按钮

修改后前端组件代码

import {
  makeStyles,
  shorthands,
  Field,
  Input,
  Button,
  tokens,
  useId,
  useToastController,
  Toast,
  ToastTitle,
  Toaster,
} from "@fluentui/react-components";
import { Add16Filled } from "@fluentui/react-icons";
import axios from "axios";
import { useState } from "react";
import { useMutation } from "react-query";

interface CreateQuery {
  name: string;
  description: string;
  content: string;
  tags: string[];
}
interface CreateQueryProps {
  setComponent: (component: string) => void;
}

const useStyles = makeStyles({
  root: {
    display: "flex",
    fontWeight: tokens.fontWeightBold,
    flexDirection: "column",
    ...shorthands.gap("20px"),
    maxWidth: "600px",
    marginLeft: "20px",
    marginTop: "20px",
  },

  primaryButton: {
    marginRight: "8px",
  },
});

export default function CreateQuery({ setComponent }: CreateQueryProps) {
  const classes = useStyles();

  const [name, setName] = useState("");
  const [description, setDescription] = useState("");
  const [content, setContent] = useState("");
  const [tags, setTags] = useState<string[]>([]);
  const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});

  const handleTags = (tags: string) => {
    setTags(tags.split(",").map(tag => tag.trim()));
  };

  const createQuery = async ({ name, description, content, tags }: CreateQuery) => {
    try {
      const response = await axios.post("https://localhost:8443/queries", {
        name,
        description,
        content,
        tags,
      });
      return response.data;
    } catch (err: any) {
      if (err.response?.status === 400) {
        throw new Error(JSON.stringify(err.response.data));
      }
      throw err;
    }
  };

  const errorToasterId = useId("error");
  const { dispatchToast: dispatchError } = useToastController(errorToasterId);

  const mutation = useMutation(createQuery, {
    onSuccess: () => setComponent("home"),
    onError: (err: any) => {
      setFieldErrors({});
      if (err.message.startsWith("{")) {
        const errorData = JSON.parse(err.message);
        const newFieldErrors: Record<string, string> = {};
        
        Object.entries(errorData).forEach(([field, messages]) => {
          if (Array.isArray(messages) && messages.length > 0) {
            newFieldErrors[field.toLowerCase()] = messages[0];
            dispatchError(
              <Toast>
                <ToastTitle>{messages[0]}</ToastTitle>
              </Toast>,
              { intent: "error" }
            );
          }
        });
        
        setFieldErrors(newFieldErrors);
      } else {
        dispatchError(
          <Toast>
            <ToastTitle>请求失败:{err.message}</ToastTitle>
          </Toast>,
          { intent: "error" }
        );
      }
    },
    onMutate: () => setFieldErrors({})
  });

  const submitData = () => {
    mutation.mutate({ name, description, content, tags });
  };

  return (
    <div className={classes.root}>
      <Field
        size="medium"
        label="Name"
        required
        validationState={fieldErrors.name ? "error" : undefined}
        errorMessage={fieldErrors.name}
      >
        <Input
          appearance="outline"
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
          onFocus={() => setFieldErrors(prev => ({ ...prev, name: undefined }))}
        />
      </Field>
      <Field
        label="Description"
        validationState={fieldErrors.description ? "error" : undefined}
        errorMessage={fieldErrors.description}
      >
        <Input
          appearance="outline"
          type="text"
          value={description}
          onChange={(e) => setDescription(e.target.value)}
          onFocus={() => setFieldErrors(prev => ({ ...prev, description: undefined }))}
        />
      </Field>
      <Field
        label="Content"
        required
        validationState={fieldErrors.content ? "error" : undefined}
        errorMessage={fieldErrors.content}
      >
        <Input
          appearance="outline"
          type="text"
          value={content}
          onChange={(e) => setContent(e.target.value)}
          onFocus={() => setFieldErrors(prev => ({ ...prev, content: undefined }))}
        />
      </Field>
      <Field
        label="Tags"
        validationState={fieldErrors.tags ? "error" : undefined}
        errorMessage={fieldErrors.tags}
      >
        <Input
          appearance="outline"
          type="text"
          value={tags.join(", ")}
          onChange={(e) => handleTags(e.target.value)}
          onFocus={() => setFieldErrors(prev => ({ ...prev, tags: undefined }))}
        />
      </Field>
      <div>
        <Button
          className={classes.primaryButton}
          appearance="primary"
          icon={<Add16Filled />}
          onClick={submitData}
          disabled={mutation.isLoading}
        >
          {mutation.isLoading ? "Creating..." : "Create"}
        </Button>
        <Button onClick={() => setComponent("home")}>Cancel</Button>
      </div>
      <Toaster toasterId={errorToasterId} position="top-end" timeout={3000} />
    </div>
  );
}

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.10 00:32:03