统一的创建和编辑表单(使用 NextJS)

发布: (2025年12月7日 GMT+8 19:32)
5 min read
原文: Dev.to

Source: Dev.to

介绍

在本指南中,我们将创建一个统一的表单,既能处理新增数据,也能更新已有数据。使用单一表单可以减少代码重复、简化维护、确保数据处理和验证的一致性、提升用户体验,并加快开发速度。

我们将演示如何为 branches(分支) 创建添加和编辑表单。

分支实例类型

export type BranchInstance = {
  id: string;
  branch_name: string;
  location: string;
  img: string;
  google_coordinate: string;
};

验证模式(Zod)

import { z } from "zod";

// validator
export const BranchSchema = z.object({
  branch_name: z.string().min(1, "Branch name is required"),
  location: z.string().min(1, "Location is required"),
  google_coordinate: z.string().min(1, "Google Map embed link is required"),
  img: z.string().min(1, "One Image of the place is required"),
});

// Infer the TypeScript type from the Zod schema
type BranchSchemaType = z.infer<typeof BranchSchema>;

// Errors type
export type zodBranchErrorsType = {
  [K in keyof BranchSchemaType]?: string[];
};

保存响应类型

export type BranchSaveResponse = {
  status: number;
  data: BranchInstance;
  message: string;
  errors?: {
    fieldErrors?: zodBranchErrorsType;
    formErrors?: string[];
  };
};

// Generic reusable version
export type SaveResponse<T> = {
  status: number;
  data: T;
  message: string;
  errors?: {
    fieldErrors?: Record<string, string[]>;
    formErrors?: string[];
  };
};

export type BranchSaveResponse = SaveResponse<BranchInstance>;

FormData 转换为普通对象

export function formDataToObject(fd: FormData) {
  // This object will hold the final key‑value pairs
  const out: Record<string, any> = {};

  // This set keeps track of all keys that are arrays (ending with "[]")
  const arrayKeys = new Set<string>();

  // Loop through all key‑value pairs in the FormData
  for (const [rawKey, value] of fd.entries()) {
    // Check if the field name ends with "[]", which means it's an array field
    const isArray = rawKey.endsWith("[]");

    // Remove the "[]" from the key for cleaner object property names
    const key = isArray ? rawKey.slice(0, -2) : rawKey;

    // Remember that this key is meant to represent an array
    if (isArray) arrayKeys.add(key);

    // If this key already exists in the object, we need to merge the new value
    if (key in out) {
      const prev = out[key];
      // If it's already an array, add the new value to it;
      // otherwise, convert the previous single value into an array
      out[key] = Array.isArray(prev) ? [...prev, value] : [prev, value];
    } else {
      out[key] = value;
    }
  }

  // Ensure that all keys marked as arrays are stored as arrays,
  // even if only one value was present in the FormData
  for (const key of arrayKeys) {
    const v = out[key];
    out[key] = Array.isArray(v) ? v : v === undefined ? [] : [v];
  }

  // Return the final, cleaned‑up object
  return out;
}

可复用的输入组件

export function InputField({
  label,
  name,
  defaultValue,
  errors,
  placeholder,
  type = "text", // default is text
}: {
  label: string;
  name: string;
  defaultValue?: string | number | null;
  errors?: string[];
  placeholder?: string;
  type?: "text" | "number" | "email" | "password";
}) {
  return (
    <div className="mb-4">
      <label htmlFor={name} className="block text-sm font-medium text-gray-700">
        {label}
      </label>
      <input
        id={name}
        name={name}
        type={type}
        className={`mt-1 block w-full rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm ${
          errors?.length ? "border-red-500" : "border-gray-300"
        }`}
        defaultValue={defaultValue as string | number}
        placeholder={placeholder}
      />
      {errors?.map((error, index) => (
        <p key={index} className="mt-1 text-sm text-red-600">
          {error}
        </p>
      ))}
    </div>
  );
}

图片上传组件

import { useState, useRef, useCallback } from "react";
import toast from "react-hot-toast";

const UploadIcon: React.FC<{ className?: string }> = ({ className }) => (
  <svg
    className={className}
    /* SVG content omitted for brevity */
  />
);

const CloseIcon: React.FC<{ className?: string }> = ({ className }) => (
  <svg
    className={className}
    /* SVG content omitted for brevity */
  />
);

function ImageUploader({
  label,
  name,
  errors,
  initial,
}: {
  label: string;
  name: string;
  errors: string[];
  initial?: string; // if provided, the image is in update state
}) {
  const [image, setImage] = useState<File | null>(null);
  const [isDragging, setIsDragging] = useState(false);
  const fileInputRef = useRef<HTMLInputElement>(null);
  const [imageId, setImageId] = useState<string | null>(null);

  // Used only if `initial` is provided
  const [markedDeletedId, setMarkedDeletedId] = useState<string[]>([]);

  const processFile = useCallback(async (file: File) => {
    if (file && file.type.startsWith("image/")) {
      /** upload to Appwrite */
      toast.loading("Uploading main image", { id: "upload" });
      // ...upload logic here...
    }
  }, []);

  // Component UI (simplified)
  return (
    <div className="mb-4">
      <label className="block text-sm font-medium text-gray-700">{label}</label>
      {/* UI for drag‑and‑drop, preview, delete, etc. */}
      {/* Errors */}
      {errors.map((e, i) => (
        <p key={i} className="mt-1 text-sm text-red-600">
          {e}
        </p>
      ))}
      {/* Hidden inputs to send image ID and deleted IDs to the server */}
      <input type="hidden" name={`${name}_id`} value={imageId ?? ""} />
      {markedDeletedId.map((id) => (
        <input key={id} type="hidden" name={`${name}_deleted_ids[]`} value={id} />
      ))}
    </div>
  );
}

注意: 这种保存文件(图片、PDF、DOCX 等)的方式比直接上传到服务器更安全。仅将文件标识符发送到后端,可降低风险并避免文件大小限制。

Back to Blog

相关文章

阅读更多 »