From ed72045f5e1d8fbc26e9dca2fcfec7a76071988a Mon Sep 17 00:00:00 2001 From: siujamo Date: Mon, 31 Aug 2026 13:43:59 +0800 Subject: [PATCH] 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 --- .github/workflows/docker-build-push.yml | 102 + .gitignore | 3 + .idea/vcs.xml | 6 + .prettierrc.yaml | 24 + Dockerfile | 41 + README.md | 94 +- app/api/prototypes/[id]/[...path]/route.ts | 36 + app/api/prototypes/[id]/route.ts | 21 + app/api/prototypes/route.ts | 44 + app/globals.css | 16 +- app/layout.tsx | 23 +- app/page.tsx | 75 +- components/AddPrototypeModal.tsx | 162 + components/PrototypeCard.tsx | 55 + components/PrototypeManagerPage.tsx | 132 + components/ThemeToggle.tsx | 51 + eslint.config.mjs | 1 + lib/dayjs.ts | 6 + lib/prototype.ts | 10 + lib/store.ts | 331 + next.config.ts | 2 +- package-lock.json | 6782 -------------------- package.json | 3 + pnpm-lock.yaml | 4211 ++++++++++++ pnpm-workspace.yaml | 2 + public/file.svg | 5 +- public/globe.svg | 13 +- public/next.svg | 7 +- public/vercel.svg | 4 +- public/window.svg | 6 +- 30 files changed, 5369 insertions(+), 6899 deletions(-) create mode 100644 .github/workflows/docker-build-push.yml create mode 100644 .idea/vcs.xml create mode 100644 .prettierrc.yaml create mode 100644 Dockerfile create mode 100644 app/api/prototypes/[id]/[...path]/route.ts create mode 100644 app/api/prototypes/[id]/route.ts create mode 100644 app/api/prototypes/route.ts create mode 100644 components/AddPrototypeModal.tsx create mode 100644 components/PrototypeCard.tsx create mode 100644 components/PrototypeManagerPage.tsx create mode 100644 components/ThemeToggle.tsx create mode 100644 lib/dayjs.ts create mode 100644 lib/prototype.ts create mode 100644 lib/store.ts delete mode 100644 package-lock.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml new file mode 100644 index 0000000..27c4815 --- /dev/null +++ b/.github/workflows/docker-build-push.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index 5ef6a52..4dc104e 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# runtime data +data/ diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.prettierrc.yaml b/.prettierrc.yaml new file mode 100644 index 0000000..4faf7c3 --- /dev/null +++ b/.prettierrc.yaml @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ffa431d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index e215bc4..647b365 100644 --- a/README.md +++ b/README.md @@ -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) + /... # 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). diff --git a/app/api/prototypes/[id]/[...path]/route.ts b/app/api/prototypes/[id]/[...path]/route.ts new file mode 100644 index 0000000..da2f7fc --- /dev/null +++ b/app/api/prototypes/[id]/[...path]/route.ts @@ -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", + }, + }) +} diff --git a/app/api/prototypes/[id]/route.ts b/app/api/prototypes/[id]/route.ts new file mode 100644 index 0000000..71f9604 --- /dev/null +++ b/app/api/prototypes/[id]/route.ts @@ -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 }) +} diff --git a/app/api/prototypes/route.ts b/app/api/prototypes/route.ts new file mode 100644 index 0000000..89c36a1 --- /dev/null +++ b/app/api/prototypes/route.ts @@ -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 }) + } +} diff --git a/app/globals.css b/app/globals.css index a2dc41e..5ae545a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; } diff --git a/app/layout.tsx b/app/layout.tsx index 9852c15..2593eb1 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( - + {children} - ); + ) } diff --git a/app/page.tsx b/app/page.tsx index c887311..2ccc479 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,69 +1,10 @@ -import Image from "next/image"; +import { PrototypeManagerPage } from "@/components/PrototypeManagerPage"; +import { listPrototypes } from "@/lib/store"; -export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the{" "} - - page.tsx - {" "} - file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- - Vercel logomark - Deploy Now - - - Documentation - -
-
-
- ); +export const dynamic = "force-dynamic"; + +export default async function Home() { + const initialPrototypes = await listPrototypes(); + + return ; } diff --git a/components/AddPrototypeModal.tsx b/components/AddPrototypeModal.tsx new file mode 100644 index 0000000..7144486 --- /dev/null +++ b/components/AddPrototypeModal.tsx @@ -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("idle"); + const [error, setError] = useState(""); + + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [file, setFile] = useState(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) => { + 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 ( + <> + + + {open ? ( +
+
+
+

上传 Axure 原型

+ +
+ +
+ + +