Update WBBR frontend template collection

This commit is contained in:
Codex
2026-08-27 20:15:24 +08:00
parent bd4d54a110
commit e444605e5d
85 changed files with 6322 additions and 115 deletions

View File

@@ -0,0 +1,319 @@
"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`;
}