init: init prok
This commit is contained in:
5
.env.example
Normal file
5
.env.example
Normal file
@@ -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"
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.db
|
||||
*.db-journal
|
||||
.env
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
85
CLAUDE.md
Normal file
85
CLAUDE.md
Normal file
@@ -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<T>`, `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
|
||||
```
|
||||
36
README.md
Normal file
36
README.md
Normal file
@@ -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)
|
||||
```
|
||||
30
package.json
Normal file
30
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
30
packages/server/package.json
Normal file
30
packages/server/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
36
packages/server/src/index.ts
Normal file
36
packages/server/src/index.ts
Normal file
@@ -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}`);
|
||||
});
|
||||
16
packages/server/src/lib/jwt.ts
Normal file
16
packages/server/src/lib/jwt.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
12
packages/server/src/lib/qrcode.ts
Normal file
12
packages/server/src/lib/qrcode.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import QRCode from "qrcode";
|
||||
|
||||
export async function generateQRCodeDataURL(
|
||||
url: string,
|
||||
options?: { width?: number }
|
||||
): Promise<string> {
|
||||
return QRCode.toDataURL(url, {
|
||||
width: options?.width ?? 300,
|
||||
margin: 2,
|
||||
color: { dark: "#1a1a2e", light: "#ffffff" },
|
||||
});
|
||||
}
|
||||
11
packages/server/src/middleware/admin.ts
Normal file
11
packages/server/src/middleware/admin.ts
Normal file
@@ -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();
|
||||
}
|
||||
36
packages/server/src/middleware/auth.ts
Normal file
36
packages/server/src/middleware/auth.ts
Normal file
@@ -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();
|
||||
}
|
||||
197
packages/server/src/routes/admin.ts
Normal file
197
packages/server/src/routes/admin.ts
Normal file
@@ -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;
|
||||
66
packages/server/src/routes/auth.ts
Normal file
66
packages/server/src/routes/auth.ts
Normal file
@@ -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;
|
||||
79
packages/server/src/routes/redemption.ts
Normal file
79
packages/server/src/routes/redemption.ts
Normal file
@@ -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;
|
||||
85
packages/server/src/routes/stamps.ts
Normal file
85
packages/server/src/routes/stamps.ts
Normal file
@@ -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<string> = new Set();
|
||||
let collectionMap: Map<string, Date> = 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;
|
||||
68
packages/server/src/seed.ts
Normal file
68
packages/server/src/seed.ts
Normal file
@@ -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());
|
||||
8
packages/server/tsconfig.json
Normal file
8
packages/server/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
0
packages/server/uploads/.gitkeep
Normal file
0
packages/server/uploads/.gitkeep
Normal file
16
packages/shared/package.json
Normal file
16
packages/shared/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
3
packages/shared/src/db.ts
Normal file
3
packages/shared/src/db.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
2
packages/shared/src/index.ts
Normal file
2
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { prisma } from "./db.js";
|
||||
export type * from "./types.js";
|
||||
30
packages/shared/src/types.ts
Normal file
30
packages/shared/src/types.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export type ApiResponse<T = unknown> = {
|
||||
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;
|
||||
};
|
||||
8
packages/shared/tsconfig.json
Normal file
8
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
18
packages/web/index.html
Normal file
18
packages/web/index.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#1a1a2e" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400;1,600&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet" />
|
||||
<title>CityWalk 图章之旅</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
26
packages/web/package.json
Normal file
26
packages/web/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
48
packages/web/src/App.tsx
Normal file
48
packages/web/src/App.tsx
Normal file
@@ -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 <Navigate to={`/?stamp=${stampId}`} replace />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
{/* User-facing mobile H5 */}
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/album" element={<AlbumPage />} />
|
||||
<Route path="/collect/:stampId" element={<CollectRedirect />} />
|
||||
|
||||
{/* Admin panel */}
|
||||
<Route path="/admin" element={<AdminLogin />} />
|
||||
<Route element={<AdminGuard />}>
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route path="/admin/stamps" element={<StampList />} />
|
||||
<Route path="/admin/stamps/new" element={<StampForm />} />
|
||||
<Route path="/admin/stamps/:id/edit" element={<StampForm />} />
|
||||
<Route path="/admin/stamps/:id/qrcode" element={<StampQRCode />} />
|
||||
<Route path="/admin/rules" element={<RuleList />} />
|
||||
<Route path="/admin/rules/new" element={<RuleForm />} />
|
||||
<Route path="/admin/rules/:id/edit" element={<RuleForm />} />
|
||||
<Route path="/admin/redemptions" element={<RedemptionLog />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
7
packages/web/src/admin/AdminGuard.tsx
Normal file
7
packages/web/src/admin/AdminGuard.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
|
||||
export default function AdminGuard() {
|
||||
const key = sessionStorage.getItem("admin_key");
|
||||
if (!key) return <Navigate to="/admin" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
54
packages/web/src/admin/AdminLayout.tsx
Normal file
54
packages/web/src/admin/AdminLayout.tsx
Normal file
@@ -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 (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-56 bg-white border-r border-gray-200 flex flex-col shrink-0">
|
||||
<div className="px-5 py-4 border-b border-gray-200">
|
||||
<h1 className="text-base font-semibold text-gray-800">图章管理后台</h1>
|
||||
</div>
|
||||
<nav className="flex-1 py-3">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`block px-5 py-2.5 text-sm transition-colors ${
|
||||
isActive
|
||||
? "text-blue-600 bg-blue-50 font-medium border-r-2 border-blue-600"
|
||||
: "text-gray-600 hover:bg-gray-50"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="px-5 py-3 border-t border-gray-200">
|
||||
<button onClick={handleLogout} className="text-sm text-gray-500 hover:text-gray-700">
|
||||
退出管理
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 p-6 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
packages/web/src/admin/AdminLogin.tsx
Normal file
56
packages/web/src/admin/AdminLogin.tsx
Normal file
@@ -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 (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="w-80 bg-white rounded-lg shadow-sm p-6 border border-gray-200">
|
||||
<h1 className="text-lg font-semibold text-gray-800 mb-4 text-center">管理后台</h1>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="password"
|
||||
value={key}
|
||||
onChange={(e) => 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 && <p className="text-sm text-red-500">{error}</p>}
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={loading || !key}
|
||||
className="w-full py-2 bg-blue-600 text-white text-sm rounded-md
|
||||
hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? "验证中..." : "登录"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
packages/web/src/admin/RedemptionLog.tsx
Normal file
94
packages/web/src/admin/RedemptionLog.tsx
Normal file
@@ -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<RedemptionRecord[]>([]);
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
adminFetch<RedemptionRecord[]>("/redemptions"),
|
||||
adminFetch<Stats>("/stats"),
|
||||
])
|
||||
.then(([recs, st]) => {
|
||||
setRecords(recs);
|
||||
setStats(st);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <p className="text-gray-500">加载中...</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">兑换记录</h2>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
{[
|
||||
{ label: "注册用户", value: stats.userCount },
|
||||
{ label: "当前收集数", value: stats.collectionCount },
|
||||
{ label: "累计兑换", value: stats.redemptionCount },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="bg-white rounded-lg border border-gray-200 p-4 text-center">
|
||||
<p className="text-2xl font-semibold text-gray-800">{s.value}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Records table */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">用户</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">手机号</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">兑换奖品</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600">图章数</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-600">时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((r) => (
|
||||
<tr key={r.id} className="border-b border-gray-100">
|
||||
<td className="px-4 py-3 text-gray-800">{r.user.username}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{r.user.phone}</td>
|
||||
<td className="px-4 py-3 text-gray-700">{r.rule.name}</td>
|
||||
<td className="px-4 py-3 text-center text-gray-500">{r.stampCount}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-500">
|
||||
{new Date(r.redeemedAt).toLocaleString("zh-CN")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{records.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-400">
|
||||
暂无兑换记录
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
packages/web/src/admin/RuleForm.tsx
Normal file
138
packages/web/src/admin/RuleForm.tsx
Normal file
@@ -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<Rule[]>("/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 (
|
||||
<div className="max-w-xl">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
{isEdit ? "编辑兑换规则" : "添加兑换规则"}
|
||||
</h2>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">奖品名称</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">奖品描述</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">所需图章数</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={threshold}
|
||||
onChange={(e) => setThreshold(Number(e.target.value))}
|
||||
className="w-24 px-3 py-2 border border-gray-300 rounded-md text-sm
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">排序</label>
|
||||
<input
|
||||
type="number"
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(Number(e.target.value))}
|
||||
className="w-24 px-3 py-2 border border-gray-300 rounded-md text-sm
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md
|
||||
hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "保存中..." : "保存"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate("/admin/rules")}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded-md hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
packages/web/src/admin/RuleList.tsx
Normal file
107
packages/web/src/admin/RuleList.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } 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 RuleList() {
|
||||
const [rules, setRules] = useState<Rule[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchRules = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await adminFetch<Rule[]>("/rules");
|
||||
setRules(data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchRules(); }, []);
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (!confirm(`确定删除规则「${name}」?`)) return;
|
||||
await adminFetch(`/rules/${id}`, { method: "DELETE" });
|
||||
fetchRules();
|
||||
};
|
||||
|
||||
const handleToggle = async (id: string, enabled: boolean) => {
|
||||
await adminFetch(`/rules/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enabled: !enabled }),
|
||||
});
|
||||
fetchRules();
|
||||
};
|
||||
|
||||
if (loading) return <p className="text-gray-500">加载中...</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800">兑换规则</h2>
|
||||
<Link
|
||||
to="/admin/rules/new"
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
|
||||
>
|
||||
添加规则
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">奖品名称</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">描述</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600">所需图章</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600">状态</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-600">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rules.map((rule) => (
|
||||
<tr key={rule.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-gray-800">{rule.name}</td>
|
||||
<td className="px-4 py-3 text-gray-500 max-w-[250px] truncate">{rule.description || "—"}</td>
|
||||
<td className="px-4 py-3 text-center font-medium text-gray-700">{rule.threshold}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button
|
||||
onClick={() => handleToggle(rule.id, rule.enabled)}
|
||||
className={`px-2 py-0.5 rounded text-xs ${
|
||||
rule.enabled ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{rule.enabled ? "启用" : "禁用"}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right space-x-2">
|
||||
<Link to={`/admin/rules/${rule.id}/edit`} className="text-blue-600 hover:underline">
|
||||
编辑
|
||||
</Link>
|
||||
<button onClick={() => handleDelete(rule.id, rule.name)} className="text-red-500 hover:underline">
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rules.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-400">
|
||||
暂无规则
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
181
packages/web/src/admin/StampForm.tsx
Normal file
181
packages/web/src/admin/StampForm.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { adminFetch } from "./adminApi";
|
||||
|
||||
type Stamp = {
|
||||
id: string;
|
||||
name: string;
|
||||
note: string | null;
|
||||
imageColor: string;
|
||||
imageGrey: string;
|
||||
sortOrder: number;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export default function StampForm() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const isEdit = !!id;
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [sortOrder, setSortOrder] = useState(0);
|
||||
const [imageColor, setImageColor] = useState("");
|
||||
const [imageGrey, setImageGrey] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
adminFetch<Stamp[]>("/stamps").then((stamps) => {
|
||||
const stamp = stamps.find((s) => s.id === id);
|
||||
if (stamp) {
|
||||
setName(stamp.name);
|
||||
setNote(stamp.note || "");
|
||||
setSortOrder(stamp.sortOrder);
|
||||
setImageColor(stamp.imageColor);
|
||||
setImageGrey(stamp.imageGrey);
|
||||
}
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
const handleUpload = async (file: File, field: "imageColor" | "imageGrey") => {
|
||||
if (!id) {
|
||||
setError("请先保存图章后再上传图片");
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append("image", file);
|
||||
formData.append("field", field);
|
||||
const data = await adminFetch<{ path: string }>(`/stamps/${id}/upload`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (field === "imageColor") setImageColor(data.path);
|
||||
else setImageGrey(data.path);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setError("");
|
||||
if (!name.trim()) {
|
||||
setError("请输入图章名称");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEdit) {
|
||||
await adminFetch(`/stamps/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ name: name.trim(), note: note.trim() || undefined, sortOrder }),
|
||||
});
|
||||
} else {
|
||||
const stamp = await adminFetch<Stamp>("/stamps", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: name.trim(), note: note.trim() || undefined, sortOrder }),
|
||||
});
|
||||
navigate(`/admin/stamps/${stamp.id}/edit`, { replace: true });
|
||||
return;
|
||||
}
|
||||
navigate("/admin/stamps");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
{isEdit ? "编辑图章" : "添加图章"}
|
||||
</h2>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">名称</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">备注</label>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={2}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">排序</label>
|
||||
<input
|
||||
type="number"
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(Number(e.target.value))}
|
||||
className="w-24 px-3 py-2 border border-gray-300 rounded-md text-sm
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Image uploads - only available after saving */}
|
||||
{isEdit && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">彩色图章</label>
|
||||
{imageColor && (
|
||||
<img src={imageColor} alt="彩色" className="w-20 h-20 object-contain bg-gray-50 rounded mb-2" />
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0], "imageColor")}
|
||||
className="text-xs text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">灰色图章</label>
|
||||
{imageGrey && (
|
||||
<img src={imageGrey} alt="灰色" className="w-20 h-20 object-contain bg-gray-50 rounded mb-2" />
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0], "imageGrey")}
|
||||
className="text-xs text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEdit && (
|
||||
<p className="text-xs text-gray-400">保存后可上传图章图片</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md
|
||||
hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "保存中..." : "保存"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate("/admin/stamps")}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded-md hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
packages/web/src/admin/StampList.tsx
Normal file
119
packages/web/src/admin/StampList.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { adminFetch } from "./adminApi";
|
||||
|
||||
type Stamp = {
|
||||
id: string;
|
||||
name: string;
|
||||
note: string | null;
|
||||
imageColor: string;
|
||||
imageGrey: string;
|
||||
sortOrder: number;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export default function StampList() {
|
||||
const [stamps, setStamps] = useState<Stamp[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchStamps = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await adminFetch<Stamp[]>("/stamps");
|
||||
setStamps(data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchStamps(); }, []);
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (!confirm(`确定删除图章「${name}」?`)) return;
|
||||
await adminFetch(`/stamps/${id}`, { method: "DELETE" });
|
||||
fetchStamps();
|
||||
};
|
||||
|
||||
const handleToggle = async (id: string, enabled: boolean) => {
|
||||
await adminFetch(`/stamps/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enabled: !enabled }),
|
||||
});
|
||||
fetchStamps();
|
||||
};
|
||||
|
||||
if (loading) return <p className="text-gray-500">加载中...</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800">图章管理</h2>
|
||||
<Link
|
||||
to="/admin/stamps/new"
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
|
||||
>
|
||||
添加图章
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">图章</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">名称</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">备注</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600">排序</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600">状态</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-600">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stamps.map((stamp) => (
|
||||
<tr key={stamp.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="w-10 h-10 rounded bg-gray-100 overflow-hidden">
|
||||
{stamp.imageColor && (
|
||||
<img src={stamp.imageColor} alt="" className="w-full h-full object-contain" />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-800">{stamp.name}</td>
|
||||
<td className="px-4 py-3 text-gray-500 max-w-[200px] truncate">{stamp.note || "—"}</td>
|
||||
<td className="px-4 py-3 text-center text-gray-500">{stamp.sortOrder}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button
|
||||
onClick={() => handleToggle(stamp.id, stamp.enabled)}
|
||||
className={`px-2 py-0.5 rounded text-xs ${
|
||||
stamp.enabled ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{stamp.enabled ? "启用" : "禁用"}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right space-x-2">
|
||||
<Link to={`/admin/stamps/${stamp.id}/edit`} className="text-blue-600 hover:underline">
|
||||
编辑
|
||||
</Link>
|
||||
<Link to={`/admin/stamps/${stamp.id}/qrcode`} className="text-blue-600 hover:underline">
|
||||
二维码
|
||||
</Link>
|
||||
<button onClick={() => handleDelete(stamp.id, stamp.name)} className="text-red-500 hover:underline">
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{stamps.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
|
||||
暂无图章,点击右上角添加
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
packages/web/src/admin/StampQRCode.tsx
Normal file
96
packages/web/src/admin/StampQRCode.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { adminFetch } from "./adminApi";
|
||||
|
||||
type QRData = {
|
||||
qrDataUrl: string;
|
||||
collectUrl: string;
|
||||
stampName: string;
|
||||
};
|
||||
|
||||
export default function StampQRCode() {
|
||||
const { id } = useParams();
|
||||
const [data, setData] = useState<QRData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
adminFetch<QRData>(`/stamps/${id}/qrcode`)
|
||||
.then(setData)
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
// Render composite image (QR code + URL text) to canvas
|
||||
useEffect(() => {
|
||||
if (!data || !canvasRef.current) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const padding = 20;
|
||||
const textHeight = 40;
|
||||
canvas.width = img.width + padding * 2;
|
||||
canvas.height = img.height + padding * 2 + textHeight;
|
||||
|
||||
// White background
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// QR code
|
||||
ctx.drawImage(img, padding, padding);
|
||||
|
||||
// URL text below
|
||||
ctx.fillStyle = "#666666";
|
||||
ctx.font = "12px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(data.collectUrl, canvas.width / 2, img.height + padding * 2 + 12);
|
||||
};
|
||||
img.src = data.qrDataUrl;
|
||||
}, [data]);
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!canvasRef.current || !data) return;
|
||||
const link = document.createElement("a");
|
||||
link.download = `${data.stampName}-二维码.png`;
|
||||
link.href = canvasRef.current.toDataURL("image/png");
|
||||
link.click();
|
||||
};
|
||||
|
||||
if (loading) return <p className="text-gray-500">加载中...</p>;
|
||||
if (!data) return <p className="text-gray-500">图章不存在</p>;
|
||||
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Link to="/admin/stamps" className="text-gray-400 hover:text-gray-600">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</Link>
|
||||
<h2 className="text-lg font-semibold text-gray-800">{data.stampName} — 二维码</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 text-center space-y-4">
|
||||
{/* QR code display */}
|
||||
<div className="inline-block">
|
||||
<img src={data.qrDataUrl} alt="二维码" className="w-64 h-64" />
|
||||
</div>
|
||||
|
||||
{/* URL display */}
|
||||
<p className="text-xs text-gray-500 break-all select-all">{data.collectUrl}</p>
|
||||
|
||||
{/* Hidden canvas for composite download */}
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
|
||||
>
|
||||
下载二维码(含链接)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
packages/web/src/admin/adminApi.ts
Normal file
21
packages/web/src/admin/adminApi.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
type ApiResponse<T> = {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: { code: string; message: string };
|
||||
};
|
||||
|
||||
export async function adminFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const key = sessionStorage.getItem("admin_key");
|
||||
if (!key) throw new Error("未登录");
|
||||
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("X-Admin-Key", key);
|
||||
if (!headers.has("Content-Type") && !(options.body instanceof FormData)) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/admin${path}`, { ...options, headers });
|
||||
const json: ApiResponse<T> = await res.json();
|
||||
if (!json.success) throw new Error(json.error?.message || "请求失败");
|
||||
return json.data as T;
|
||||
}
|
||||
25
packages/web/src/components/FloatingButton.tsx
Normal file
25
packages/web/src/components/FloatingButton.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function FloatingButton() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 left-0 right-0 z-50 flex justify-center safe-bottom">
|
||||
<button
|
||||
onClick={() => navigate("/album")}
|
||||
className="animate-pulse-glow active:scale-95 transition-transform"
|
||||
style={{
|
||||
padding: "14px 44px",
|
||||
borderRadius: "100px",
|
||||
background: "linear-gradient(135deg, var(--terracotta) 0%, #d4623f 100%)",
|
||||
color: "var(--text-inverted)",
|
||||
fontSize: "15px",
|
||||
fontWeight: 500,
|
||||
letterSpacing: "0.15em",
|
||||
}}
|
||||
>
|
||||
进入图章集册
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
packages/web/src/components/RedeemModal.tsx
Normal file
94
packages/web/src/components/RedeemModal.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useState } from "react";
|
||||
import type { RedemptionRuleInfo } from "@stamp/shared";
|
||||
|
||||
type RedeemModalProps = {
|
||||
rules: RedemptionRuleInfo[];
|
||||
collectedCount: number;
|
||||
onRedeem: (ruleId: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }: RedeemModalProps) {
|
||||
const [redeeming, setRedeeming] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleRedeem = async (ruleId: string) => {
|
||||
if (!confirm("兑换后所有已收集的图章将被清空,确定兑换吗?")) return;
|
||||
setRedeeming(ruleId);
|
||||
setError("");
|
||||
try {
|
||||
await onRedeem(ruleId);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "兑换失败");
|
||||
} finally {
|
||||
setRedeeming(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center animate-overlay-fade"
|
||||
style={{ backgroundColor: "var(--overlay)" }}
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div className="w-full max-w-lg bg-[var(--bg-cream)] rounded-t-2xl p-6 pb-10 animate-slide-up safe-bottom">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">兑换奖品</h3>
|
||||
<button onClick={onClose} className="text-[var(--text-muted)] p-1">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-4">
|
||||
当前已收集 <span className="font-semibold text-[var(--jade)]">{collectedCount}</span> 枚图章
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-[var(--terracotta)] mb-3">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{rules.map((rule) => {
|
||||
const canRedeem = collectedCount >= rule.threshold;
|
||||
return (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="flex items-center justify-between p-4 rounded-xl border"
|
||||
style={{
|
||||
borderColor: canRedeem ? "var(--jade)" : "var(--border-muted)",
|
||||
backgroundColor: canRedeem ? "rgba(45, 106, 79, 0.05)" : "white",
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 min-w-0 mr-3">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||
{rule.name}
|
||||
</p>
|
||||
{rule.description && (
|
||||
<p className="text-xs text-[var(--text-muted)] mt-0.5 truncate">{rule.description}</p>
|
||||
)}
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1">
|
||||
需要 {rule.threshold} 枚图章
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRedeem(rule.id)}
|
||||
disabled={!canRedeem || !!redeeming}
|
||||
className="shrink-0 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{
|
||||
backgroundColor: canRedeem ? "var(--jade)" : "var(--border-muted)",
|
||||
color: canRedeem ? "white" : "var(--text-muted)",
|
||||
opacity: redeeming === rule.id ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{redeeming === rule.id ? "兑换中..." : "兑换"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
packages/web/src/components/RegisterModal.tsx
Normal file
131
packages/web/src/components/RegisterModal.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "../lib/auth";
|
||||
|
||||
type RegisterModalProps = {
|
||||
onSuccess: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const phoneRegex = /^1[3-9]\d{9}$/;
|
||||
|
||||
export default function RegisterModal({ onSuccess, onClose }: RegisterModalProps) {
|
||||
const { register, login } = useAuth();
|
||||
const [mode, setMode] = useState<"register" | "login">("register");
|
||||
const [username, setUsername] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError("");
|
||||
|
||||
if (!phoneRegex.test(phone)) {
|
||||
setError("请输入正确的手机号");
|
||||
return;
|
||||
}
|
||||
if (mode === "register" && !username.trim()) {
|
||||
setError("请输入用户名");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (mode === "register") {
|
||||
await register(username.trim(), phone);
|
||||
} else {
|
||||
await login(phone);
|
||||
}
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "操作失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center animate-overlay-fade"
|
||||
style={{ backgroundColor: "var(--overlay)" }}
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div className="w-full max-w-lg bg-[var(--bg-cream)] rounded-t-2xl p-6 pb-10 animate-slide-up safe-bottom">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{mode === "register" ? "注册账号" : "登录"}
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-[var(--text-muted)] p-1">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{mode === "register" && (
|
||||
<div>
|
||||
<label className="text-sm text-[var(--text-secondary)] mb-1.5 block">用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="请输入用户名"
|
||||
maxLength={20}
|
||||
className="w-full px-4 py-3 rounded-xl border border-[var(--border-default)]
|
||||
bg-white text-[var(--text-primary)] text-sm
|
||||
focus:outline-none focus:border-[var(--gold)]
|
||||
placeholder:text-[var(--text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-[var(--text-secondary)] mb-1.5 block">手机号</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, "").slice(0, 11))}
|
||||
placeholder="请输入手机号"
|
||||
className="w-full px-4 py-3 rounded-xl border border-[var(--border-default)]
|
||||
bg-white text-[var(--text-primary)] text-sm
|
||||
focus:outline-none focus:border-[var(--gold)]
|
||||
placeholder:text-[var(--text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-[var(--terracotta)]">{error}</p>}
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
className="w-full py-3 rounded-xl text-sm font-medium text-white transition-opacity"
|
||||
style={{
|
||||
backgroundColor: "var(--terracotta)",
|
||||
opacity: submitting ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{submitting ? "请稍候..." : mode === "register" ? "注册" : "登录"}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-xs text-[var(--text-muted)]">
|
||||
{mode === "register" ? (
|
||||
<>
|
||||
已有账号?
|
||||
<button onClick={() => setMode("login")} className="text-[var(--gold)] ml-1">
|
||||
去登录
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
没有账号?
|
||||
<button onClick={() => setMode("register")} className="text-[var(--gold)] ml-1">
|
||||
去注册
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
packages/web/src/components/StampCard.tsx
Normal file
68
packages/web/src/components/StampCard.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
type StampCardProps = {
|
||||
name: string;
|
||||
imageColor: string;
|
||||
imageGrey: string;
|
||||
collected: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export default function StampCard({ name, imageColor, imageGrey, collected, onClick }: StampCardProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-xl transition-transform active:scale-95"
|
||||
>
|
||||
{/* Stamp image with perforated border effect */}
|
||||
<div
|
||||
className="relative w-full aspect-square rounded-lg overflow-hidden
|
||||
shadow-[var(--shadow-sm)] border border-[var(--border-muted)]"
|
||||
style={{
|
||||
background: collected
|
||||
? "linear-gradient(135deg, #fdf6ee 0%, #f8eed8 100%)"
|
||||
: "linear-gradient(135deg, #f0f0f0 0%, #e8e8e8 100%)",
|
||||
}}
|
||||
>
|
||||
{/* Perforated edge effect */}
|
||||
<div className="absolute inset-0 stamp-border pointer-events-none z-10" />
|
||||
|
||||
<img
|
||||
src={collected ? imageColor : imageGrey}
|
||||
alt={name}
|
||||
className="w-full h-full object-contain p-3"
|
||||
style={collected ? {} : { filter: "grayscale(1) opacity(0.4)" }}
|
||||
onError={(e) => {
|
||||
// Fallback for missing images: show placeholder
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = "none";
|
||||
target.parentElement!.innerHTML += `
|
||||
<div class="w-full h-full flex items-center justify-center text-3xl"
|
||||
style="color: ${collected ? "var(--gold)" : "var(--text-muted)"}; opacity: ${collected ? 1 : 0.3}">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<circle cx="12" cy="12" r="4"/>
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Collected badge */}
|
||||
{collected && (
|
||||
<div className="absolute top-1 right-1 w-5 h-5 rounded-full bg-[var(--jade)] flex items-center justify-center">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stamp name */}
|
||||
<span
|
||||
className="text-xs font-medium truncate w-full text-center"
|
||||
style={{ color: collected ? "var(--text-primary)" : "var(--text-muted)" }}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
24
packages/web/src/components/StampGrid.tsx
Normal file
24
packages/web/src/components/StampGrid.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { StampWithStatus } from "@stamp/shared";
|
||||
import StampCard from "./StampCard";
|
||||
|
||||
type StampGridProps = {
|
||||
stamps: StampWithStatus[];
|
||||
onStampClick?: (stamp: StampWithStatus) => void;
|
||||
};
|
||||
|
||||
export default function StampGrid({ stamps, onStampClick }: StampGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-3 stagger-children">
|
||||
{stamps.map((stamp) => (
|
||||
<StampCard
|
||||
key={stamp.id}
|
||||
name={stamp.name}
|
||||
imageColor={stamp.imageColor}
|
||||
imageGrey={stamp.imageGrey}
|
||||
collected={stamp.collected}
|
||||
onClick={() => onStampClick?.(stamp)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
packages/web/src/components/StampPopup.tsx
Normal file
94
packages/web/src/components/StampPopup.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
type StampPopupProps = {
|
||||
name: string;
|
||||
imageColor: string;
|
||||
note?: string | null;
|
||||
status: "preview" | "collected" | "already";
|
||||
onCollect?: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function StampPopup({ name, imageColor, note, status, onCollect, onClose }: StampPopupProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center animate-overlay-fade"
|
||||
style={{ backgroundColor: "var(--overlay)" }}
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div className="w-72 bg-[var(--bg-cream)] rounded-2xl p-6 text-center shadow-[var(--shadow-lg)]">
|
||||
{/* Stamp image */}
|
||||
<div className="w-40 h-40 mx-auto mb-4">
|
||||
<div className="w-full h-full rounded-xl overflow-hidden animate-stamp-press"
|
||||
style={{ background: "linear-gradient(135deg, #fdf6ee 0%, #f8eed8 100%)" }}
|
||||
>
|
||||
<img
|
||||
src={imageColor}
|
||||
alt={name}
|
||||
className="w-full h-full object-contain p-4"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stamp name */}
|
||||
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-1">{name}</h3>
|
||||
{note && <p className="text-xs text-[var(--text-muted)] mb-4">{note}</p>}
|
||||
|
||||
{/* Status message & action */}
|
||||
{status === "preview" && (
|
||||
<button
|
||||
onClick={onCollect}
|
||||
className="w-full py-3 rounded-xl text-sm font-medium text-white mt-2"
|
||||
style={{ backgroundColor: "var(--terracotta)" }}
|
||||
>
|
||||
立即获取
|
||||
</button>
|
||||
)}
|
||||
|
||||
{status === "collected" && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-center gap-1.5 text-[var(--jade)] mb-3">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">收集成功!</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate("/album")}
|
||||
className="w-full py-3 rounded-xl text-sm font-medium text-white"
|
||||
style={{ backgroundColor: "var(--jade)" }}
|
||||
>
|
||||
查看图章册
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "already" && (
|
||||
<>
|
||||
<p className="text-sm text-[var(--text-muted)] mt-2">你已经拥有这枚图章了</p>
|
||||
<button
|
||||
onClick={() => navigate("/album")}
|
||||
className="w-full py-3 rounded-xl text-sm font-medium text-white mt-3"
|
||||
style={{ backgroundColor: "var(--jade)" }}
|
||||
>
|
||||
查看图章册
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="mt-3 text-xs text-[var(--text-muted)] underline underline-offset-2"
|
||||
>
|
||||
{status === "preview" ? "关闭" : "继续浏览"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
196
packages/web/src/index.css
Normal file
196
packages/web/src/index.css
Normal file
@@ -0,0 +1,196 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ===== Base Styles (in @layer base so utilities can override) ===== */
|
||||
@layer base {
|
||||
:root {
|
||||
--bg-cream: #f5f0e8;
|
||||
--bg-paper: #ece5d8;
|
||||
--bg-dark: #1a1a2e;
|
||||
--bg-dark-deep: #10101e;
|
||||
--text-primary: #1a1a2e;
|
||||
--text-secondary: #4a4553;
|
||||
--text-muted: #8a8494;
|
||||
--text-inverted: #f5f0e8;
|
||||
--gold: #d4a574;
|
||||
--gold-light: #e8c9a0;
|
||||
--gold-hover: #c49464;
|
||||
--terracotta: #c75b39;
|
||||
--terracotta-hover: #b04d2f;
|
||||
--jade: #2d6a4f;
|
||||
--jade-light: #40916c;
|
||||
--border-default: #d4cfc5;
|
||||
--border-muted: #e8e3d9;
|
||||
--shadow-sm: 0 1px 3px rgba(26, 26, 46, 0.08);
|
||||
--shadow-md: 0 4px 12px rgba(26, 26, 46, 0.12);
|
||||
--shadow-lg: 0 8px 24px rgba(26, 26, 46, 0.16);
|
||||
--overlay: rgba(26, 26, 46, 0.5);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Noto Sans SC", sans-serif;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-dark);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
font-family: "Playfair Display", serif;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Keyframes ===== */
|
||||
@keyframes stamp-press {
|
||||
0% { transform: scale(1.3) rotate(-5deg); opacity: 0; }
|
||||
40% { transform: scale(0.95) rotate(2deg); opacity: 1; }
|
||||
60% { transform: scale(1.05) rotate(-1deg); }
|
||||
80% { transform: scale(0.98) rotate(0.5deg); }
|
||||
100% { transform: scale(1) rotate(0deg); }
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from { opacity: 0; transform: translateY(24px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes pulse-soft {
|
||||
0%, 100% { transform: scale(1); box-shadow: 0 4px 12px rgba(199, 91, 57, 0.3); }
|
||||
50% { transform: scale(1.03); box-shadow: 0 6px 20px rgba(199, 91, 57, 0.45); }
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { box-shadow: 0 0 20px rgba(212, 165, 116, 0.15), 0 8px 32px rgba(199, 91, 57, 0.3); }
|
||||
50% { box-shadow: 0 0 40px rgba(212, 165, 116, 0.25), 0 8px 40px rgba(199, 91, 57, 0.45); }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes overlay-fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
|
||||
@keyframes rotate-slow {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
from { opacity: 0; transform: scale(0.85); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ===== Component Classes (in @layer components) ===== */
|
||||
@layer components {
|
||||
.safe-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
.animate-stamp-press { animation: stamp-press 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
|
||||
.animate-fade-in-up { opacity: 0; animation: fade-in-up 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; }
|
||||
.animate-fade-in { opacity: 0; animation: fade-in 0.8s ease-out both; }
|
||||
.animate-pulse-soft { animation: pulse-soft 2s ease-in-out infinite; }
|
||||
.animate-pulse-glow { animation: pulse-glow 3s ease-in-out infinite; }
|
||||
.animate-slide-up { animation: slide-up 0.35s cubic-bezier(0.22, 1, 0.36, 1) both; }
|
||||
.animate-overlay-fade { animation: overlay-fade 0.25s ease-out both; }
|
||||
.animate-float { animation: float 4s ease-in-out infinite; }
|
||||
.animate-scale-in { opacity: 0; animation: scale-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) both; }
|
||||
|
||||
/* Stagger children */
|
||||
.stagger-children > * {
|
||||
opacity: 0;
|
||||
animation: fade-in-up 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
.stagger-children > *:nth-child(1) { animation-delay: 0.1s; }
|
||||
.stagger-children > *:nth-child(2) { animation-delay: 0.2s; }
|
||||
.stagger-children > *:nth-child(3) { animation-delay: 0.3s; }
|
||||
.stagger-children > *:nth-child(4) { animation-delay: 0.4s; }
|
||||
.stagger-children > *:nth-child(5) { animation-delay: 0.5s; }
|
||||
.stagger-children > *:nth-child(6) { animation-delay: 0.6s; }
|
||||
.stagger-children > *:nth-child(7) { animation-delay: 0.7s; }
|
||||
.stagger-children > *:nth-child(8) { animation-delay: 0.8s; }
|
||||
.stagger-children > *:nth-child(9) { animation-delay: 0.9s; }
|
||||
|
||||
/* Stamp Card Effects */
|
||||
.stamp-border {
|
||||
background-image: radial-gradient(circle, var(--bg-cream) 4px, transparent 4px);
|
||||
background-size: 12px 12px;
|
||||
background-position: -6px -6px;
|
||||
}
|
||||
|
||||
/* Grain / Noise Texture */
|
||||
.grain-overlay::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
opacity: 0.035;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
background-size: 256px 256px;
|
||||
}
|
||||
|
||||
/* Paper Texture */
|
||||
.paper-texture {
|
||||
position: relative;
|
||||
background-color: var(--bg-cream);
|
||||
background-image:
|
||||
radial-gradient(ellipse at 20% 0%, rgba(212, 165, 116, 0.08) 0%, transparent 60%),
|
||||
radial-gradient(ellipse at 80% 100%, rgba(199, 91, 57, 0.05) 0%, transparent 60%);
|
||||
}
|
||||
|
||||
/* Decorative Elements */
|
||||
.ornament-line {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent 0%, var(--gold) 20%, var(--gold) 80%, transparent 100%);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.stamp-seal {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stamp-seal::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -8px;
|
||||
border: 2px dashed;
|
||||
border-color: rgba(212, 165, 116, 0.25);
|
||||
border-radius: 50%;
|
||||
animation: rotate-slow 30s linear infinite;
|
||||
}
|
||||
|
||||
.stamp-seal::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -20px;
|
||||
border: 1px solid;
|
||||
border-color: rgba(212, 165, 116, 0.1);
|
||||
border-radius: 50%;
|
||||
animation: rotate-slow 45s linear infinite reverse;
|
||||
}
|
||||
}
|
||||
41
packages/web/src/lib/api.ts
Normal file
41
packages/web/src/lib/api.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export const API_BASE = "/api";
|
||||
|
||||
type ApiResponse<T> = {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: { code: string; message: string };
|
||||
};
|
||||
|
||||
let token: string | null = localStorage.getItem("stamp_token");
|
||||
|
||||
export function setToken(t: string) {
|
||||
token = t;
|
||||
localStorage.setItem("stamp_token", t);
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
token = null;
|
||||
localStorage.removeItem("stamp_token");
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
if (!headers.has("Content-Type") && !(options.body instanceof FormData)) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||
const json: ApiResponse<T> = await res.json();
|
||||
|
||||
if (!json.success) {
|
||||
throw new Error(json.error?.message || "请求失败");
|
||||
}
|
||||
return json.data as T;
|
||||
}
|
||||
66
packages/web/src/lib/auth.tsx
Normal file
66
packages/web/src/lib/auth.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import { apiFetch, setToken, clearToken, getToken } from "./api";
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
phone: string;
|
||||
};
|
||||
|
||||
type AuthContextType = {
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
register: (username: string, phone: string) => Promise<void>;
|
||||
login: (phone: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(!!getToken());
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) return;
|
||||
apiFetch<User>("/auth/me")
|
||||
.then(setUser)
|
||||
.catch(() => clearToken())
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (username: string, phone: string) => {
|
||||
const data = await apiFetch<{ user: User; token: string }>("/auth/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, phone }),
|
||||
});
|
||||
setToken(data.token);
|
||||
setUser(data.user);
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (phone: string) => {
|
||||
const data = await apiFetch<{ user: User; token: string }>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ phone }),
|
||||
});
|
||||
setToken(data.token);
|
||||
setUser(data.user);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearToken();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, isLoading, register, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
||||
return ctx;
|
||||
}
|
||||
13
packages/web/src/main.tsx
Normal file
13
packages/web/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
);
|
||||
164
packages/web/src/pages/AlbumPage.tsx
Normal file
164
packages/web/src/pages/AlbumPage.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { StampWithStatus, RedemptionRuleInfo, RedemptionRecord } from "@stamp/shared";
|
||||
import { apiFetch } from "../lib/api";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import StampGrid from "../components/StampGrid";
|
||||
import RedeemModal from "../components/RedeemModal";
|
||||
import RegisterModal from "../components/RegisterModal";
|
||||
|
||||
export default function AlbumPage() {
|
||||
const navigate = useNavigate();
|
||||
const { user, isLoading: authLoading } = useAuth();
|
||||
const [stamps, setStamps] = useState<StampWithStatus[]>([]);
|
||||
const [rules, setRules] = useState<RedemptionRuleInfo[]>([]);
|
||||
const [history, setHistory] = useState<RedemptionRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showRedeem, setShowRedeem] = useState(false);
|
||||
const [showRegister, setShowRegister] = useState(false);
|
||||
|
||||
const collectedCount = stamps.filter((s) => s.collected).length;
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [stampsData, rulesData] = await Promise.all([
|
||||
apiFetch<StampWithStatus[]>("/stamps"),
|
||||
apiFetch<RedemptionRuleInfo[]>("/redemption/rules"),
|
||||
]);
|
||||
setStamps(stampsData);
|
||||
setRules(rulesData);
|
||||
|
||||
if (user) {
|
||||
const historyData = await apiFetch<RedemptionRecord[]>("/redemption/history");
|
||||
setHistory(historyData);
|
||||
}
|
||||
} catch {
|
||||
// Stamps endpoint works without auth
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading) fetchData();
|
||||
}, [authLoading, user]);
|
||||
|
||||
const handleRedeem = async (ruleId: string) => {
|
||||
await apiFetch("/redemption/redeem", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ruleId }),
|
||||
});
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
const handleRedeemClick = () => {
|
||||
if (!user) {
|
||||
setShowRegister(true);
|
||||
return;
|
||||
}
|
||||
setShowRedeem(true);
|
||||
};
|
||||
|
||||
if (loading || authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--bg-cream)] flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-[var(--gold)] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--bg-cream)] paper-texture">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-40 bg-[var(--bg-cream)]/90 backdrop-blur-sm border-b border-[var(--border-muted)]">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<button onClick={() => navigate("/")} className="text-[var(--text-secondary)] p-1">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-base font-semibold text-[var(--text-primary)]">图章集册</h1>
|
||||
<div className="w-8" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="px-6 pt-5 pb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-[var(--text-secondary)]">收集进度</span>
|
||||
<span className="text-sm font-semibold text-[var(--gold)]">
|
||||
{collectedCount} / {stamps.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-[var(--border-muted)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-[var(--gold)] to-[var(--terracotta)] rounded-full transition-all duration-500"
|
||||
style={{ width: stamps.length ? `${(collectedCount / stamps.length) * 100}%` : "0%" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stamp Grid */}
|
||||
<div className="px-4 pb-6">
|
||||
<StampGrid stamps={stamps} />
|
||||
</div>
|
||||
|
||||
{/* Redeem Section */}
|
||||
{rules.length > 0 && (
|
||||
<div className="px-6 pb-6">
|
||||
<button
|
||||
onClick={handleRedeemClick}
|
||||
className="w-full py-3 rounded-xl text-sm font-medium transition-colors"
|
||||
style={{
|
||||
backgroundColor: collectedCount > 0 ? "var(--jade)" : "var(--border-muted)",
|
||||
color: collectedCount > 0 ? "white" : "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
兑换奖品 ({rules.filter((r) => collectedCount >= r.threshold).length} 个可兑换)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Redemption History */}
|
||||
{history.length > 0 && (
|
||||
<div className="px-6 pb-8">
|
||||
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3">兑换记录</h3>
|
||||
<div className="space-y-2">
|
||||
{history.map((r) => (
|
||||
<div key={r.id} className="flex items-center justify-between py-2 px-3 bg-white/60 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-primary)]">{r.ruleName}</p>
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
{new Date(r.redeemedAt).toLocaleDateString("zh-CN")}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--jade)]">已兑换</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
{showRedeem && (
|
||||
<RedeemModal
|
||||
rules={rules}
|
||||
collectedCount={collectedCount}
|
||||
onRedeem={handleRedeem}
|
||||
onClose={() => setShowRedeem(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showRegister && (
|
||||
<RegisterModal
|
||||
onSuccess={() => {
|
||||
setShowRegister(false);
|
||||
fetchData();
|
||||
}}
|
||||
onClose={() => setShowRegister(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
288
packages/web/src/pages/LandingPage.tsx
Normal file
288
packages/web/src/pages/LandingPage.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||
import { apiFetch } from "../lib/api";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import FloatingButton from "../components/FloatingButton";
|
||||
import StampPopup from "../components/StampPopup";
|
||||
import RegisterModal from "../components/RegisterModal";
|
||||
|
||||
const PENDING_STAMP_KEY = "stamp_pending_collect";
|
||||
|
||||
type StampDetail = {
|
||||
id: string;
|
||||
name: string;
|
||||
note: string | null;
|
||||
imageColor: string;
|
||||
imageGrey: string;
|
||||
};
|
||||
|
||||
type CollectState = "idle" | "loading" | "show_stamp" | "needs_register" | "collecting" | "collected" | "already_collected";
|
||||
|
||||
const STEPS = [
|
||||
{ num: "01", title: "前往点位", desc: "跟随路线,探索散落在城市中的文化地标" },
|
||||
{ num: "02", title: "扫码集章", desc: "发现点位专属二维码,扫描即刻收入囊中" },
|
||||
{ num: "03", title: "兑换好礼", desc: "集满图章,兑换城市限定纪念品" },
|
||||
];
|
||||
|
||||
export default function LandingPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { user, isLoading: authLoading } = useAuth();
|
||||
|
||||
const stampId = searchParams.get("stamp");
|
||||
|
||||
const [stamp, setStamp] = useState<StampDetail | null>(null);
|
||||
const [collectState, setCollectState] = useState<CollectState>("idle");
|
||||
|
||||
// Fetch stamp info when stampId is present
|
||||
useEffect(() => {
|
||||
if (!stampId || authLoading) return;
|
||||
setCollectState("loading");
|
||||
apiFetch<StampDetail>(`/stamps/${stampId}`)
|
||||
.then((data) => {
|
||||
setStamp(data);
|
||||
setCollectState("show_stamp");
|
||||
})
|
||||
.catch(() => {
|
||||
setCollectState("idle");
|
||||
});
|
||||
}, [stampId, authLoading]);
|
||||
|
||||
const doCollect = useCallback(async () => {
|
||||
if (!stampId) return;
|
||||
setCollectState("collecting");
|
||||
try {
|
||||
await apiFetch(`/stamps/${stampId}/collect`, { method: "POST" });
|
||||
setCollectState("collected");
|
||||
sessionStorage.removeItem(PENDING_STAMP_KEY);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "";
|
||||
if (msg.includes("已经收集")) {
|
||||
setCollectState("already_collected");
|
||||
} else {
|
||||
setCollectState("idle");
|
||||
}
|
||||
}
|
||||
}, [stampId]);
|
||||
|
||||
// Auto-collect if user just registered and has a pending stamp
|
||||
useEffect(() => {
|
||||
if (authLoading || !user || collectState !== "show_stamp" || !stampId) return;
|
||||
const pending = sessionStorage.getItem(PENDING_STAMP_KEY);
|
||||
if (pending === stampId) {
|
||||
doCollect();
|
||||
}
|
||||
}, [authLoading, user, collectState, stampId, doCollect]);
|
||||
|
||||
const handleCollect = () => {
|
||||
if (!user) {
|
||||
sessionStorage.setItem(PENDING_STAMP_KEY, stampId!);
|
||||
setCollectState("needs_register");
|
||||
return;
|
||||
}
|
||||
doCollect();
|
||||
};
|
||||
|
||||
const handleRegisterSuccess = () => {
|
||||
setCollectState("show_stamp");
|
||||
doCollect();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setCollectState("idle");
|
||||
setStamp(null);
|
||||
navigate("/", { replace: true });
|
||||
};
|
||||
|
||||
const showStampPopup = stamp && (collectState === "show_stamp" || collectState === "collecting");
|
||||
const showCollectedPopup = stamp && collectState === "collected";
|
||||
const showAlreadyPopup = stamp && collectState === "already_collected";
|
||||
const showRegister = collectState === "needs_register";
|
||||
|
||||
return (
|
||||
<div className="grain-overlay">
|
||||
{/* ═══════════ HERO ═══════════ */}
|
||||
<section className="relative min-h-svh flex flex-col items-center justify-center overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[var(--bg-dark-deep)]" />
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
radial-gradient(ellipse 60% 50% at 50% 40%, rgba(212, 165, 116, 0.08) 0%, transparent 100%),
|
||||
radial-gradient(circle at 15% 80%, rgba(199, 91, 57, 0.06) 0%, transparent 50%),
|
||||
radial-gradient(circle at 85% 20%, rgba(45, 106, 79, 0.04) 0%, transparent 40%)
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.03]"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(212, 165, 116, 0.5) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(212, 165, 116, 0.5) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: "60px 60px",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 text-center px-8 flex flex-col items-center">
|
||||
<div className="animate-fade-in mb-8" style={{ animationDelay: "0.2s" }}>
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<span className="block w-8 h-px bg-[var(--gold)]/40" />
|
||||
<span className="text-[var(--gold)] text-[11px] tracking-[0.4em] uppercase"
|
||||
style={{ fontFamily: "'Playfair Display', serif" }}>
|
||||
CityWalk
|
||||
</span>
|
||||
<span className="block w-8 h-px bg-[var(--gold)]/40" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="animate-fade-in-up text-[var(--text-inverted)] leading-none mb-6"
|
||||
style={{
|
||||
animationDelay: "0.4s",
|
||||
fontSize: "clamp(3rem, 12vw, 4.5rem)",
|
||||
fontFamily: "'Playfair Display', serif",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "-0.02em",
|
||||
}}>
|
||||
城市漫步
|
||||
</h1>
|
||||
|
||||
<p className="animate-fade-in-up text-[var(--gold-light)]/70 text-sm leading-relaxed max-w-[260px]"
|
||||
style={{ animationDelay: "0.6s", letterSpacing: "0.08em" }}>
|
||||
走过每一条街巷<br />收集属于你的城市记忆
|
||||
</p>
|
||||
|
||||
<div className="animate-scale-in mt-14" style={{ animationDelay: "0.9s" }}>
|
||||
<div className="stamp-seal w-[100px] h-[100px] animate-float">
|
||||
<div className="w-[100px] h-[100px] rounded-full flex items-center justify-center"
|
||||
style={{
|
||||
background: "radial-gradient(circle, rgba(212, 165, 116, 0.12) 0%, rgba(212, 165, 116, 0.02) 70%)",
|
||||
border: "1.5px solid rgba(212, 165, 116, 0.2)",
|
||||
}}>
|
||||
<div className="text-center">
|
||||
<div className="text-[var(--gold)] text-[10px] tracking-[0.2em] uppercase opacity-60">Stamp</div>
|
||||
<div className="text-[var(--gold)] text-2xl mt-0.5 opacity-80"
|
||||
style={{ fontFamily: "'Playfair Display', serif" }}>9</div>
|
||||
<div className="text-[var(--gold)] text-[9px] tracking-[0.15em] uppercase opacity-50">Collect</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="animate-fade-in mt-16" style={{ animationDelay: "1.4s" }}>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="text-[var(--gold)]/30 text-[10px] tracking-[0.3em] uppercase">探索</span>
|
||||
<div className="w-px h-8 bg-gradient-to-b from-[var(--gold)]/30 to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ═══════════ ABOUT ═══════════ */}
|
||||
<section className="relative bg-[var(--bg-dark)] py-20 px-6 overflow-hidden">
|
||||
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-[var(--gold)]/20 to-transparent" />
|
||||
<div className="max-w-sm mx-auto">
|
||||
<div className="flex items-center gap-3 mb-8 animate-fade-in-up">
|
||||
<span className="block w-6 h-px bg-[var(--gold)]/40" />
|
||||
<span className="text-[var(--gold)]/50 text-[10px] tracking-[0.3em] uppercase">About</span>
|
||||
</div>
|
||||
<h2 className="text-[var(--text-inverted)] text-2xl leading-snug mb-6 animate-fade-in-up"
|
||||
style={{ fontFamily: "'Playfair Display', serif", animationDelay: "0.1s" }}>
|
||||
一场属于你的<br /><span className="text-[var(--gold)]">城市寻宝之旅</span>
|
||||
</h2>
|
||||
<p className="text-[var(--text-inverted)]/50 text-sm leading-[1.9] animate-fade-in-up"
|
||||
style={{ animationDelay: "0.2s" }}>
|
||||
穿行于古桥与老街之间,在园林深处驻足片刻,于茶馆中听一段旧时光。每一个城市坐标都藏着一枚专属图章,等你亲手揭开。
|
||||
</p>
|
||||
<div className="ornament-line mt-10" />
|
||||
<div className="mt-10 grid grid-cols-3 gap-4 stagger-children">
|
||||
{[
|
||||
{ num: "9", label: "城市坐标" },
|
||||
{ num: "4", label: "限定好礼" },
|
||||
{ num: "∞", label: "重复挑战" },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="text-center">
|
||||
<div className="text-[var(--gold)] text-3xl mb-1.5"
|
||||
style={{ fontFamily: "'Playfair Display', serif" }}>{item.num}</div>
|
||||
<div className="text-[var(--text-inverted)]/35 text-[11px] tracking-wider">{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ═══════════ HOW IT WORKS ═══════════ */}
|
||||
<section className="relative paper-texture py-20 px-6 pb-32">
|
||||
<div className="relative z-10 max-w-sm mx-auto pt-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="block w-6 h-px bg-[var(--text-primary)]/20" />
|
||||
<span className="text-[var(--text-muted)] text-[10px] tracking-[0.3em] uppercase">How it works</span>
|
||||
</div>
|
||||
<h2 className="text-[var(--text-primary)] text-2xl leading-snug mb-12"
|
||||
style={{ fontFamily: "'Playfair Display', serif" }}>
|
||||
三步开启旅程
|
||||
</h2>
|
||||
<div className="space-y-0 stagger-children">
|
||||
{STEPS.map((step, i) => (
|
||||
<div key={step.num} className="relative flex gap-5">
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<div className="w-11 h-11 rounded-full border-2 flex items-center justify-center"
|
||||
style={{ borderColor: "var(--gold)", background: "rgba(212, 165, 116, 0.06)" }}>
|
||||
<span className="text-[var(--gold)] text-xs"
|
||||
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 600 }}>
|
||||
{step.num}
|
||||
</span>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && <div className="w-px flex-1 min-h-[40px] bg-[var(--gold)]/20" />}
|
||||
</div>
|
||||
<div className="pb-10 pt-1.5">
|
||||
<h3 className="text-base font-semibold text-[var(--text-primary)] mb-1">{step.title}</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)] leading-relaxed">{step.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<FloatingButton />
|
||||
|
||||
{/* ═══════════ Collection Overlays ═══════════ */}
|
||||
{showStampPopup && (
|
||||
<StampPopup
|
||||
name={stamp.name}
|
||||
imageColor={stamp.imageColor}
|
||||
note={stamp.note}
|
||||
status="preview"
|
||||
onCollect={handleCollect}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
{showCollectedPopup && (
|
||||
<StampPopup
|
||||
name={stamp.name}
|
||||
imageColor={stamp.imageColor}
|
||||
note={stamp.note}
|
||||
status="collected"
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
{showAlreadyPopup && (
|
||||
<StampPopup
|
||||
name={stamp.name}
|
||||
imageColor={stamp.imageColor}
|
||||
note={stamp.note}
|
||||
status="already"
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)}
|
||||
{showRegister && (
|
||||
<RegisterModal
|
||||
onSuccess={handleRegisterSuccess}
|
||||
onClose={() => setCollectState("show_stamp")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
packages/web/tsconfig.json
Normal file
11
packages/web/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"jsx": "react-jsx",
|
||||
"declaration": false,
|
||||
"declarationMap": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
13
packages/web/vite.config.ts
Normal file
13
packages/web/vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:3000",
|
||||
"/uploads": "http://localhost:3000",
|
||||
},
|
||||
},
|
||||
});
|
||||
2633
pnpm-lock.yaml
generated
Normal file
2633
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- "packages/*"
|
||||
69
prisma/migrations/20260416035619_init/migration.sql
Normal file
69
prisma/migrations/20260416035619_init/migration.sql
Normal file
@@ -0,0 +1,69 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"username" TEXT NOT NULL,
|
||||
"phone" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Stamp" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"note" TEXT,
|
||||
"imageColor" TEXT NOT NULL,
|
||||
"imageGrey" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Collection" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"stampId" TEXT NOT NULL,
|
||||
"collectedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "Collection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "Collection_stampId_fkey" FOREIGN KEY ("stampId") REFERENCES "Stamp" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RedemptionRule" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"threshold" INTEGER NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Redemption" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"ruleId" TEXT NOT NULL,
|
||||
"stampCount" INTEGER NOT NULL,
|
||||
"redeemedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "Redemption_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "Redemption_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "RedemptionRule" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Collection_userId_idx" ON "Collection"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Collection_userId_stampId_key" ON "Collection"("userId", "stampId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Redemption_userId_idx" ON "Redemption"("userId");
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
67
prisma/schema.prisma
Normal file
67
prisma/schema.prisma
Normal file
@@ -0,0 +1,67 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
username String @unique
|
||||
phone String @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
collections Collection[]
|
||||
redemptions Redemption[]
|
||||
}
|
||||
|
||||
model Stamp {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
note String?
|
||||
imageColor String
|
||||
imageGrey String
|
||||
sortOrder Int @default(0)
|
||||
enabled Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
collections Collection[]
|
||||
}
|
||||
|
||||
model Collection {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
stampId String
|
||||
collectedAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
stamp Stamp @relation(fields: [stampId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, stampId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model RedemptionRule {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
description String?
|
||||
threshold Int
|
||||
enabled Boolean @default(true)
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
redemptions Redemption[]
|
||||
}
|
||||
|
||||
model Redemption {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
ruleId String
|
||||
stampCount Int
|
||||
redeemedAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
rule RedemptionRule @relation(fields: [ruleId], references: [id])
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
15
tsconfig.base.json
Normal file
15
tsconfig.base.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user