- add prototype upload, list, view, and delete api routes - add prototype manager ui with search, modal, and theme toggle - add filesystem-backed zip extraction and serving logic - switch project to pnpm with updated lockfile and tooling - add multi-stage dockerfile with standalone next.js output - add github actions to lint, build, and publish docker images
163 lines
6.1 KiB
TypeScript
163 lines
6.1 KiB
TypeScript
"use client";
|
|
|
|
import { FormEvent, useMemo, useState } from "react";
|
|
import { PrototypeSummary } from "@/lib/prototype";
|
|
|
|
interface AddPrototypeModalProps {
|
|
onCreated: (item: PrototypeSummary) => void;
|
|
}
|
|
|
|
type UploadState = "idle" | "submitting";
|
|
|
|
export function AddPrototypeModal({ onCreated }: AddPrototypeModalProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const [uploadState, setUploadState] = useState<UploadState>("idle");
|
|
const [error, setError] = useState<string>("");
|
|
|
|
const [name, setName] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [file, setFile] = useState<File | null>(null);
|
|
|
|
const canSubmit = useMemo(() => {
|
|
return uploadState !== "submitting" && name.trim().length > 0 && Boolean(file);
|
|
}, [uploadState, name, file]);
|
|
|
|
const reset = () => {
|
|
setName("");
|
|
setDescription("");
|
|
setFile(null);
|
|
setError("");
|
|
setUploadState("idle");
|
|
};
|
|
|
|
const close = () => {
|
|
setOpen(false);
|
|
reset();
|
|
};
|
|
|
|
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
|
|
if (!file) {
|
|
setError("请选择 ZIP 文件");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setUploadState("submitting");
|
|
setError("");
|
|
|
|
const formData = new FormData();
|
|
formData.append("name", name.trim());
|
|
formData.append("description", description.trim());
|
|
formData.append("file", file);
|
|
|
|
const response = await fetch("/api/prototypes", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
const payload = await response.json();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(payload.error || "上传失败");
|
|
}
|
|
|
|
onCreated(payload.data as PrototypeSummary);
|
|
close();
|
|
} catch (submitError) {
|
|
setError(submitError instanceof Error ? submitError.message : "上传失败");
|
|
setUploadState("idle");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(true)}
|
|
className="rounded-xl bg-sky-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-sky-700"
|
|
>
|
|
添加原型
|
|
</button>
|
|
|
|
{open ? (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4">
|
|
<div className="w-full max-w-xl rounded-2xl border border-slate-200 bg-white p-6 shadow-xl dark:border-slate-700 dark:bg-slate-900">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100">上传 Axure 原型</h2>
|
|
<button
|
|
type="button"
|
|
onClick={close}
|
|
className="rounded-md px-2 py-1 text-slate-500 transition hover:bg-slate-100 dark:hover:bg-slate-800"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
<form className="space-y-4" onSubmit={onSubmit}>
|
|
<label className="block space-y-1">
|
|
<span className="text-sm text-slate-700 dark:text-slate-300">原型名称 *</span>
|
|
<input
|
|
type="text"
|
|
value={name}
|
|
onChange={(event) => setName(event.target.value)}
|
|
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-50"
|
|
placeholder="例如:CRM 完整版"
|
|
required
|
|
/>
|
|
</label>
|
|
|
|
<label className="block space-y-1">
|
|
<span className="text-sm text-slate-700 dark:text-slate-300">原型描述</span>
|
|
<textarea
|
|
rows={3}
|
|
value={description}
|
|
onChange={(event) => setDescription(event.target.value)}
|
|
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-200 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-50"
|
|
placeholder="例如:CRM 项目开发原型"
|
|
/>
|
|
</label>
|
|
|
|
<label className="block space-y-1">
|
|
<span className="text-sm text-slate-700 dark:text-slate-300">ZIP 文件 *</span>
|
|
<input
|
|
type="file"
|
|
accept=".zip,application/zip,application/x-zip-compressed"
|
|
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
|
|
className="block w-full text-sm text-slate-600 file:mr-4 file:rounded-md file:border-0 file:bg-sky-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-sky-700 hover:file:bg-sky-100 dark:text-slate-300 dark:file:bg-sky-500/20 dark:file:text-sky-200"
|
|
required
|
|
/>
|
|
<p className="text-xs text-slate-500 dark:text-slate-400">ZIP 内必须包含 start.html 与 Axure 资源文件。</p>
|
|
</label>
|
|
|
|
{error ? (
|
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300">
|
|
{error}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="flex justify-end gap-3 pt-2">
|
|
<button
|
|
type="button"
|
|
onClick={close}
|
|
className="rounded-lg border border-slate-300 px-4 py-2 text-sm text-slate-700 transition hover:bg-slate-100 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-800"
|
|
>
|
|
取消
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={!canSubmit}
|
|
className="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{uploadState === "submitting" ? "上传中..." : "确认上传"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|