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
No files matched your search
@@ -0,0 +1,102 @@
|
||||
name: Docker Build and Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
IMAGE_NAME: prototype-manager
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Enable Corepack
|
||||
if: github.event_name != 'pull_request'
|
||||
run: corepack enable
|
||||
|
||||
- name: Install dependencies
|
||||
if: github.event_name != 'pull_request'
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run lint
|
||||
if: github.event_name != 'pull_request'
|
||||
run: pnpm lint
|
||||
|
||||
- name: Set up QEMU
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build image (PR only, quick validation, no push)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=${{ github.ref_name }}
|
||||
cache-to: type=gha,mode=max,scope=${{ github.ref_name }}
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
if: github.event_name != 'pull_request'
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push image (multi-arch)
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=${{ github.ref_name }}
|
||||
cache-to: type=gha,mode=max,scope=${{ github.ref_name }}
|
||||
@@ -39,3 +39,6 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# runtime data
|
||||
data/
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,24 @@
|
||||
printWidth: 100
|
||||
tabWidth: 2
|
||||
useTabs: false
|
||||
semi: false
|
||||
singleQuote: false
|
||||
quoteProps: as-needed
|
||||
jsxSingleQuote: false
|
||||
trailingComma: es5
|
||||
bracketSpacing: true
|
||||
bracketSameLine: true
|
||||
arrowParens: always
|
||||
#rangeStart: 0
|
||||
#rangeEnd: n
|
||||
#parser:
|
||||
#filepath:
|
||||
#requirePragma: false
|
||||
#insertPragma: false
|
||||
#proseWrap: preserve
|
||||
htmlWhitespaceSensitivity: css
|
||||
#vueIndentScriptAndStyle: false
|
||||
endOfLine: lf
|
||||
#embeddedLanguageFormatting: auto
|
||||
# Enforce single attribute per line in HTML, Vue and JSX
|
||||
#singleAttributePerLine: false
|
||||
@@ -0,0 +1,41 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:22-alpine AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
||||
pnpm run build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV PORT=3000
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# Runtime write path for uploads/index
|
||||
RUN mkdir -p /app/data/prototypes \
|
||||
&& chown -R nextjs:nodejs /app/data
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,36 +1,86 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
This is a Next.js + Tailwind CSS based Axure prototype manager.
|
||||
|
||||
## Getting Started
|
||||
## Tech Stack
|
||||
|
||||
First, run the development server:
|
||||
- Next.js (App Router, TypeScript)
|
||||
- Tailwind CSS (atomic utility classes)
|
||||
- Node runtime API Routes
|
||||
- Local filesystem persistence (`data/prototypes`)
|
||||
- Package manager: **pnpm**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
## Features
|
||||
|
||||
- Prototype card navigation homepage
|
||||
- Upload Axure prototype ZIP package manually
|
||||
- Auto-parse and register prototype (ZIP must contain `start.html`)
|
||||
- Open prototype in **new tab**
|
||||
- Dark mode toggle (stored in `localStorage`)
|
||||
- Delete uploaded prototype
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```text
|
||||
app/
|
||||
api/prototypes/
|
||||
route.ts # list + upload
|
||||
[id]/route.ts # delete
|
||||
[id]/[...path]/route.ts # serve prototype files
|
||||
layout.tsx
|
||||
page.tsx
|
||||
components/
|
||||
AddPrototypeModal.tsx
|
||||
PrototypeCard.tsx
|
||||
ThemeToggle.tsx
|
||||
lib/
|
||||
prototype.ts
|
||||
store.ts
|
||||
data/
|
||||
prototypes/
|
||||
index.json # runtime metadata (auto-generated)
|
||||
<prototype-id>/... # unzipped Axure files
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
## Start Development
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
Open `http://localhost:3000`.
|
||||
|
||||
## Learn More
|
||||
## Build & Run Production
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
```bash
|
||||
pnpm build
|
||||
pnpm start
|
||||
```
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
## Docker (multi-stage, pnpm)
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
Build image:
|
||||
|
||||
## Deploy on Vercel
|
||||
```bash
|
||||
docker build -t prototype-manager:latest .
|
||||
```
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
Run container:
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
```bash
|
||||
docker run --rm -p 3000:3000 -v prototype-manager-data:/app/data prototype-manager:latest
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Image uses Next.js `output: "standalone"` and `pnpm` in multi-stage build.
|
||||
- Runtime data is written to `/app/data/prototypes`; mount a volume to persist uploads.
|
||||
|
||||
## Upload Requirements
|
||||
|
||||
- Only `.zip` is accepted
|
||||
- ZIP must include `start.html`
|
||||
- Keep Axure relative assets (`resources`, `data`, `images`, etc.) in original structure
|
||||
|
||||
## Notes on Deployment
|
||||
|
||||
This project writes files at runtime (upload/unzip), so it must run in a writable Node environment (self-hosted server/VM/container with persistent volume).
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { readPrototypeFile } from "@/lib/store"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{
|
||||
id: string
|
||||
path?: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
function decodePathSegments(segments: string[] = []) {
|
||||
return segments.map((segment) => decodeURIComponent(segment)).join("/")
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, context: RouteContext) {
|
||||
const { id, path } = await context.params
|
||||
const relativePath = decodePathSegments(path)
|
||||
|
||||
const file = await readPrototypeFile(id, relativePath || undefined)
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "文件不存在" }, { status: 404 })
|
||||
}
|
||||
|
||||
return new NextResponse(file.content, {
|
||||
headers: {
|
||||
"content-type": file.mimeType,
|
||||
"content-length": String(file.size),
|
||||
"last-modified": file.lastModified,
|
||||
"cache-control": "no-store",
|
||||
"x-content-type-options": "nosniff",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { deletePrototype } from "@/lib/store"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, context: RouteContext) {
|
||||
const { id } = await context.params
|
||||
const deleted = await deletePrototype(id)
|
||||
|
||||
if (!deleted) {
|
||||
return NextResponse.json({ error: "原型不存在" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true })
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { createPrototypeFromZip, listPrototypes } from "@/lib/store"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
export async function GET() {
|
||||
const prototypes = await listPrototypes()
|
||||
return NextResponse.json({ data: prototypes })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const name = `${formData.get("name") ?? ""}`
|
||||
const description = `${formData.get("description") ?? ""}`
|
||||
const file = formData.get("file")
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: "请上传 ZIP 文件" }, { status: 400 })
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
const created = await createPrototypeFromZip({
|
||||
name,
|
||||
description,
|
||||
zipBuffer: buffer,
|
||||
zipFileName: file.name,
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: created }, { status: 201 })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "上传失败"
|
||||
const status =
|
||||
message.includes("为空") ||
|
||||
message.includes("不") ||
|
||||
message.includes("非法") ||
|
||||
message.includes("过大") ||
|
||||
message.includes("未找到")
|
||||
? 400
|
||||
: 500
|
||||
|
||||
return NextResponse.json({ error: message }, { status })
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--background: #f8fafc;
|
||||
--foreground: #1e293b;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -12,15 +12,13 @@
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
.dark {
|
||||
--background: #020617;
|
||||
--foreground: #f8fafc;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-family: var(--font-geist-sans), "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
@@ -1,29 +1,26 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import type { Metadata } from "next"
|
||||
import { Geist, Geist_Mono } from "next/font/google"
|
||||
import "./globals.css"
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
})
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
title: "原型导航 | Prototype Navigation",
|
||||
description: "Axure 原型管理系统",
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<html lang="zh-CN" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -1,69 +1,10 @@
|
||||
import Image from "next/image";
|
||||
import { PrototypeManagerPage } from "@/components/PrototypeManagerPage";
|
||||
import { listPrototypes } from "@/lib/store";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert h-5 w-[100px]"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the{" "}
|
||||
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
|
||||
page.tsx
|
||||
</code>{" "}
|
||||
file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert h-[14px] w-4"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={14}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Home() {
|
||||
const initialPrototypes = await listPrototypes();
|
||||
|
||||
return <PrototypeManagerPage initialPrototypes={initialPrototypes} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const eslintConfig = defineConfig([
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
"data/**",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import dayjs from "dayjs"
|
||||
import duration from "dayjs/plugin/duration"
|
||||
|
||||
dayjs.extend(duration)
|
||||
|
||||
export default dayjs
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface PrototypeSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
entryFile: string;
|
||||
createdAt: string;
|
||||
viewUrl: string;
|
||||
}
|
||||
|
||||
export const THEME_STORAGE_KEY = "prototype-dark-mode";
|
||||
@@ -0,0 +1,331 @@
|
||||
import AdmZip from "adm-zip";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { PrototypeSummary } from "@/lib/prototype";
|
||||
|
||||
interface PrototypeRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
entryFile: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const DATA_ROOT = path.join(process.cwd(), "data", "prototypes");
|
||||
const INDEX_FILE = path.join(DATA_ROOT, "index.json");
|
||||
|
||||
const MAX_UPLOAD_BYTES = 200 * 1024 * 1024;
|
||||
const MAX_FILES = 20_000;
|
||||
const MAX_UNCOMPRESSED_BYTES = 600 * 1024 * 1024;
|
||||
|
||||
let writeQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
function queueWrite<T>(work: () => Promise<T>): Promise<T> {
|
||||
const next = writeQueue.then(work, work);
|
||||
writeQueue = next.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
async function ensureDataRoot() {
|
||||
await fs.mkdir(DATA_ROOT, { recursive: true });
|
||||
}
|
||||
|
||||
async function readIndex(): Promise<PrototypeRecord[]> {
|
||||
await ensureDataRoot();
|
||||
|
||||
try {
|
||||
const raw = await fs.readFile(INDEX_FILE, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed.filter((item): item is PrototypeRecord => {
|
||||
return (
|
||||
typeof item?.id === "string" &&
|
||||
typeof item?.name === "string" &&
|
||||
typeof item?.description === "string" &&
|
||||
typeof item?.entryFile === "string" &&
|
||||
typeof item?.createdAt === "string"
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeIndex(records: PrototypeRecord[]) {
|
||||
await ensureDataRoot();
|
||||
const tempFile = `${INDEX_FILE}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
await fs.writeFile(tempFile, JSON.stringify(records, null, 2), "utf-8");
|
||||
await fs.rename(tempFile, INDEX_FILE);
|
||||
}
|
||||
|
||||
function toSummary(record: PrototypeRecord): PrototypeSummary {
|
||||
return {
|
||||
...record,
|
||||
viewUrl: `/api/prototypes/${encodeURIComponent(record.id)}/${encodePathSegments(record.entryFile)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function generateId() {
|
||||
return `proto-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
|
||||
function isPathInside(rootDir: string, candidatePath: string) {
|
||||
const root = path.resolve(rootDir);
|
||||
const candidate = path.resolve(candidatePath);
|
||||
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
||||
}
|
||||
|
||||
function sanitizeZipEntry(entryName: string) {
|
||||
const normalized = entryName.replace(/\\/g, "/");
|
||||
const cleaned = path.posix.normalize(normalized);
|
||||
|
||||
if (!cleaned || cleaned === ".") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
cleaned.startsWith("../") ||
|
||||
cleaned === ".." ||
|
||||
cleaned.startsWith("/") ||
|
||||
/^[A-Za-z]:/.test(cleaned)
|
||||
) {
|
||||
throw new Error(`ZIP 中包含非法路径:${entryName}`);
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function detectMimeType(filePath: string) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
switch (ext) {
|
||||
case ".html":
|
||||
case ".htm":
|
||||
return "text/html; charset=utf-8";
|
||||
case ".css":
|
||||
return "text/css; charset=utf-8";
|
||||
case ".js":
|
||||
return "application/javascript; charset=utf-8";
|
||||
case ".json":
|
||||
return "application/json; charset=utf-8";
|
||||
case ".svg":
|
||||
return "image/svg+xml";
|
||||
case ".png":
|
||||
return "image/png";
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
return "image/jpeg";
|
||||
case ".gif":
|
||||
return "image/gif";
|
||||
case ".webp":
|
||||
return "image/webp";
|
||||
case ".ico":
|
||||
return "image/x-icon";
|
||||
case ".woff":
|
||||
return "font/woff";
|
||||
case ".woff2":
|
||||
return "font/woff2";
|
||||
case ".ttf":
|
||||
return "font/ttf";
|
||||
default:
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
||||
function encodePathSegments(relativePath: string) {
|
||||
return relativePath
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
}
|
||||
|
||||
export async function listPrototypes(): Promise<PrototypeSummary[]> {
|
||||
const records = await readIndex();
|
||||
return records
|
||||
.slice()
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
.map(toSummary);
|
||||
}
|
||||
|
||||
export async function findPrototypeById(id: string): Promise<PrototypeSummary | null> {
|
||||
const records = await readIndex();
|
||||
const found = records.find((item) => item.id === id);
|
||||
return found ? toSummary(found) : null;
|
||||
}
|
||||
|
||||
export async function createPrototypeFromZip(input: {
|
||||
name: string;
|
||||
description?: string;
|
||||
zipFileName: string;
|
||||
zipBuffer: Buffer;
|
||||
}): Promise<PrototypeSummary> {
|
||||
return queueWrite(async () => {
|
||||
const name = input.name.trim();
|
||||
if (!name) {
|
||||
throw new Error("原型名称不能为空");
|
||||
}
|
||||
|
||||
if (!input.zipFileName.toLowerCase().endsWith(".zip")) {
|
||||
throw new Error("仅支持上传 ZIP 文件");
|
||||
}
|
||||
|
||||
if (input.zipBuffer.byteLength > MAX_UPLOAD_BYTES) {
|
||||
throw new Error("上传文件过大,请控制在 200MB 以内");
|
||||
}
|
||||
|
||||
const zip = new AdmZip(input.zipBuffer);
|
||||
const entries = zip.getEntries();
|
||||
|
||||
if (!entries.length) {
|
||||
throw new Error("ZIP 文件为空");
|
||||
}
|
||||
|
||||
if (entries.length > MAX_FILES) {
|
||||
throw new Error(`ZIP 文件数量过多(>${MAX_FILES})`);
|
||||
}
|
||||
|
||||
await ensureDataRoot();
|
||||
|
||||
const id = generateId();
|
||||
const tempDir = path.join(DATA_ROOT, `.tmp-${id}`);
|
||||
const finalDir = path.join(DATA_ROOT, id);
|
||||
|
||||
const extractedFiles: string[] = [];
|
||||
let totalUncompressed = 0;
|
||||
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
|
||||
try {
|
||||
for (const entry of entries) {
|
||||
const safePath = sanitizeZipEntry(entry.entryName);
|
||||
if (!safePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const target = path.join(tempDir, ...safePath.split("/"));
|
||||
if (!isPathInside(tempDir, target)) {
|
||||
throw new Error(`ZIP 中包含非法路径:${entry.entryName}`);
|
||||
}
|
||||
|
||||
if (entry.isDirectory) {
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
totalUncompressed += entry.header.size;
|
||||
if (totalUncompressed > MAX_UNCOMPRESSED_BYTES) {
|
||||
throw new Error("解压后文件总体积过大");
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.writeFile(target, entry.getData());
|
||||
extractedFiles.push(safePath);
|
||||
}
|
||||
|
||||
const startCandidates = extractedFiles
|
||||
.filter((item) => item.toLowerCase().endsWith("/start.html") || item.toLowerCase() === "start.html")
|
||||
.sort((a, b) => a.length - b.length);
|
||||
|
||||
if (!startCandidates.length) {
|
||||
throw new Error("ZIP 中未找到 start.html");
|
||||
}
|
||||
|
||||
const entryFile = startCandidates[0];
|
||||
|
||||
await fs.rm(finalDir, { recursive: true, force: true });
|
||||
await fs.rename(tempDir, finalDir);
|
||||
|
||||
const records = await readIndex();
|
||||
const record: PrototypeRecord = {
|
||||
id,
|
||||
name,
|
||||
description: input.description?.trim() ?? "",
|
||||
createdAt: new Date().toISOString(),
|
||||
entryFile,
|
||||
};
|
||||
|
||||
records.push(record);
|
||||
await writeIndex(records);
|
||||
|
||||
return toSummary(record);
|
||||
} catch (error) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function deletePrototype(id: string): Promise<boolean> {
|
||||
return queueWrite(async () => {
|
||||
const records = await readIndex();
|
||||
const target = records.find((item) => item.id === id);
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const remained = records.filter((item) => item.id !== id);
|
||||
await writeIndex(remained);
|
||||
|
||||
const targetDir = path.join(DATA_ROOT, id);
|
||||
if (isPathInside(DATA_ROOT, targetDir)) {
|
||||
await fs.rm(targetDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function readPrototypeFile(id: string, relativePath?: string) {
|
||||
const records = await readIndex();
|
||||
const target = records.find((item) => item.id === id);
|
||||
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requested = relativePath?.trim() ? relativePath : target.entryFile;
|
||||
const normalized = sanitizeZipEntry(requested);
|
||||
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filePath = path.join(DATA_ROOT, id, ...normalized.split("/"));
|
||||
|
||||
if (!isPathInside(path.join(DATA_ROOT, id), filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
if (!stat.isFile()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath);
|
||||
|
||||
return {
|
||||
content,
|
||||
mimeType: detectMimeType(filePath),
|
||||
size: stat.size,
|
||||
lastModified: stat.mtime.toUTCString(),
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -9,6 +9,8 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.6.0",
|
||||
"dayjs": "^1.11.23",
|
||||
"next": "16.3.3",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
@@ -20,6 +22,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.3",
|
||||
"prettier": "^3.9.6",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
unrs-resolver: true
|
||||
@@ -1 +1,4 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z"
|
||||
clip-rule="evenodd" fill="#666" fill-rule="evenodd"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 391 B After Width: | Height: | Size: 408 B |
@@ -1 +1,12 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<g clip-path="url(#a)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1"
|
||||
fill="#666"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<path fill="#fff" d="M0 0h16v16H0z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80">
|
||||
<path fill="#000"
|
||||
d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/>
|
||||
<path fill="#000"
|
||||
d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -1 +1,3 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000">
|
||||
<path d="m577.3 0 577.4 1000H0z" fill="#fff"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 128 B After Width: | Height: | Size: 135 B |
@@ -1 +1,5 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5"
|
||||
fill="#666"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 385 B After Width: | Height: | Size: 412 B |