From db74381f133569630b72b5156bd6edd232b43cd0 Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Thu, 16 Apr 2026 15:34:47 +0800 Subject: [PATCH] init: init prok --- .env.example | 5 + .gitignore | 9 + CLAUDE.md | 85 + README.md | 36 + package.json | 30 + packages/server/package.json | 30 + packages/server/src/index.ts | 36 + packages/server/src/lib/jwt.ts | 16 + packages/server/src/lib/qrcode.ts | 12 + packages/server/src/middleware/admin.ts | 11 + packages/server/src/middleware/auth.ts | 36 + packages/server/src/routes/admin.ts | 197 ++ packages/server/src/routes/auth.ts | 66 + packages/server/src/routes/redemption.ts | 79 + packages/server/src/routes/stamps.ts | 85 + packages/server/src/seed.ts | 68 + packages/server/tsconfig.json | 8 + packages/server/uploads/.gitkeep | 0 packages/shared/package.json | 16 + packages/shared/src/db.ts | 3 + packages/shared/src/index.ts | 2 + packages/shared/src/types.ts | 30 + packages/shared/tsconfig.json | 8 + packages/web/index.html | 18 + packages/web/package.json | 26 + packages/web/src/App.tsx | 48 + packages/web/src/admin/AdminGuard.tsx | 7 + packages/web/src/admin/AdminLayout.tsx | 54 + packages/web/src/admin/AdminLogin.tsx | 56 + packages/web/src/admin/RedemptionLog.tsx | 94 + packages/web/src/admin/RuleForm.tsx | 138 + packages/web/src/admin/RuleList.tsx | 107 + packages/web/src/admin/StampForm.tsx | 181 ++ packages/web/src/admin/StampList.tsx | 119 + packages/web/src/admin/StampQRCode.tsx | 96 + packages/web/src/admin/adminApi.ts | 21 + .../web/src/components/FloatingButton.tsx | 25 + packages/web/src/components/RedeemModal.tsx | 94 + packages/web/src/components/RegisterModal.tsx | 131 + packages/web/src/components/StampCard.tsx | 68 + packages/web/src/components/StampGrid.tsx | 24 + packages/web/src/components/StampPopup.tsx | 94 + packages/web/src/index.css | 196 ++ packages/web/src/lib/api.ts | 41 + packages/web/src/lib/auth.tsx | 66 + packages/web/src/main.tsx | 13 + packages/web/src/pages/AlbumPage.tsx | 164 + packages/web/src/pages/LandingPage.tsx | 288 ++ packages/web/tsconfig.json | 11 + packages/web/vite.config.ts | 13 + pnpm-lock.yaml | 2633 +++++++++++++++++ pnpm-workspace.yaml | 2 + .../20260416035619_init/migration.sql | 69 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 67 + tsconfig.base.json | 15 + 56 files changed, 5850 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 package.json create mode 100644 packages/server/package.json create mode 100644 packages/server/src/index.ts create mode 100644 packages/server/src/lib/jwt.ts create mode 100644 packages/server/src/lib/qrcode.ts create mode 100644 packages/server/src/middleware/admin.ts create mode 100644 packages/server/src/middleware/auth.ts create mode 100644 packages/server/src/routes/admin.ts create mode 100644 packages/server/src/routes/auth.ts create mode 100644 packages/server/src/routes/redemption.ts create mode 100644 packages/server/src/routes/stamps.ts create mode 100644 packages/server/src/seed.ts create mode 100644 packages/server/tsconfig.json create mode 100644 packages/server/uploads/.gitkeep create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/db.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/types.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 packages/web/index.html create mode 100644 packages/web/package.json create mode 100644 packages/web/src/App.tsx create mode 100644 packages/web/src/admin/AdminGuard.tsx create mode 100644 packages/web/src/admin/AdminLayout.tsx create mode 100644 packages/web/src/admin/AdminLogin.tsx create mode 100644 packages/web/src/admin/RedemptionLog.tsx create mode 100644 packages/web/src/admin/RuleForm.tsx create mode 100644 packages/web/src/admin/RuleList.tsx create mode 100644 packages/web/src/admin/StampForm.tsx create mode 100644 packages/web/src/admin/StampList.tsx create mode 100644 packages/web/src/admin/StampQRCode.tsx create mode 100644 packages/web/src/admin/adminApi.ts create mode 100644 packages/web/src/components/FloatingButton.tsx create mode 100644 packages/web/src/components/RedeemModal.tsx create mode 100644 packages/web/src/components/RegisterModal.tsx create mode 100644 packages/web/src/components/StampCard.tsx create mode 100644 packages/web/src/components/StampGrid.tsx create mode 100644 packages/web/src/components/StampPopup.tsx create mode 100644 packages/web/src/index.css create mode 100644 packages/web/src/lib/api.ts create mode 100644 packages/web/src/lib/auth.tsx create mode 100644 packages/web/src/main.tsx create mode 100644 packages/web/src/pages/AlbumPage.tsx create mode 100644 packages/web/src/pages/LandingPage.tsx create mode 100644 packages/web/tsconfig.json create mode 100644 packages/web/vite.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 prisma/migrations/20260416035619_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 tsconfig.base.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..43df6a1 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +DATABASE_URL="file:./dev.db" +JWT_SECRET="change-me-to-a-random-secret" +ADMIN_API_KEY="change-me-to-a-random-key" +SERVER_PORT=3000 +SITE_URL="http://localhost:5173" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e56d1c --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +*.db +*.db-journal +.env +uploads/* +!uploads/.gitkeep +.DS_Store +*.tsbuildinfo diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..441eae4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,85 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +CityWalk 图章收集系统 — 移动端 H5 + 管理后台。游客在城市点位扫描二维码收集图章,集满可兑换奖品,兑换后图章清空,支持重复收集。 + +## Commands + +```bash +# Development (需要同时运行两个服务) +pnpm dev:server # Express API on :3000 +pnpm dev:web # Vite dev server on :5173 + +# Database +pnpm db:generate # Generate Prisma client after schema changes +pnpm db:migrate # Create and apply migrations (prisma migrate dev) +pnpm db:push # Push schema directly (dev only, no migration file) +pnpm db:seed # Seed sample data (9 stamps + 4 redemption rules) + +# Build +pnpm build # Build all packages +``` + +## Architecture + +pnpm monorepo with 3 packages sharing `tsconfig.base.json` (target ES2022, moduleResolution bundler): + +- **`packages/shared`** — Prisma client singleton + shared TypeScript types (`ApiResponse`, `StampWithStatus`, etc.) +- **`packages/server`** (port 3000) — Express 5 + Zod validation + JWT auth. Routes: auth, stamps, collection, redemption, admin. File uploads via multer to `packages/server/uploads/`. +- **`packages/web`** (port 5173) — React 19 + Vite 8 + Tailwind CSS 4. Dual interface: mobile H5 (/, /album) + PC admin (/admin/*). Vite proxies `/api` and `/uploads` to server. + +### API Response Format + +All endpoints return: `{ success: boolean, data?: T, error?: { code: string, message: string } }` + +### Authentication + +- **Users**: JWT (7-day expiry, single token in localStorage `stamp_token`). Middleware: `requireAuth`, `optionalAuth`. +- **Admin**: API key via `X-Admin-Key` header (matched against `ADMIN_API_KEY` env var). Middleware: `requireAdmin`. + +### Frontend Routing + +``` +/ → LandingPage (also handles /?stamp={id} for collection popup) +/album → AlbumPage (stamp grid + redemption) +/collect/:stampId → Redirects to /?stamp={stampId} (QR code entry point) +/admin → AdminLogin +/admin/stamps → Stamp CRUD + QR code generation +/admin/rules → Redemption rule CRUD +/admin/redemptions → Redemption history + stats +``` + +### Collection Flow + +QR codes encode `/collect/{stampId}` → redirects to `/?stamp={stampId}` → LandingPage shows collection overlay. All interactions (register, collect, close) happen as modals on top of the landing page. The stamp ID is stored in `sessionStorage` during registration to resume collection after auth. + +### Redemption Transaction + +Atomic: `prisma.$transaction` creates Redemption record + deletes all user Collections. The `@@unique([userId, stampId])` constraint resets after deletion, allowing re-collection. + +## Critical: Tailwind CSS v4 Layer Architecture + +Custom CSS in `packages/web/src/index.css` **must** use `@layer` to avoid overriding Tailwind utilities: + +```css +@import "tailwindcss"; + +@layer base { /* Reset, CSS variables, body/heading fonts */} +@keyframes ... { } /* Keyframes stay OUTSIDE layers */ +@layer components { /* .animate-*, .stagger-children, .paper-texture, etc. */} +``` + +**Why**: In Tailwind CSS v4, unlayered styles always beat `@layer utilities`. If base resets like `* { padding: 0 }` are unlayered, they override Tailwind's `px-4`, `py-3`, etc., breaking all utility spacing across the app. + +## Environment + +``` +DATABASE_URL="file:./dev.db" # SQLite (relative to project root) +JWT_SECRET="..." # JWT signing key +ADMIN_API_KEY="..." # Admin panel access key +SERVER_PORT=3000 +SITE_URL="http://localhost:5173" # Used in QR code URL generation +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..b4d120e --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# CityWalk 图章收集系统 + +游客在城市不同点位扫描二维码,收集图章,集满兑换奖品。兑换后图章清空,可重复挑战。 + +## 快速开始 + +```bash +pnpm install +cp .env.example .env +pnpm db:push +pnpm db:seed + +# 启动(需同时运行) +pnpm dev:server # API :3000 +pnpm dev:web # 前端 :5173 +``` + +- 用户端:http://localhost:5173 +- 管理后台:http://localhost:5173/admin(密钥见 `.env` 中 `ADMIN_API_KEY`) + +## 技术栈 + +| 前端 | 后端 | 数据库 | +|------|------|--------| +| React 19 + Vite 8 + Tailwind CSS 4 | Express 5 + TypeScript | SQLite (Prisma) | + +## 项目结构 + +``` +packages/ + shared/ Prisma client + 共享类型 + server/ Express API(认证、图章、兑换、管理) + web/ React SPA(移动端 H5 + PC 管理后台) +prisma/ + schema.prisma 数据模型(User, Stamp, Collection, RedemptionRule, Redemption) +``` diff --git a/package.json b/package.json new file mode 100644 index 0000000..4511f27 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "stamp", + "private": true, + "scripts": { + "dev:server": "pnpm --filter @stamp/server dev", + "dev:web": "pnpm --filter @stamp/web dev", + "build": "pnpm -r build", + "db:generate": "prisma generate", + "db:migrate": "prisma migrate dev", + "db:push": "prisma db push", + "db:seed": "pnpm --filter @stamp/server seed" + }, + "engines": { + "node": ">=20" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "@prisma/client", + "@prisma/engines", + "esbuild", + "prisma" + ] + }, + "devDependencies": { + "prisma": "^6.19.3" + }, + "dependencies": { + "@prisma/client": "^6.19.3" + } +} diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 0000000..0340b71 --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,30 @@ +{ + "name": "@stamp/server", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "seed": "tsx src/seed.ts" + }, + "dependencies": { + "@stamp/shared": "workspace:*", + "cors": "^2.8.5", + "express": "^5.0.0", + "jsonwebtoken": "^9.0.3", + "multer": "^1.4.5-lts.2", + "qrcode": "^1.5.4", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/jsonwebtoken": "^9.0.10", + "@types/multer": "^1.4.12", + "@types/qrcode": "^1.5.5", + "tsx": "^4.19.0", + "typescript": "~5.9.3" + } +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts new file mode 100644 index 0000000..b2c6065 --- /dev/null +++ b/packages/server/src/index.ts @@ -0,0 +1,36 @@ +import express from "express"; +import cors from "cors"; +import path from "path"; +import { fileURLToPath } from "url"; +import authRoutes from "./routes/auth.js"; +import stampRoutes from "./routes/stamps.js"; +import redemptionRoutes from "./routes/redemption.js"; +import adminRoutes from "./routes/admin.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const app = express(); +const PORT = process.env.SERVER_PORT || 3000; + +app.use(cors()); +app.use(express.json()); + +// Serve uploaded files +app.use("/uploads", express.static(path.join(__dirname, "../uploads"))); + +// Health check +app.get("/api/health", (_req, res) => { + res.json({ success: true, data: { status: "ok" } }); +}); + +// User-facing routes +app.use("/api/auth", authRoutes); +app.use("/api/stamps", stampRoutes); +app.use("/api/redemption", redemptionRoutes); + +// Admin routes +app.use("/api/admin", adminRoutes); + +app.listen(PORT, () => { + console.log(`Server running on http://localhost:${PORT}`); +}); diff --git a/packages/server/src/lib/jwt.ts b/packages/server/src/lib/jwt.ts new file mode 100644 index 0000000..43b8b62 --- /dev/null +++ b/packages/server/src/lib/jwt.ts @@ -0,0 +1,16 @@ +import jwt from "jsonwebtoken"; + +const SECRET = process.env.JWT_SECRET || "dev-secret"; +const EXPIRES_IN = "7d"; + +export function signToken(userId: string): string { + return jwt.sign({ sub: userId }, SECRET, { expiresIn: EXPIRES_IN }); +} + +export function verifyToken(token: string): { sub: string } | null { + try { + return jwt.verify(token, SECRET) as { sub: string }; + } catch { + return null; + } +} diff --git a/packages/server/src/lib/qrcode.ts b/packages/server/src/lib/qrcode.ts new file mode 100644 index 0000000..afae023 --- /dev/null +++ b/packages/server/src/lib/qrcode.ts @@ -0,0 +1,12 @@ +import QRCode from "qrcode"; + +export async function generateQRCodeDataURL( + url: string, + options?: { width?: number } +): Promise { + return QRCode.toDataURL(url, { + width: options?.width ?? 300, + margin: 2, + color: { dark: "#1a1a2e", light: "#ffffff" }, + }); +} diff --git a/packages/server/src/middleware/admin.ts b/packages/server/src/middleware/admin.ts new file mode 100644 index 0000000..f7e14ed --- /dev/null +++ b/packages/server/src/middleware/admin.ts @@ -0,0 +1,11 @@ +import type { Request, Response, NextFunction } from "express"; + +export function requireAdmin(req: Request, res: Response, next: NextFunction) { + const key = req.headers["x-admin-key"]; + const expected = process.env.ADMIN_API_KEY; + if (!expected || key !== expected) { + res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "管理员权限不足" } }); + return; + } + next(); +} diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts new file mode 100644 index 0000000..e6fb8fe --- /dev/null +++ b/packages/server/src/middleware/auth.ts @@ -0,0 +1,36 @@ +import type { Request, Response, NextFunction } from "express"; +import { verifyToken } from "../lib/jwt.js"; + +declare global { + namespace Express { + interface Request { + userId?: string; + } + } +} + +export function requireAuth(req: Request, res: Response, next: NextFunction) { + const header = req.headers.authorization; + if (!header?.startsWith("Bearer ")) { + res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "请先登录" } }); + return; + } + const payload = verifyToken(header.slice(7)); + if (!payload) { + res.status(401).json({ success: false, error: { code: "TOKEN_INVALID", message: "登录已过期,请重新登录" } }); + return; + } + req.userId = payload.sub; + next(); +} + +export function optionalAuth(req: Request, _res: Response, next: NextFunction) { + const header = req.headers.authorization; + if (header?.startsWith("Bearer ")) { + const payload = verifyToken(header.slice(7)); + if (payload) { + req.userId = payload.sub; + } + } + next(); +} diff --git a/packages/server/src/routes/admin.ts b/packages/server/src/routes/admin.ts new file mode 100644 index 0000000..a286bc9 --- /dev/null +++ b/packages/server/src/routes/admin.ts @@ -0,0 +1,197 @@ +import { Router } from "express"; +import { z } from "zod"; +import multer from "multer"; +import path from "path"; +import { fileURLToPath } from "url"; +import { prisma } from "@stamp/shared"; +import { requireAdmin } from "../middleware/admin.js"; +import { generateQRCodeDataURL } from "../lib/qrcode.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const uploadsDir = path.join(__dirname, "../../uploads"); + +const storage = multer.diskStorage({ + destination: uploadsDir, + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname); + cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`); + }, +}); +const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 } }); + +const router = Router(); +router.use(requireAdmin); + +// ===== Stamps CRUD ===== + +router.get("/stamps", async (_req, res) => { + const stamps = await prisma.stamp.findMany({ orderBy: { sortOrder: "asc" } }); + res.json({ success: true, data: stamps }); +}); + +const stampSchema = z.object({ + name: z.string().min(1, "图章名称不能为空"), + note: z.string().optional(), + imageColor: z.string().optional(), + imageGrey: z.string().optional(), + sortOrder: z.number().int().optional(), + enabled: z.boolean().optional(), +}); + +router.post("/stamps", async (req, res) => { + const parsed = stampSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } }); + return; + } + const stamp = await prisma.stamp.create({ + data: { + name: parsed.data.name, + note: parsed.data.note, + imageColor: parsed.data.imageColor ?? "", + imageGrey: parsed.data.imageGrey ?? "", + sortOrder: parsed.data.sortOrder ?? 0, + enabled: parsed.data.enabled ?? true, + }, + }); + res.json({ success: true, data: stamp }); +}); + +router.put("/stamps/:id", async (req, res) => { + const parsed = stampSchema.partial().safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } }); + return; + } + const stamp = await prisma.stamp.update({ + where: { id: req.params.id }, + data: parsed.data, + }).catch(() => null); + if (!stamp) { + res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "图章不存在" } }); + return; + } + res.json({ success: true, data: stamp }); +}); + +router.delete("/stamps/:id", async (req, res) => { + await prisma.stamp.delete({ where: { id: req.params.id } }).catch(() => null); + res.json({ success: true, data: null }); +}); + +// Upload stamp image +router.post("/stamps/:id/upload", upload.single("image"), async (req, res) => { + if (!req.file) { + res.status(400).json({ success: false, error: { code: "NO_FILE", message: "请选择图片" } }); + return; + } + + const field = req.body.field as string; + if (field !== "imageColor" && field !== "imageGrey") { + res.status(400).json({ success: false, error: { code: "INVALID_FIELD", message: "field 必须是 imageColor 或 imageGrey" } }); + return; + } + + const imagePath = `/uploads/${req.file.filename}`; + const stamp = await prisma.stamp.update({ + where: { id: req.params.id }, + data: { [field]: imagePath }, + }).catch(() => null); + + if (!stamp) { + res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "图章不存在" } }); + return; + } + res.json({ success: true, data: { path: imagePath } }); +}); + +// Generate QR code +router.get("/stamps/:id/qrcode", async (req, res) => { + const stamp = await prisma.stamp.findUnique({ where: { id: req.params.id } }); + if (!stamp) { + res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "图章不存在" } }); + return; + } + + const siteUrl = process.env.SITE_URL || "http://localhost:5173"; + const collectUrl = `${siteUrl}/collect/${stamp.id}`; + const qrDataUrl = await generateQRCodeDataURL(collectUrl, { width: 400 }); + + res.json({ success: true, data: { qrDataUrl, collectUrl, stampName: stamp.name } }); +}); + +// ===== Redemption Rules CRUD ===== + +router.get("/rules", async (_req, res) => { + const rules = await prisma.redemptionRule.findMany({ orderBy: { sortOrder: "asc" } }); + res.json({ success: true, data: rules }); +}); + +const ruleSchema = z.object({ + name: z.string().min(1, "奖品名称不能为空"), + description: z.string().optional(), + threshold: z.number().int().min(1, "兑换门槛至少为 1"), + enabled: z.boolean().optional(), + sortOrder: z.number().int().optional(), +}); + +router.post("/rules", async (req, res) => { + const parsed = ruleSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } }); + return; + } + const rule = await prisma.redemptionRule.create({ + data: { + name: parsed.data.name, + description: parsed.data.description, + threshold: parsed.data.threshold, + enabled: parsed.data.enabled ?? true, + sortOrder: parsed.data.sortOrder ?? 0, + }, + }); + res.json({ success: true, data: rule }); +}); + +router.put("/rules/:id", async (req, res) => { + const parsed = ruleSchema.partial().safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } }); + return; + } + const rule = await prisma.redemptionRule.update({ + where: { id: req.params.id }, + data: parsed.data, + }).catch(() => null); + if (!rule) { + res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "规则不存在" } }); + return; + } + res.json({ success: true, data: rule }); +}); + +router.delete("/rules/:id", async (req, res) => { + await prisma.redemptionRule.delete({ where: { id: req.params.id } }).catch(() => null); + res.json({ success: true, data: null }); +}); + +// ===== Redemption Records & Stats ===== + +router.get("/redemptions", async (_req, res) => { + const records = await prisma.redemption.findMany({ + include: { user: { select: { username: true, phone: true } }, rule: { select: { name: true } } }, + orderBy: { redeemedAt: "desc" }, + }); + res.json({ success: true, data: records }); +}); + +router.get("/stats", async (_req, res) => { + const [userCount, collectionCount, redemptionCount] = await Promise.all([ + prisma.user.count(), + prisma.collection.count(), + prisma.redemption.count(), + ]); + res.json({ success: true, data: { userCount, collectionCount, redemptionCount } }); +}); + +export default router; diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts new file mode 100644 index 0000000..9af31d0 --- /dev/null +++ b/packages/server/src/routes/auth.ts @@ -0,0 +1,66 @@ +import { Router } from "express"; +import { z } from "zod"; +import { prisma } from "@stamp/shared"; +import { signToken } from "../lib/jwt.js"; +import { requireAuth } from "../middleware/auth.js"; + +const router = Router(); + +const phoneRegex = /^1[3-9]\d{9}$/; + +const registerSchema = z.object({ + username: z.string().min(1, "用户名不能为空").max(20, "用户名最长 20 字符"), + phone: z.string().regex(phoneRegex, "手机号格式不正确"), +}); + +const loginSchema = z.object({ + phone: z.string().regex(phoneRegex, "手机号格式不正确"), +}); + +router.post("/register", async (req, res) => { + const parsed = registerSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } }); + return; + } + const { username, phone } = parsed.data; + + const existing = await prisma.user.findFirst({ + where: { OR: [{ username }, { phone }] }, + }); + if (existing) { + const field = existing.username === username ? "用户名" : "手机号"; + res.status(409).json({ success: false, error: { code: "DUPLICATE", message: `该${field}已被注册` } }); + return; + } + + const user = await prisma.user.create({ data: { username, phone } }); + const token = signToken(user.id); + res.json({ success: true, data: { user: { id: user.id, username: user.username, phone: user.phone }, token } }); +}); + +router.post("/login", async (req, res) => { + const parsed = loginSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } }); + return; + } + const user = await prisma.user.findUnique({ where: { phone: parsed.data.phone } }); + if (!user) { + res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "该手机号未注册" } }); + return; + } + const token = signToken(user.id); + res.json({ success: true, data: { user: { id: user.id, username: user.username, phone: user.phone }, token } }); +}); + +router.get("/me", requireAuth, async (req, res) => { + const user = await prisma.user.findUnique({ where: { id: req.userId } }); + if (!user) { + res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "用户不存在" } }); + return; + } + res.json({ success: true, data: { id: user.id, username: user.username, phone: user.phone } }); +}); + +export default router; diff --git a/packages/server/src/routes/redemption.ts b/packages/server/src/routes/redemption.ts new file mode 100644 index 0000000..164cb34 --- /dev/null +++ b/packages/server/src/routes/redemption.ts @@ -0,0 +1,79 @@ +import { Router } from "express"; +import { z } from "zod"; +import { prisma } from "@stamp/shared"; +import { requireAuth } from "../middleware/auth.js"; + +const router = Router(); + +router.get("/rules", async (_req, res) => { + const rules = await prisma.redemptionRule.findMany({ + where: { enabled: true }, + orderBy: { sortOrder: "asc" }, + select: { id: true, name: true, description: true, threshold: true }, + }); + res.json({ success: true, data: rules }); +}); + +const redeemSchema = z.object({ + ruleId: z.string().uuid("规则 ID 格式不正确"), +}); + +router.post("/redeem", requireAuth, async (req, res) => { + const parsed = redeemSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } }); + return; + } + + const rule = await prisma.redemptionRule.findUnique({ where: { id: parsed.data.ruleId, enabled: true } }); + if (!rule) { + res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "兑换规则不存在" } }); + return; + } + + const collectionCount = await prisma.collection.count({ where: { userId: req.userId! } }); + if (collectionCount < rule.threshold) { + res.status(400).json({ + success: false, + error: { code: "INSUFFICIENT", message: `需要收集 ${rule.threshold} 枚图章,当前只有 ${collectionCount} 枚` }, + }); + return; + } + + const redemption = await prisma.$transaction(async (tx) => { + const record = await tx.redemption.create({ + data: { userId: req.userId!, ruleId: rule.id, stampCount: collectionCount }, + }); + await tx.collection.deleteMany({ where: { userId: req.userId! } }); + return record; + }); + + res.json({ + success: true, + data: { + id: redemption.id, + ruleName: rule.name, + stampCount: redemption.stampCount, + redeemedAt: redemption.redeemedAt.toISOString(), + }, + }); +}); + +router.get("/history", requireAuth, async (req, res) => { + const records = await prisma.redemption.findMany({ + where: { userId: req.userId! }, + include: { rule: { select: { name: true } } }, + orderBy: { redeemedAt: "desc" }, + }); + + const data = records.map((r) => ({ + id: r.id, + ruleName: r.rule.name, + stampCount: r.stampCount, + redeemedAt: r.redeemedAt.toISOString(), + })); + + res.json({ success: true, data }); +}); + +export default router; diff --git a/packages/server/src/routes/stamps.ts b/packages/server/src/routes/stamps.ts new file mode 100644 index 0000000..b65cd4d --- /dev/null +++ b/packages/server/src/routes/stamps.ts @@ -0,0 +1,85 @@ +import { Router } from "express"; +import { prisma } from "@stamp/shared"; +import { optionalAuth, requireAuth } from "../middleware/auth.js"; + +const router = Router(); + +router.get("/", optionalAuth, async (req, res) => { + const stamps = await prisma.stamp.findMany({ + where: { enabled: true }, + orderBy: { sortOrder: "asc" }, + }); + + let collections: Set = new Set(); + let collectionMap: Map = new Map(); + + if (req.userId) { + const userCollections = await prisma.collection.findMany({ + where: { userId: req.userId }, + select: { stampId: true, collectedAt: true }, + }); + userCollections.forEach((c) => { + collections.add(c.stampId); + collectionMap.set(c.stampId, c.collectedAt); + }); + } + + const data = stamps.map((s) => ({ + id: s.id, + name: s.name, + note: s.note, + imageColor: s.imageColor, + imageGrey: s.imageGrey, + sortOrder: s.sortOrder, + collected: collections.has(s.id), + collectedAt: collectionMap.get(s.id)?.toISOString() ?? null, + })); + + res.json({ success: true, data }); +}); + +router.get("/:id", async (req, res) => { + const stamp = await prisma.stamp.findUnique({ where: { id: req.params.id } }); + if (!stamp) { + res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "图章不存在" } }); + return; + } + res.json({ + success: true, + data: { + id: stamp.id, + name: stamp.name, + note: stamp.note, + imageColor: stamp.imageColor, + imageGrey: stamp.imageGrey, + sortOrder: stamp.sortOrder, + }, + }); +}); + +router.post("/:id/collect", requireAuth, async (req, res) => { + const stamp = await prisma.stamp.findUnique({ where: { id: req.params.id, enabled: true } }); + if (!stamp) { + res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "图章不存在" } }); + return; + } + + const existing = await prisma.collection.findUnique({ + where: { userId_stampId: { userId: req.userId!, stampId: stamp.id } }, + }); + if (existing) { + res.status(409).json({ success: false, error: { code: "ALREADY_COLLECTED", message: "你已经收集了这枚图章" } }); + return; + } + + const collection = await prisma.collection.create({ + data: { userId: req.userId!, stampId: stamp.id }, + }); + + res.json({ + success: true, + data: { id: collection.id, stampId: collection.stampId, collectedAt: collection.collectedAt.toISOString() }, + }); +}); + +export default router; diff --git a/packages/server/src/seed.ts b/packages/server/src/seed.ts new file mode 100644 index 0000000..90e49b1 --- /dev/null +++ b/packages/server/src/seed.ts @@ -0,0 +1,68 @@ +import { prisma } from "@stamp/shared"; + +async function seed() { + console.log("Seeding database..."); + + // Create sample stamps + const stamps = await Promise.all([ + prisma.stamp.create({ + data: { name: "古桥印记", note: "始建于明代的石拱桥", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 1 }, + }), + prisma.stamp.create({ + data: { name: "老街风韵", note: "百年历史的商业老街", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 2 }, + }), + prisma.stamp.create({ + data: { name: "园林雅趣", note: "江南古典园林", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 3 }, + }), + prisma.stamp.create({ + data: { name: "茶馆时光", note: "百年老茶馆", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 4 }, + }), + prisma.stamp.create({ + data: { name: "水乡晨曲", note: "清晨的水乡渔市", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 5 }, + }), + prisma.stamp.create({ + data: { name: "戏台余韵", note: "古戏台与昆曲", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 6 }, + }), + prisma.stamp.create({ + data: { name: "巷弄深处", note: "青石板铺就的幽深小巷", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 7 }, + }), + prisma.stamp.create({ + data: { name: "月下拱桥", note: "夜晚灯火映照的拱桥", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 8 }, + }), + prisma.stamp.create({ + data: { name: "匠心工坊", note: "传统手工艺作坊", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 9 }, + }), + ]); + + console.log(`Created ${stamps.length} stamps`); + + // Create redemption rules + const rules = await Promise.all([ + prisma.redemptionRule.create({ + data: { name: "纪念书签", description: "精美城市纪念书签一枚", threshold: 3, sortOrder: 1 }, + }), + prisma.redemptionRule.create({ + data: { name: "手绘明信片套装", description: "一套 5 张手绘城市明信片", threshold: 5, sortOrder: 2 }, + }), + prisma.redemptionRule.create({ + data: { name: "限定徽章礼盒", description: "城市限定版金属徽章礼盒", threshold: 7, sortOrder: 3 }, + }), + prisma.redemptionRule.create({ + data: { name: "城市记忆礼包", description: "包含纪念 T 恤、帆布袋和城市画册", threshold: 9, sortOrder: 4 }, + }), + ]); + + console.log(`Created ${rules.length} redemption rules`); + + // Print stamp IDs for QR code testing + console.log("\nStamp IDs for testing:"); + stamps.forEach((s) => { + console.log(` ${s.name}: /collect/${s.id}`); + }); + + console.log("\nSeed complete!"); +} + +seed() + .catch(console.error) + .finally(() => prisma.$disconnect()); diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/server/uploads/.gitkeep b/packages/server/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..20ae241 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,16 @@ +{ + "name": "@stamp/shared", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "scripts": { + "build": "tsc", + "db:generate": "prisma generate --schema=../../prisma/schema.prisma", + "db:migrate": "prisma migrate dev --schema=../../prisma/schema.prisma", + "db:push": "prisma db push --schema=../../prisma/schema.prisma" + }, + "devDependencies": { + "typescript": "~5.9.3" + } +} diff --git a/packages/shared/src/db.ts b/packages/shared/src/db.ts new file mode 100644 index 0000000..901f3a0 --- /dev/null +++ b/packages/shared/src/db.ts @@ -0,0 +1,3 @@ +import { PrismaClient } from "@prisma/client"; + +export const prisma = new PrismaClient(); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..de2a9fd --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,2 @@ +export { prisma } from "./db.js"; +export type * from "./types.js"; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts new file mode 100644 index 0000000..b6f0489 --- /dev/null +++ b/packages/shared/src/types.ts @@ -0,0 +1,30 @@ +export type ApiResponse = { + success: boolean; + data?: T; + error?: { code: string; message: string }; +}; + +export type StampWithStatus = { + id: string; + name: string; + note: string | null; + imageColor: string; + imageGrey: string; + sortOrder: number; + collected: boolean; + collectedAt: string | null; +}; + +export type RedemptionRuleInfo = { + id: string; + name: string; + description: string | null; + threshold: number; +}; + +export type RedemptionRecord = { + id: string; + ruleName: string; + stampCount: number; + redeemedAt: string; +}; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/web/index.html b/packages/web/index.html new file mode 100644 index 0000000..2cf357f --- /dev/null +++ b/packages/web/index.html @@ -0,0 +1,18 @@ + + + + + + + + + + + + CityWalk 图章之旅 + + +
+ + + diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 0000000..4755a71 --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,26 @@ +{ + "name": "@stamp/web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "tailwindcss": "^4.2.2", + "typescript": "~5.9.3", + "vite": "^8.0.1" + }, + "dependencies": { + "@stamp/shared": "workspace:*", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-router-dom": "^7.13.2" + } +} diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx new file mode 100644 index 0000000..f6030c2 --- /dev/null +++ b/packages/web/src/App.tsx @@ -0,0 +1,48 @@ +import { Routes, Route, Navigate, useParams } from "react-router-dom"; +import { AuthProvider } from "./lib/auth"; +import LandingPage from "./pages/LandingPage"; +import AlbumPage from "./pages/AlbumPage"; +import AdminLogin from "./admin/AdminLogin"; +import AdminGuard from "./admin/AdminGuard"; +import AdminLayout from "./admin/AdminLayout"; +import StampList from "./admin/StampList"; +import StampForm from "./admin/StampForm"; +import StampQRCode from "./admin/StampQRCode"; +import RuleList from "./admin/RuleList"; +import RuleForm from "./admin/RuleForm"; +import RedemptionLog from "./admin/RedemptionLog"; + +function CollectRedirect() { + const { stampId } = useParams(); + return ; +} + +export default function App() { + return ( + + + {/* User-facing mobile H5 */} + } /> + } /> + } /> + + {/* Admin panel */} + } /> + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + } /> + + + ); +} diff --git a/packages/web/src/admin/AdminGuard.tsx b/packages/web/src/admin/AdminGuard.tsx new file mode 100644 index 0000000..159103e --- /dev/null +++ b/packages/web/src/admin/AdminGuard.tsx @@ -0,0 +1,7 @@ +import { Navigate, Outlet } from "react-router-dom"; + +export default function AdminGuard() { + const key = sessionStorage.getItem("admin_key"); + if (!key) return ; + return ; +} diff --git a/packages/web/src/admin/AdminLayout.tsx b/packages/web/src/admin/AdminLayout.tsx new file mode 100644 index 0000000..69a54d3 --- /dev/null +++ b/packages/web/src/admin/AdminLayout.tsx @@ -0,0 +1,54 @@ +import { NavLink, Outlet, useNavigate } from "react-router-dom"; + +const navItems = [ + { path: "/admin/stamps", label: "图章管理" }, + { path: "/admin/rules", label: "兑换规则" }, + { path: "/admin/redemptions", label: "兑换记录" }, +]; + +export default function AdminLayout() { + const navigate = useNavigate(); + + const handleLogout = () => { + sessionStorage.removeItem("admin_key"); + navigate("/admin"); + }; + + return ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+ +
+
+ ); +} diff --git a/packages/web/src/admin/AdminLogin.tsx b/packages/web/src/admin/AdminLogin.tsx new file mode 100644 index 0000000..1c0cb96 --- /dev/null +++ b/packages/web/src/admin/AdminLogin.tsx @@ -0,0 +1,56 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +export default function AdminLogin() { + const navigate = useNavigate(); + const [key, setKey] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleLogin = async () => { + setError(""); + setLoading(true); + try { + const res = await fetch("/api/admin/stats", { headers: { "X-Admin-Key": key } }); + const json = await res.json(); + if (json.success) { + sessionStorage.setItem("admin_key", key); + navigate("/admin/stamps"); + } else { + setError("密钥不正确"); + } + } catch { + setError("连接失败"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

管理后台

+
+ setKey(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleLogin()} + placeholder="输入管理密钥" + className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm + focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500" + /> + {error &&

{error}

} + +
+
+
+ ); +} diff --git a/packages/web/src/admin/RedemptionLog.tsx b/packages/web/src/admin/RedemptionLog.tsx new file mode 100644 index 0000000..d7c0b35 --- /dev/null +++ b/packages/web/src/admin/RedemptionLog.tsx @@ -0,0 +1,94 @@ +import { useState, useEffect } from "react"; +import { adminFetch } from "./adminApi"; + +type RedemptionRecord = { + id: string; + userId: string; + stampCount: number; + redeemedAt: string; + user: { username: string; phone: string }; + rule: { name: string }; +}; + +type Stats = { + userCount: number; + collectionCount: number; + redemptionCount: number; +}; + +export default function RedemptionLog() { + const [records, setRecords] = useState([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + Promise.all([ + adminFetch("/redemptions"), + adminFetch("/stats"), + ]) + .then(([recs, st]) => { + setRecords(recs); + setStats(st); + }) + .finally(() => setLoading(false)); + }, []); + + if (loading) return

加载中...

; + + return ( +
+

兑换记录

+ + {/* Stats */} + {stats && ( +
+ {[ + { label: "注册用户", value: stats.userCount }, + { label: "当前收集数", value: stats.collectionCount }, + { label: "累计兑换", value: stats.redemptionCount }, + ].map((s) => ( +
+

{s.value}

+

{s.label}

+
+ ))} +
+ )} + + {/* Records table */} +
+ + + + + + + + + + + + {records.map((r) => ( + + + + + + + + ))} + {records.length === 0 && ( + + + + )} + +
用户手机号兑换奖品图章数时间
{r.user.username}{r.user.phone}{r.rule.name}{r.stampCount} + {new Date(r.redeemedAt).toLocaleString("zh-CN")} +
+ 暂无兑换记录 +
+
+
+ ); +} diff --git a/packages/web/src/admin/RuleForm.tsx b/packages/web/src/admin/RuleForm.tsx new file mode 100644 index 0000000..5828d17 --- /dev/null +++ b/packages/web/src/admin/RuleForm.tsx @@ -0,0 +1,138 @@ +import { useState, useEffect } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { adminFetch } from "./adminApi"; + +type Rule = { + id: string; + name: string; + description: string | null; + threshold: number; + enabled: boolean; + sortOrder: number; +}; + +export default function RuleForm() { + const { id } = useParams(); + const navigate = useNavigate(); + const isEdit = !!id; + + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [threshold, setThreshold] = useState(1); + const [sortOrder, setSortOrder] = useState(0); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (!id) return; + adminFetch("/rules").then((rules) => { + const rule = rules.find((r) => r.id === id); + if (rule) { + setName(rule.name); + setDescription(rule.description || ""); + setThreshold(rule.threshold); + setSortOrder(rule.sortOrder); + } + }); + }, [id]); + + const handleSave = async () => { + setError(""); + if (!name.trim()) { + setError("请输入奖品名称"); + return; + } + setSaving(true); + try { + const body = { + name: name.trim(), + description: description.trim() || undefined, + threshold, + sortOrder, + }; + if (isEdit) { + await adminFetch(`/rules/${id}`, { method: "PUT", body: JSON.stringify(body) }); + } else { + await adminFetch("/rules", { method: "POST", body: JSON.stringify(body) }); + } + navigate("/admin/rules"); + } catch (e) { + setError(e instanceof Error ? e.message : "保存失败"); + } finally { + setSaving(false); + } + }; + + return ( +
+

+ {isEdit ? "编辑兑换规则" : "添加兑换规则"} +

+ +
+
+ + setName(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm + focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ +
+ +