feat(prototype): add upload manager and docker ci
- 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
This commit is contained in:
30 files changed
+5369
-6899
No files matched your search
@@ -0,0 +1,162 @@
|
||||
"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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { PrototypeSummary } from "@/lib/prototype";
|
||||
|
||||
interface PrototypeCardProps {
|
||||
prototype: PrototypeSummary;
|
||||
onDelete?: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function PrototypeCard({ prototype, onDelete }: PrototypeCardProps) {
|
||||
return (
|
||||
<article className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm transition hover:-translate-y-1 hover:shadow-lg dark:border-slate-700 dark:bg-slate-900">
|
||||
<div className="flex h-40 items-center justify-center bg-slate-100 text-6xl text-slate-400 dark:bg-slate-800 dark:text-slate-500">
|
||||
<span aria-hidden>🧭</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-6">
|
||||
<div>
|
||||
<h2 className="line-clamp-2 text-2xl font-semibold text-slate-900 dark:text-slate-50">
|
||||
{prototype.name}
|
||||
</h2>
|
||||
{prototype.description ? (
|
||||
<p className="mt-2 line-clamp-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
{prototype.description}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-slate-400 dark:text-slate-500">暂无描述</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-full bg-sky-50 px-3 py-1 text-xs text-sky-700 dark:bg-sky-500/20 dark:text-sky-200">
|
||||
上传时间:{new Date(prototype.createdAt).toLocaleString()}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<a
|
||||
href={prototype.viewUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex flex-1 items-center justify-center rounded-lg bg-sky-600 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-sky-700"
|
||||
>
|
||||
查看原型
|
||||
</a>
|
||||
{onDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(prototype.id)}
|
||||
className="inline-flex items-center justify-center rounded-lg border border-red-200 px-4 py-2.5 text-sm font-medium text-red-600 transition hover:bg-red-50 dark:border-red-900 dark:text-red-300 dark:hover:bg-red-950/40"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { AddPrototypeModal } from "@/components/AddPrototypeModal";
|
||||
import { PrototypeCard } from "@/components/PrototypeCard";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { PrototypeSummary } from "@/lib/prototype";
|
||||
import dayjs from "@/lib/dayjs"
|
||||
|
||||
interface PrototypeManagerPageProps {
|
||||
initialPrototypes: PrototypeSummary[];
|
||||
}
|
||||
|
||||
export function PrototypeManagerPage({ initialPrototypes }: PrototypeManagerPageProps) {
|
||||
const [prototypes, setPrototypes] = useState<PrototypeSummary[]>(initialPrototypes);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
|
||||
const onCreated = useCallback((item: PrototypeSummary) => {
|
||||
setPrototypes((prev) => [item, ...prev.filter((current) => current.id !== item.id)]);
|
||||
}, []);
|
||||
|
||||
const onDelete = useCallback(async (id: string) => {
|
||||
const confirmed = window.confirm("确定删除这个原型吗?删除后不可恢复。");
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/prototypes/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({ error: "删除失败" }));
|
||||
throw new Error(payload.error || "删除失败");
|
||||
}
|
||||
|
||||
setPrototypes((prev) => prev.filter((item) => item.id !== id));
|
||||
}, []);
|
||||
|
||||
const filteredPrototypes = useMemo(() => {
|
||||
const query = keyword.trim().toLowerCase();
|
||||
|
||||
if (!query) {
|
||||
return prototypes;
|
||||
}
|
||||
|
||||
return prototypes.filter((item) => item.name.toLowerCase().includes(query));
|
||||
}, [keyword, prototypes]);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!prototypes.length) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-slate-300 bg-white px-6 py-16 text-center dark:border-slate-700 dark:bg-slate-900">
|
||||
<p className="text-base text-slate-600 dark:text-slate-300">暂无原型,请点击右上角“添加原型”进行上传。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!filteredPrototypes.length) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-slate-300 bg-white px-6 py-16 text-center dark:border-slate-700 dark:bg-slate-900">
|
||||
<p className="text-base text-slate-600 dark:text-slate-300">
|
||||
未找到名称包含“{keyword}”的原型。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredPrototypes.map((item) => (
|
||||
<PrototypeCard
|
||||
key={item.id}
|
||||
prototype={item}
|
||||
onDelete={async (id) => {
|
||||
try {
|
||||
await onDelete(id);
|
||||
} catch (deleteError) {
|
||||
const message =
|
||||
deleteError instanceof Error ? deleteError.message : "删除失败,请稍后重试";
|
||||
window.alert(message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}, [filteredPrototypes, keyword, onDelete, prototypes]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-800 dark:bg-slate-950 dark:text-slate-100">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col px-4 py-8 md:px-8 md:py-10">
|
||||
<header className="mb-10 flex flex-wrap items-end justify-between gap-4 border-b border-slate-200 pb-8 dark:border-slate-800">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-sky-600 dark:text-sky-400">原型导航</h1>
|
||||
<p className="mt-2 text-slate-600 dark:text-slate-300">Axure 原型集合 · Prototype Collection</p>
|
||||
</div>
|
||||
<AddPrototypeModal onCreated={onCreated} />
|
||||
</header>
|
||||
|
||||
<main className="flex-1 space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="根据名称筛选原型..."
|
||||
className="w-full max-w-md rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm outline-none transition focus:ring-2 focus:ring-sky-200 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
/>
|
||||
{keyword ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setKeyword("")}
|
||||
className="rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-700 transition hover:bg-slate-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{content}
|
||||
</main>
|
||||
|
||||
<footer className="mt-16 border-t border-slate-200 pt-8 text-center text-sm text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
||||
© {dayjs().format("YYYY")} 原型导航 | Powered by OnixByte
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { THEME_STORAGE_KEY } from "@/lib/prototype";
|
||||
|
||||
function getInitialTheme() {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const saved = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
|
||||
if (saved === "true") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (saved === "false") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
|
||||
function applyTheme(isDark: boolean) {
|
||||
document.documentElement.classList.toggle("dark", isDark);
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [darkMode, setDarkMode] = useState(getInitialTheme);
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(darkMode);
|
||||
localStorage.setItem(THEME_STORAGE_KEY, String(darkMode));
|
||||
}, [darkMode]);
|
||||
|
||||
const toggle = () => {
|
||||
setDarkMode((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="fixed right-8 bottom-8 z-50 flex h-12 w-12 items-center justify-center rounded-full border border-slate-200 bg-white text-xl shadow-md transition hover:-translate-y-0.5 dark:border-slate-700 dark:bg-slate-900"
|
||||
aria-label={darkMode ? "切换到浅色模式" : "切换到深色模式"}
|
||||
title={darkMode ? "浅色模式" : "深色模式"}
|
||||
>
|
||||
{darkMode ? "☀️" : "🌙"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user