320 lines
21 KiB
TypeScript
320 lines
21 KiB
TypeScript
"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>JPG、PNG、WEBP · 新上传单张不超过 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`;
|
||
}
|