This repository has been archived on 2026-08-27. You can view files and clone it, but cannot push or open issues or pull requests.
Files
2026-08-27 20:15:24 +08:00

320 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<string, string> = { "编辑中": "recordEditing", "已提交": "recordSubmitted", "草稿": "recordDraft" };
const formStatusStyles: Record<string, string> = { "编辑中": "formStatusEditing", "已提交": "formStatusSubmitted", "草稿": "formStatusDraft" };
const itemStatusStyles: Record<string, string> = { "待填写": "itemStatusEmpty", "进行中": "itemStatusDoing", "待确认": "itemStatusReview", "已完成": "itemStatusDone" };
export default function RepeatableFormPage() {
const [forms, setForms] = useState<FormRecord[]>(initialForms);
const [activeId, setActiveId] = useState(initialForms[0].id);
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("全部");
const [collapsed, setCollapsed] = useState<string[]>([]);
const [lightbox, setLightbox] = useState<ImageAttachment | null>(null);
const [toast, setToast] = useState("");
const [saved, setSaved] = useState(true);
const [hydrated, setHydrated] = useState(false);
const [storageWarning, setStorageWarning] = useState(false);
const saveTimer = useRef<number | null>(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<FormRecord>) => setForms((current) => current.map((form) => form.id === activeId ? { ...form, ...patch, updatedAt: "刚刚" } : form));
const updateItem = (itemId: string, patch: Partial<FormItem>) => 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 (
<div className={styles.page}>
<header className={styles.topbar}>
<Link href="/" className={styles.brand}><span>F</span><strong>FormStack</strong></Link>
<nav><Link href="/simple-list"></Link><Link className={styles.activeNav} href="/repeatable-form"></Link><Link href="/markdown"></Link></nav>
<div className={styles.saveState}><i className={saved ? styles.savedDot : ""} />{saved ? "所有更改已保存" : "正在自动保存…"}</div>
<div className={styles.headerActions}><button onClick={() => notify("预览模式为模板交互占位")}></button><button className={styles.saveButton} onClick={saveNow}></button><span></span></div>
</header>
<div className={styles.workspace}>
<aside className={styles.sidebar}>
<div className={styles.sidebarTitle}><div><p>FORM RECORDS</p><strong></strong></div><button onClick={createForm}></button></div>
<label className={styles.search}><span></span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索名称或编号" /></label>
<div className={styles.statusTabs}>{["全部", "编辑中", "已提交", "草稿"].map((value) => <button key={value} className={statusFilter === value ? styles.statusTabActive : ""} onClick={() => setStatusFilter(value)}>{value}</button>)}</div>
<div className={styles.formList}>
{visibleForms.map((form) => <button key={form.id} className={form.id === activeId ? styles.formActive : ""} onClick={() => setActiveId(form.id)}>
<span className={styles.formIcon}></span><div><strong>{form.title}</strong><small>{form.code}</small><p><i className={styles[recordStatusStyles[form.status]]} />{form.status}<span>{form.items.length} </span><time>{form.updatedAt}</time></p></div>
</button>)}
{visibleForms.length === 0 && <div className={styles.noForms}></div>}
</div>
<div className={styles.sidebarFooter}><button onClick={duplicateForm}> </button><button onClick={removeForm}> </button><p><br /><span></span></p></div>
</aside>
<main className={styles.main}>
<div className={styles.breadcrumb}><Link href="/"></Link><span>/</span><span></span><span>/</span><strong>{activeForm.code}</strong></div>
<section className={styles.formHeader}>
<div className={styles.titleGroup}><span className={`${styles.formStatus} ${styles[formStatusStyles[activeForm.status]]}`}>{activeForm.status}</span><input value={activeForm.title} onChange={(event) => updateForm({ title: event.target.value })} aria-label="表单名称" /><p> {activeForm.code} ·  {activeForm.createdBy} ·  {activeForm.updatedAt}</p></div>
<div className={styles.headerMenu}><button onClick={duplicateForm}></button><button onClick={() => notify("更多操作:导出 JSON、打印、归档")}></button></div>
</section>
<section className={styles.summary}>
<div><span></span><strong>{activeForm.items.length}</strong><small></small></div>
<div><span></span><strong>{totals.completed}<em> / {activeForm.items.length}</em></strong><small>{activeForm.items.length ? Math.round(totals.completed / activeForm.items.length * 100) : 0}% </small></div>
<div><span></span><strong>{totals.images}</strong><small></small></div>
<div><span></span><strong>{totals.files}</strong><small></small></div>
</section>
{storageWarning && <div className={styles.warning}><span>!</span><p><strong></strong></p><button onClick={() => setStorageWarning(false)}>×</button></div>}
<div className={styles.sectionBar}><div><p>REPEATABLE ITEMS</p><h2></h2><span></span></div><button onClick={addItem}> </button></div>
<div className={styles.itemList}>
{activeForm.items.map((item, index) => {
const isCollapsed = collapsed.includes(item.id);
return <article className={styles.itemCard} id={item.id} key={item.id}>
<header className={styles.itemHeader}>
<span className={styles.dragHandle}></span><span className={styles.itemNumber}>{String(index + 1).padStart(2, "0")}</span>
<div><strong>{item.title || "未命名表单项"}</strong><small>{item.category} · {item.images.length} · {item.files.length} </small></div>
<span className={`${styles.itemStatus} ${styles[itemStatusStyles[item.status]]}`}>{item.status}</span>
<div className={styles.itemActions}><button disabled={index === 0} onClick={() => moveItem(item.id, -1)}></button><button disabled={index === activeForm.items.length - 1} onClick={() => moveItem(item.id, 1)}></button><button onClick={() => duplicateItem(item)}></button><button onClick={() => setCollapsed((current) => isCollapsed ? current.filter((id) => id !== item.id) : [...current, item.id])}>{isCollapsed ? "展开" : "收起"}</button><button className={styles.deleteAction} onClick={() => removeItem(item.id)}></button></div>
</header>
{!isCollapsed && <div className={styles.itemBody}>
<div className={styles.fieldsGrid}>
<label className={styles.wideField}><span> <b>*</b></span><input value={item.title} onChange={(event) => updateItem(item.id, { title: event.target.value })} placeholder="请输入明细名称" /></label>
<label><span></span><select value={item.category} onChange={(event) => updateItem(item.id, { category: event.target.value })}><option></option><option></option><option></option><option></option><option></option><option></option></select></label>
<label><span></span><input value={item.owner} onChange={(event) => updateItem(item.id, { owner: event.target.value })} /></label>
<label><span></span><select value={item.status} onChange={(event) => updateItem(item.id, { status: event.target.value })}><option></option><option></option><option></option><option></option></select></label>
<label><span></span><input type="number" min="0" value={item.quantity} onChange={(event) => updateItem(item.id, { quantity: Number(event.target.value) })} /></label>
<label><span></span><input value={item.unit} onChange={(event) => updateItem(item.id, { unit: event.target.value })} /></label>
<label><span></span><input type="date" value={item.dueDate} onChange={(event) => updateItem(item.id, { dueDate: event.target.value })} /></label>
<label className={styles.descriptionField}><span></span><textarea value={item.description} onChange={(event) => updateItem(item.id, { description: event.target.value })} placeholder="补充本条明细的内容、规格或验收要求" /></label>
</div>
<div className={styles.attachmentSection}>
<div className={styles.attachmentHeading}><div><span className={styles.imageBadge}></span><p><strong></strong><small>JPGPNGWEBP · 1.5MB · 5 </small></p></div><label className={styles.uploadButton}> <input type="file" accept="image/*" multiple onChange={(event) => { handleAttachments(item.id, event.target.files, "images"); event.currentTarget.value = ""; }} /></label></div>
<div className={styles.imageGrid}>
{item.images.map((image) => <figure key={image.id}><button className={styles.imagePreview} onClick={() => setLightbox(image)}><img src={image.url} alt={image.name} /><span></span></button><figcaption><p><strong>{image.name}</strong><small>{image.size}</small></p><button aria-label={`删除 ${image.name}`} onClick={() => updateItem(item.id, { images: item.images.filter((current) => current.id !== image.id) })}>×</button></figcaption></figure>)}
{item.images.length === 0 && <label className={styles.emptyImage}><span></span><strong></strong><small></small><input type="file" accept="image/*" multiple onChange={(event) => { handleAttachments(item.id, event.target.files, "images"); event.currentTarget.value = ""; }} /></label>}
</div>
</div>
<div className={styles.attachmentSection}>
<div className={styles.attachmentHeading}><div><span className={styles.fileBadge}></span><p><strong></strong><small> · 1MB · 6 </small></p></div><label className={styles.uploadButton}> <input type="file" multiple onChange={(event) => { handleAttachments(item.id, event.target.files, "files"); event.currentTarget.value = ""; }} /></label></div>
<div className={styles.fileList}>
{item.files.map((file) => <div key={file.id}><span className={styles.fileType}>{file.type}</span><p><strong>{file.name}</strong><small>{file.size} · </small></p>{file.url ? <a href={file.url} download={file.name}></a> : <button onClick={() => notify("这是 JSON 示例文件,仅展示文件信息")}></button>}<button aria-label={`删除 ${file.name}`} onClick={() => updateItem(item.id, { files: item.files.filter((current) => current.id !== file.id) })}>×</button></div>)}
{item.files.length === 0 && <label className={styles.emptyFile}> <input type="file" multiple onChange={(event) => { handleAttachments(item.id, event.target.files, "files"); event.currentTarget.value = ""; }} /></label>}
</div>
</div>
</div>}
</article>;
})}
{activeForm.items.length === 0 && <div className={styles.emptyItems}><span></span><h3></h3><p></p><button onClick={addItem}> </button></div>}
</div>
<button className={styles.addItemBottom} onClick={addItem}><span></span><strong></strong><small></small></button>
<footer className={styles.formFooter}><p><i className={saved ? styles.savedDot : ""} />{saved ? `上次保存:${activeForm.updatedAt}` : "存在未保存更改"}</p><div><button onClick={() => updateForm({ status: "草稿" })}>稿</button><button className={styles.submitButton} onClick={() => { updateForm({ status: "已提交" }); saveNow(); notify("表单已提交(演示)"); }}></button></div></footer>
</main>
</div>
{lightbox && <div className={styles.lightbox} onMouseDown={() => setLightbox(null)}><div onMouseDown={(event) => event.stopPropagation()}><header><p><strong>{lightbox.name}</strong><small>{lightbox.size}</small></p><button onClick={() => setLightbox(null)}>×</button></header><img src={lightbox.url} alt={lightbox.name} /></div></div>}
{toast && <div className={styles.toast}> {toast}</div>}
</div>
);
}
function emptyItem(): FormItem {
return { id: `ITEM-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, title: "", category: "页面交付", owner: "林知夏", status: "待填写", quantity: 1, unit: "项", dueDate: new Date().toISOString().slice(0, 10), description: "", images: [], files: [] };
}
function readAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}