统一的创建和编辑表单(使用 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 等)的方式比直接上传到服务器更安全。仅将文件标识符发送到后端,可降低风险并避免文件大小限制。