"use client"; import Link from "next/link"; import { useEffect, useMemo, useRef, useState } from "react"; import seedData from "../data/repeatable-forms.json"; import styles from "./RepeatableForm.module.css"; type ImageAttachment = { id: string; name: string; size: string; url: string }; type FileAttachment = { id: string; name: string; size: string; type: string; url: string }; type FormItem = { id: string; title: string; category: string; owner: string; status: string; quantity: number; unit: string; dueDate: string; description: string; images: ImageAttachment[]; files: FileAttachment[]; }; type FormRecord = { id: string; title: string; code: string; status: string; updatedAt: string; createdBy: string; tags: string[]; items: FormItem[]; }; const initialForms = seedData.forms as FormRecord[]; const recordStatusStyles: Record = { "编辑中": "recordEditing", "已提交": "recordSubmitted", "草稿": "recordDraft" }; const formStatusStyles: Record = { "编辑中": "formStatusEditing", "已提交": "formStatusSubmitted", "草稿": "formStatusDraft" }; const itemStatusStyles: Record = { "待填写": "itemStatusEmpty", "进行中": "itemStatusDoing", "待确认": "itemStatusReview", "已完成": "itemStatusDone" }; export default function RepeatableFormPage() { const [forms, setForms] = useState(initialForms); const [activeId, setActiveId] = useState(initialForms[0].id); const [query, setQuery] = useState(""); const [statusFilter, setStatusFilter] = useState("全部"); const [collapsed, setCollapsed] = useState([]); const [lightbox, setLightbox] = useState(null); const [toast, setToast] = useState(""); const [saved, setSaved] = useState(true); const [hydrated, setHydrated] = useState(false); const [storageWarning, setStorageWarning] = useState(false); const saveTimer = useRef(null); useEffect(() => { try { const cached = window.localStorage.getItem("orbit-repeatable-forms"); const cachedActive = window.localStorage.getItem("orbit-repeatable-active"); if (cached) setForms(JSON.parse(cached)); if (cachedActive) setActiveId(cachedActive); } catch { window.localStorage.removeItem("orbit-repeatable-forms"); } setHydrated(true); }, []); useEffect(() => { if (!hydrated) return; setSaved(false); if (saveTimer.current) window.clearTimeout(saveTimer.current); saveTimer.current = window.setTimeout(() => { try { window.localStorage.setItem("orbit-repeatable-forms", JSON.stringify(forms)); window.localStorage.setItem("orbit-repeatable-active", activeId); setSaved(true); setStorageWarning(false); } catch { setStorageWarning(true); } }, 550); return () => { if (saveTimer.current) window.clearTimeout(saveTimer.current); }; }, [forms, activeId, hydrated]); const activeForm = forms.find((form) => form.id === activeId) ?? forms[0]; const visibleForms = useMemo(() => forms.filter((form) => { const matchesQuery = `${form.title}${form.code}${form.tags.join("")}`.toLowerCase().includes(query.toLowerCase()); return matchesQuery && (statusFilter === "全部" || form.status === statusFilter); }), [forms, query, statusFilter]); const totals = useMemo(() => { const items = activeForm?.items ?? []; return { completed: items.filter((item) => item.status === "已完成").length, images: items.reduce((sum, item) => sum + item.images.length, 0), files: items.reduce((sum, item) => sum + item.files.length, 0), }; }, [activeForm]); const notify = (message: string) => { setToast(message); window.setTimeout(() => setToast(""), 2200); }; const updateForm = (patch: Partial) => setForms((current) => current.map((form) => form.id === activeId ? { ...form, ...patch, updatedAt: "刚刚" } : form)); const updateItem = (itemId: string, patch: Partial) => setForms((current) => current.map((form) => form.id === activeId ? { ...form, updatedAt: "刚刚", items: form.items.map((item) => item.id === itemId ? { ...item, ...patch } : item), } : form)); const createForm = () => { const id = `FORM-${Date.now()}`; const record: FormRecord = { id, title: "未命名资料表单", code: `DRAFT-${String(Date.now()).slice(-6)}`, status: "草稿", updatedAt: "刚刚", createdBy: "林知夏", tags: ["新表单"], items: [emptyItem()] }; setForms((current) => [record, ...current]); setActiveId(id); notify("已创建新表单"); }; const duplicateForm = () => { if (!activeForm) return; const now = Date.now(); const duplicated: FormRecord = { ...activeForm, id: `FORM-${now}`, title: `${activeForm.title} - 副本`, status: "草稿", updatedAt: "刚刚", items: activeForm.items.map((item, index) => ({ ...item, id: `ITEM-${now}-${index}` })), }; setForms((current) => [duplicated, ...current]); setActiveId(duplicated.id); notify("表单副本已创建"); }; const removeForm = () => { if (!activeForm || forms.length === 1) return notify("至少需要保留一份表单"); const next = forms.filter((form) => form.id !== activeForm.id); setForms(next); setActiveId(next[0].id); notify("表单已删除"); }; const addItem = () => { const item = emptyItem(); updateForm({ items: [...activeForm.items, item] }); window.setTimeout(() => document.getElementById(item.id)?.scrollIntoView({ behavior: "smooth", block: "center" }), 80); notify("已添加一条表单项"); }; const duplicateItem = (item: FormItem) => { const clone = { ...item, id: `ITEM-${Date.now()}`, title: `${item.title} - 副本`, images: [...item.images], files: [...item.files] }; const index = activeForm.items.findIndex((current) => current.id === item.id); const next = [...activeForm.items]; next.splice(index + 1, 0, clone); updateForm({ items: next }); notify("表单项已复制"); }; const removeItem = (itemId: string) => { updateForm({ items: activeForm.items.filter((item) => item.id !== itemId) }); notify("表单项已删除"); }; const moveItem = (itemId: string, direction: -1 | 1) => { const index = activeForm.items.findIndex((item) => item.id === itemId); const target = index + direction; if (index < 0 || target < 0 || target >= activeForm.items.length) return; const next = [...activeForm.items]; [next[index], next[target]] = [next[target], next[index]]; updateForm({ items: next }); }; const handleAttachments = async (itemId: string, files: FileList | null, kind: "images" | "files") => { if (!files?.length) return; const item = activeForm.items.find((current) => current.id === itemId); if (!item) return; const limit = kind === "images" ? 5 : 6; const currentCount = item[kind].length; const incoming = Array.from(files).slice(0, Math.max(0, limit - currentCount)); if (incoming.length === 0) return notify(`每条最多添加 ${limit} 个${kind === "images" ? "图片" : "文件"}`); const maxBytes = kind === "images" ? 1_500_000 : 1_000_000; const allowed = incoming.filter((file) => file.size <= maxBytes && (kind === "files" || file.type.startsWith("image/"))); if (allowed.length !== incoming.length) notify(`已跳过超过限制的${kind === "images" ? "图片(1.5MB)" : "文件(1MB)"}`); const encoded = await Promise.all(allowed.map(async (file, index) => { const url = await readAsDataUrl(file); if (kind === "images") return { id: `IMG-${Date.now()}-${index}`, name: file.name, size: formatSize(file.size), url } as ImageAttachment; const extension = file.name.split(".").pop()?.toUpperCase() || "FILE"; return { id: `FILE-${Date.now()}-${index}`, name: file.name, size: formatSize(file.size), type: extension, url } as FileAttachment; })); updateItem(itemId, kind === "images" ? { images: [...item.images, ...(encoded as ImageAttachment[])] } : { files: [...item.files, ...(encoded as FileAttachment[])] }); notify(`已添加 ${encoded.length} 个${kind === "images" ? "图片" : "文件"}`); }; const saveNow = () => { try { window.localStorage.setItem("orbit-repeatable-forms", JSON.stringify(forms)); setSaved(true); setStorageWarning(false); notify("整份表单已保存到当前浏览器"); } catch { setStorageWarning(true); notify("存储空间不足,请删除较大的附件后重试"); } }; if (!activeForm) return null; return (
FFormStack
{saved ? "所有更改已保存" : "正在自动保存…"}
模板首页/可重复表单/{activeForm.code}
{activeForm.status} updateForm({ title: event.target.value })} aria-label="表单名称" />

编号 {activeForm.code} · 创建人 {activeForm.createdBy} · 更新于 {activeForm.updatedAt}

表单项{activeForm.items.length}可继续添加
已完成{totals.completed} / {activeForm.items.length}{activeForm.items.length ? Math.round(totals.completed / activeForm.items.length * 100) : 0}% 完成
图片附件{totals.images}支持预览
文档附件{totals.files}支持下载
{storageWarning &&
!

浏览器存储空间不足请删除较大的图片或文件。此纯前端模板会将新附件保存为本地数据。

}

REPEATABLE ITEMS

表单明细

每条明细可独立添加图片和文件附件。
{activeForm.items.map((item, index) => { const isCollapsed = collapsed.includes(item.id); return
⋮⋮{String(index + 1).padStart(2, "0")}
{item.title || "未命名表单项"}{item.category} · {item.images.length} 张图片 · {item.files.length} 个文件
{item.status}
{!isCollapsed &&