refactor: 兑换机制改为一图章一奖品并引入库存

- 废弃 RedemptionRule(集 N 换 1),新增 Prize 表与 Stamp 1:1 关联
- Redemption 记录直接绑定到 stampId + prizeId + prizeName 快照
- 兑换事务用 updateMany + stock>0 条件作乐观锁
- 兑换后保留 Collection 记录,图章持续彩色点亮并标记"已兑换"
- 用户端入口改为点击已收集图章弹出兑换,库存为 0 时按钮禁用
- 管理后台删除 /admin/rules,奖品编辑嵌入 StampForm

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 15:30:28 +08:00
parent 52169ac71d
commit 394b643304
20 changed files with 581 additions and 642 deletions

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
CityWalk 图章收集系统 — 移动端 H5 + 管理后台。游客在城市点位扫描二维码收集图章,集满可兑换奖品兑换后图章清空,支持重复收集 CityWalk 图章收集系统 — 移动端 H5 + 管理后台。游客在城市点位扫描二维码收集图章,每枚图章绑定一个特定奖品(带库存),已收集的图章可兑换对应奖品兑换后图章保持彩色点亮并标记为"已兑换",同一图章不可二次收集或二次兑换
## Commands ## Commands
@@ -17,7 +17,7 @@ pnpm dev:web # Vite dev server on :5173
pnpm db:generate # Generate Prisma client after schema changes pnpm db:generate # Generate Prisma client after schema changes
pnpm db:migrate # Create and apply migrations (prisma migrate dev) pnpm db:migrate # Create and apply migrations (prisma migrate dev)
pnpm db:push # Push schema directly (dev only, no migration file) pnpm db:push # Push schema directly (dev only, no migration file)
pnpm db:seed # Seed sample data (9 stamps + 4 redemption rules) pnpm db:seed # Seed sample data (16 stamps, each with a Prize of stock 100)
# Build # Build
pnpm build # Build all packages pnpm build # Build all packages
@@ -48,7 +48,6 @@ All endpoints return: `{ success: boolean, data?: T, error?: { code: string, mes
/collect/:stampId → Redirects to /?stamp={stampId} (QR code entry point) /collect/:stampId → Redirects to /?stamp={stampId} (QR code entry point)
/admin → AdminLogin /admin → AdminLogin
/admin/stamps → Stamp CRUD + QR code generation /admin/stamps → Stamp CRUD + QR code generation
/admin/rules → Redemption rule CRUD
/admin/redemptions → Redemption history + stats /admin/redemptions → Redemption history + stats
``` ```
@@ -58,7 +57,7 @@ QR codes encode `/collect/{stampId}` → redirects to `/?stamp={stampId}` → La
### Redemption Transaction ### Redemption Transaction
Atomic: `prisma.$transaction` creates Redemption record + deletes all user Collections. The `@@unique([userId, stampId])` constraint resets after deletion, allowing re-collection. Each `Stamp` has an optional `Prize` (1:1, `Prize.stampId @unique`). Redemption is atomic: inside `prisma.$transaction` we check the user has a `Collection` for the stamp, no existing `Redemption` for (user, stamp), the prize is `enabled`, then `prisma.prize.updateMany({ where: { id, stock: { gt: 0 } }, data: { stock: { decrement: 1 } } })` acts as a stock lock (throws `OUT_OF_STOCK` if zero rows updated) before creating the `Redemption` record with a `prizeName` snapshot. `Collection` rows are **not** deleted — the `@@unique([userId, stampId])` constraints on both `Collection` and `Redemption` naturally block re-collection and re-redemption of the same stamp.
## Critical: Tailwind CSS v4 Layer Architecture ## Critical: Tailwind CSS v4 Layer Architecture

View File

@@ -32,5 +32,5 @@ packages/
server/ Express API认证、图章、兑换、管理 server/ Express API认证、图章、兑换、管理
web/ React SPA移动端 H5 + PC 管理后台) web/ React SPA移动端 H5 + PC 管理后台)
prisma/ prisma/
schema.prisma 数据模型User, Stamp, Collection, RedemptionRule, Redemption schema.prisma 数据模型User, Stamp, Prize, Collection, Redemption
``` ```

View File

@@ -26,7 +26,10 @@ router.use(requireAdmin);
// ===== Stamps CRUD ===== // ===== Stamps CRUD =====
router.get("/stamps", async (_req, res) => { router.get("/stamps", async (_req, res) => {
const stamps = await prisma.stamp.findMany({ orderBy: { sortOrder: "asc" } }); const stamps = await prisma.stamp.findMany({
orderBy: { sortOrder: "asc" },
include: { prize: true },
});
res.json({ success: true, data: stamps }); res.json({ success: true, data: stamps });
}); });
@@ -121,69 +124,58 @@ router.get("/stamps/:id/qrcode", async (req, res) => {
res.json({ success: true, data: { qrDataUrl, collectUrl, stampName: stamp.name } }); res.json({ success: true, data: { qrDataUrl, collectUrl, stampName: stamp.name } });
}); });
// ===== Redemption Rules CRUD ===== // ===== Prize (per-stamp) =====
router.get("/rules", async (_req, res) => { const prizeSchema = z.object({
const rules = await prisma.redemptionRule.findMany({ orderBy: { sortOrder: "asc" } });
res.json({ success: true, data: rules });
});
const ruleSchema = z.object({
name: z.string().min(1, "奖品名称不能为空"), name: z.string().min(1, "奖品名称不能为空"),
description: z.string().optional(), description: z.string().optional(),
threshold: z.number().int().min(1, "兑换门槛至少为 1"), stock: z.number().int().min(0, "库存不能为负数"),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
sortOrder: z.number().int().optional(),
}); });
router.post("/rules", async (req, res) => { router.put("/stamps/:id/prize", async (req, res) => {
const parsed = ruleSchema.safeParse(req.body); const parsed = prizeSchema.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } }); res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } });
return; return;
} }
const rule = await prisma.redemptionRule.create({ const stamp = await prisma.stamp.findUnique({ where: { id: req.params.id } });
data: { if (!stamp) {
name: parsed.data.name, res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "图章不存在" } });
description: parsed.data.description, return;
threshold: parsed.data.threshold, }
enabled: parsed.data.enabled ?? true, const data = {
sortOrder: parsed.data.sortOrder ?? 0, name: parsed.data.name,
}, description: parsed.data.description ?? null,
stock: parsed.data.stock,
enabled: parsed.data.enabled ?? true,
};
const prize = await prisma.prize.upsert({
where: { stampId: stamp.id },
create: { stampId: stamp.id, ...data },
update: data,
}); });
res.json({ success: true, data: rule }); res.json({ success: true, data: prize });
});
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 ===== // ===== Redemption Records & Stats =====
router.get("/redemptions", async (_req, res) => { router.get("/redemptions", async (_req, res) => {
const records = await prisma.redemption.findMany({ const records = await prisma.redemption.findMany({
include: { user: { select: { username: true, phone: true } }, rule: { select: { name: true } } }, include: {
user: { select: { username: true, phone: true } },
stamp: { select: { name: true } },
},
orderBy: { redeemedAt: "desc" }, orderBy: { redeemedAt: "desc" },
}); });
res.json({ success: true, data: records }); const data = records.map((r) => ({
id: r.id,
redeemedAt: r.redeemedAt,
user: r.user,
stampName: r.stamp.name,
prizeName: r.prizeName,
}));
res.json({ success: true, data });
}); });
router.get("/stats", async (_req, res) => { router.get("/stats", async (_req, res) => {

View File

@@ -5,17 +5,8 @@ import { requireAuth } from "../middleware/auth.js";
const router = Router(); 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({ const redeemSchema = z.object({
ruleId: z.string().uuid("规则 ID 格式不正确"), stampId: z.string().uuid("图章 ID 格式不正确"),
}); });
router.post("/redeem", requireAuth, async (req, res) => { router.post("/redeem", requireAuth, async (req, res) => {
@@ -25,64 +16,94 @@ router.post("/redeem", requireAuth, async (req, res) => {
return; return;
} }
const rule = await prisma.redemptionRule.findUnique({ where: { id: parsed.data.ruleId, enabled: true } }); const { stampId } = parsed.data;
if (!rule) { const userId = req.userId!;
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "兑换规则不存在" } });
return; try {
const redemption = await prisma.$transaction(async (tx) => {
const collection = await tx.collection.findUnique({
where: { userId_stampId: { userId, stampId } },
});
if (!collection) {
throw new RedeemError("NOT_COLLECTED", "你还没有收集这枚图章", 400);
}
const already = await tx.redemption.findUnique({
where: { userId_stampId: { userId, stampId } },
});
if (already) {
throw new RedeemError("ALREADY_REDEEMED", "你已经兑换过这枚图章对应的奖品", 409);
}
const prize = await tx.prize.findUnique({ where: { stampId } });
if (!prize || !prize.enabled) {
throw new RedeemError("PRIZE_UNAVAILABLE", "该图章暂无可兑换的奖品", 400);
}
const decremented = await tx.prize.updateMany({
where: { id: prize.id, stock: { gt: 0 } },
data: { stock: { decrement: 1 } },
});
if (decremented.count === 0) {
throw new RedeemError("OUT_OF_STOCK", "奖品已兑完", 400);
}
return tx.redemption.create({
data: {
userId,
stampId,
prizeId: prize.id,
prizeName: prize.name,
},
include: { stamp: { select: { name: true } } },
});
});
res.json({
success: true,
data: {
id: redemption.id,
stampId: redemption.stampId,
stampName: redemption.stamp.name,
prizeName: redemption.prizeName,
redeemedAt: redemption.redeemedAt.toISOString(),
},
});
} catch (e) {
if (e instanceof RedeemError) {
res.status(e.status).json({ success: false, error: { code: e.code, message: e.message } });
return;
}
throw e;
} }
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) => {
// Deduct the oldest N collections (chronological order by collectedAt)
const toDelete = await tx.collection.findMany({
where: { userId: req.userId! },
orderBy: { collectedAt: "asc" },
take: rule.threshold,
select: { id: true },
});
await tx.collection.deleteMany({
where: { id: { in: toDelete.map((c) => c.id) } },
});
const record = await tx.redemption.create({
data: { userId: req.userId!, ruleId: rule.id, stampCount: rule.threshold },
});
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) => { router.get("/history", requireAuth, async (req, res) => {
const records = await prisma.redemption.findMany({ const records = await prisma.redemption.findMany({
where: { userId: req.userId! }, where: { userId: req.userId! },
include: { rule: { select: { name: true } } }, include: { stamp: { select: { name: true } } },
orderBy: { redeemedAt: "desc" }, orderBy: { redeemedAt: "desc" },
}); });
const data = records.map((r) => ({ const data = records.map((r) => ({
id: r.id, id: r.id,
ruleName: r.rule.name, stampId: r.stampId,
stampCount: r.stampCount, stampName: r.stamp.name,
prizeName: r.prizeName,
redeemedAt: r.redeemedAt.toISOString(), redeemedAt: r.redeemedAt.toISOString(),
})); }));
res.json({ success: true, data }); res.json({ success: true, data });
}); });
class RedeemError extends Error {
constructor(
public code: string,
message: string,
public status: number,
) {
super(message);
}
}
export default router; export default router;

View File

@@ -8,20 +8,25 @@ router.get("/", optionalAuth, async (req, res) => {
const stamps = await prisma.stamp.findMany({ const stamps = await prisma.stamp.findMany({
where: { enabled: true }, where: { enabled: true },
orderBy: { sortOrder: "asc" }, orderBy: { sortOrder: "asc" },
include: { prize: true },
}); });
let collections: Set<string> = new Set(); const collectionMap = new Map<string, Date>();
let collectionMap: Map<string, Date> = new Map(); const redeemedSet = new Set<string>();
if (req.userId) { if (req.userId) {
const userCollections = await prisma.collection.findMany({ const [userCollections, userRedemptions] = await Promise.all([
where: { userId: req.userId }, prisma.collection.findMany({
select: { stampId: true, collectedAt: true }, where: { userId: req.userId },
}); select: { stampId: true, collectedAt: true },
userCollections.forEach((c) => { }),
collections.add(c.stampId); prisma.redemption.findMany({
collectionMap.set(c.stampId, c.collectedAt); where: { userId: req.userId },
}); select: { stampId: true },
}),
]);
userCollections.forEach((c) => collectionMap.set(c.stampId, c.collectedAt));
userRedemptions.forEach((r) => redeemedSet.add(r.stampId));
} }
const data = stamps.map((s) => ({ const data = stamps.map((s) => ({
@@ -31,8 +36,18 @@ router.get("/", optionalAuth, async (req, res) => {
imageColor: s.imageColor, imageColor: s.imageColor,
imageGrey: s.imageGrey, imageGrey: s.imageGrey,
sortOrder: s.sortOrder, sortOrder: s.sortOrder,
collected: collections.has(s.id), collected: collectionMap.has(s.id),
collectedAt: collectionMap.get(s.id)?.toISOString() ?? null, collectedAt: collectionMap.get(s.id)?.toISOString() ?? null,
redeemed: redeemedSet.has(s.id),
prize: s.prize
? {
id: s.prize.id,
name: s.prize.name,
description: s.prize.description,
stock: s.prize.stock,
enabled: s.prize.enabled,
}
: null,
})); }));
res.json({ success: true, data }); res.json({ success: true, data });

View File

@@ -22,7 +22,7 @@ const stampData = [
async function seed() { async function seed() {
console.log("Seeding database..."); console.log("Seeding database...");
// Clear existing stamps (cascades to collections) // Clear existing stamps (cascades to collections + prize)
await prisma.stamp.deleteMany(); await prisma.stamp.deleteMany();
const stamps = await Promise.all( const stamps = await Promise.all(
@@ -34,34 +34,20 @@ async function seed() {
imageColor: `/uploads/stamps/stamp-${pos}-color.jpg`, imageColor: `/uploads/stamps/stamp-${pos}-color.jpg`,
imageGrey: `/uploads/stamps/stamp-${pos}-grey.jpg`, imageGrey: `/uploads/stamps/stamp-${pos}-grey.jpg`,
sortOrder: idx + 1, sortOrder: idx + 1,
prize: {
create: {
name: `${s.name} · 纪念章`,
description: `在「${s.name}」集到的专属纪念奖品`,
stock: 100,
enabled: true,
},
},
}, },
}); });
}), }),
); );
console.log(`Created ${stamps.length} stamps`); console.log(`Created ${stamps.length} stamps with prizes`);
// Create redemption rules if none exist
const existingRules = await prisma.redemptionRule.count();
if (existingRules === 0) {
const rules = await Promise.all([
prisma.redemptionRule.create({
data: { name: "纪念书签", description: "精美城市纪念书签一枚", threshold: 4, sortOrder: 1 },
}),
prisma.redemptionRule.create({
data: { name: "手绘明信片套装", description: "一套 5 张手绘城市明信片", threshold: 8, sortOrder: 2 },
}),
prisma.redemptionRule.create({
data: { name: "限定徽章礼盒", description: "城市限定版金属徽章礼盒", threshold: 12, sortOrder: 3 },
}),
prisma.redemptionRule.create({
data: { name: "城市记忆礼包", description: "包含纪念 T 恤、帆布袋和城市画册", threshold: 16, sortOrder: 4 },
}),
]);
console.log(`Created ${rules.length} redemption rules`);
} else {
console.log(`Kept existing ${existingRules} redemption rules`);
}
console.log("\nStamp IDs for testing:"); console.log("\nStamp IDs for testing:");
stamps.forEach((s) => { stamps.forEach((s) => {

View File

@@ -4,6 +4,14 @@ export type ApiResponse<T = unknown> = {
error?: { code: string; message: string }; error?: { code: string; message: string };
}; };
export type PrizeInfo = {
id: string;
name: string;
description: string | null;
stock: number;
enabled: boolean;
};
export type StampWithStatus = { export type StampWithStatus = {
id: string; id: string;
name: string; name: string;
@@ -13,19 +21,15 @@ export type StampWithStatus = {
sortOrder: number; sortOrder: number;
collected: boolean; collected: boolean;
collectedAt: string | null; collectedAt: string | null;
}; redeemed: boolean;
prize: PrizeInfo | null;
export type RedemptionRuleInfo = {
id: string;
name: string;
description: string | null;
threshold: number;
}; };
export type RedemptionRecord = { export type RedemptionRecord = {
id: string; id: string;
ruleName: string; stampId: string;
stampCount: number; stampName: string;
prizeName: string;
redeemedAt: string; redeemedAt: string;
}; };

View File

@@ -11,7 +11,6 @@ import Dashboard from "./admin/Dashboard";
import StampList from "./admin/StampList"; import StampList from "./admin/StampList";
import ArticleList from "./admin/ArticleList"; import ArticleList from "./admin/ArticleList";
import MusicList from "./admin/MusicList"; import MusicList from "./admin/MusicList";
import RuleList from "./admin/RuleList";
import UsersList from "./admin/UsersList"; import UsersList from "./admin/UsersList";
import RedemptionLog from "./admin/RedemptionLog"; import RedemptionLog from "./admin/RedemptionLog";
@@ -39,7 +38,6 @@ export default function App() {
<Route path="/admin/stamps" element={<StampList />} /> <Route path="/admin/stamps" element={<StampList />} />
<Route path="/admin/articles" element={<ArticleList />} /> <Route path="/admin/articles" element={<ArticleList />} />
<Route path="/admin/music" element={<MusicList />} /> <Route path="/admin/music" element={<MusicList />} />
<Route path="/admin/rules" element={<RuleList />} />
<Route path="/admin/users" element={<UsersList />} /> <Route path="/admin/users" element={<UsersList />} />
<Route path="/admin/redemptions" element={<RedemptionLog />} /> <Route path="/admin/redemptions" element={<RedemptionLog />} />
</Route> </Route>

View File

@@ -6,9 +6,8 @@ const navItems = [
{ path: "/admin/stamps", label: "图章管理", eyebrow: "02", tag: "Stamps" }, { path: "/admin/stamps", label: "图章管理", eyebrow: "02", tag: "Stamps" },
{ path: "/admin/articles", label: "文章管理", eyebrow: "03", tag: "Articles" }, { path: "/admin/articles", label: "文章管理", eyebrow: "03", tag: "Articles" },
{ path: "/admin/music", label: "音乐管理", eyebrow: "04", tag: "Music" }, { path: "/admin/music", label: "音乐管理", eyebrow: "04", tag: "Music" },
{ path: "/admin/rules", label: "兑换规则", eyebrow: "05", tag: "Rules" }, { path: "/admin/users", label: "用户管理", eyebrow: "05", tag: "Users" },
{ path: "/admin/users", label: "用户管理", eyebrow: "06", tag: "Users" }, { path: "/admin/redemptions", label: "兑换记录", eyebrow: "06", tag: "Log" },
{ path: "/admin/redemptions", label: "兑换记录", eyebrow: "07", tag: "Log" },
]; ];
export default function AdminLayout() { export default function AdminLayout() {

View File

@@ -5,11 +5,10 @@ import { TableCard, TableHeadRow } from "./StampList";
type RedemptionRecord = { type RedemptionRecord = {
id: string; id: string;
userId: string;
stampCount: number;
redeemedAt: string; redeemedAt: string;
user: { username: string; phone: string }; user: { username: string; phone: string };
rule: { name: string }; stampName: string;
prizeName: string;
}; };
type Stats = { type Stats = {
@@ -44,7 +43,7 @@ export default function RedemptionLog() {
return ( return (
<> <>
<PageHeader <PageHeader
eyebrow="07 · Log" eyebrow="06 · Log"
title="兑换记录" title="兑换记录"
caption="账户、图章收集与兑换的完整轨迹" caption="账户、图章收集与兑换的完整轨迹"
/> />
@@ -119,7 +118,7 @@ export default function RedemptionLog() {
) : ( ) : (
<table className="w-full"> <table className="w-full">
<thead> <thead>
<TableHeadRow cols={["用户", "手机号", "兑换奖品", "扣除枚数", "时间"]} /> <TableHeadRow cols={["用户", "手机号", "图章", "奖品", "时间"]} />
</thead> </thead>
<tbody> <tbody>
{records.map((r, i) => ( {records.map((r, i) => (
@@ -137,16 +136,10 @@ export default function RedemptionLog() {
<span className="text-sm text-[var(--text-secondary)] font-mono">{r.user.phone}</span> <span className="text-sm text-[var(--text-secondary)] font-mono">{r.user.phone}</span>
</td> </td>
<td className="px-5 py-4"> <td className="px-5 py-4">
<span className="text-sm text-[var(--text-secondary)]">{r.rule.name}</span> <span className="text-sm text-[var(--text-secondary)]">{r.stampName}</span>
</td> </td>
<td className="px-5 py-4 text-center w-[140px]"> <td className="px-5 py-4">
<span <span className="text-sm text-[var(--text-primary)] font-medium">{r.prizeName}</span>
className="inline-flex items-baseline gap-1 text-[var(--terracotta)]"
style={{ fontFamily: "'Playfair Display', serif" }}
>
<span className="text-xl font-semibold leading-none">{r.stampCount}</span>
<span className="text-[10px] tracking-[0.2em] uppercase opacity-70"></span>
</span>
</td> </td>
<td className="px-5 py-4 text-right w-[200px]"> <td className="px-5 py-4 text-right w-[200px]">
<span className="text-xs text-[var(--text-muted)] font-mono"> <span className="text-xs text-[var(--text-muted)] font-mono">

View File

@@ -1,132 +0,0 @@
import { useState, useEffect } from "react";
import Modal from "./Modal";
import { adminFetch } from "./adminApi";
import { useToast } from "./Toast";
import { Field, ErrorRow, FormFooter, fieldCls } from "./FormPrimitives";
type Rule = {
id: string;
name: string;
description: string | null;
threshold: number;
enabled: boolean;
sortOrder: number;
};
type Props = {
open: boolean;
id: string | null;
onClose: () => void;
onSaved: () => void;
};
export default function RuleForm({ open, id, onClose, onSaved }: Props) {
const toast = useToast();
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("");
const isEdit = !!id;
useEffect(() => {
if (!open) return;
setError("");
if (!id) {
setName(""); setDescription(""); setThreshold(1); setSortOrder(0);
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);
}
});
}, [open, id]);
const handleSave = async () => {
setError("");
if (!name.trim()) return setError("请输入奖品名称");
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) });
}
toast.show("已保存");
onSaved();
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
};
return (
<Modal
open={open}
onClose={onClose}
size="md"
eyebrow={isEdit ? "Edit Rule" : "New Rule"}
title={isEdit ? "编辑兑换规则" : "添加兑换规则"}
>
<div className="px-7 py-6 space-y-5">
<Field label="奖品名称" required>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="如:城市限定明信片"
className={fieldCls}
/>
</Field>
<Field label="奖品描述">
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
placeholder="选填"
className={fieldCls + " resize-none"}
/>
</Field>
<div className="grid grid-cols-2 gap-5">
<Field label="所需图章数" required hint="≥ 1">
<input
type="number"
min={1}
value={threshold}
onChange={(e) => setThreshold(Number(e.target.value))}
className={fieldCls + " w-full"}
/>
</Field>
<Field label="排序" hint="数字小的在前">
<input
type="number"
value={sortOrder}
onChange={(e) => setSortOrder(Number(e.target.value))}
className={fieldCls + " w-full"}
/>
</Field>
</div>
{error && <ErrorRow text={error} />}
</div>
<FormFooter onCancel={onClose} onSave={handleSave} saving={saving} primaryLabel={isEdit ? "保存" : "创建"} />
</Modal>
);
}

View File

@@ -1,151 +0,0 @@
import { useState, useEffect } from "react";
import { adminFetch } from "./adminApi";
import { useToast } from "./Toast";
import RuleForm from "./RuleForm";
import PageHeader, {
PrimaryButton,
StatusChip,
ActionButton,
EmptyState,
LoadingBlock,
IconEdit,
IconDelete,
} from "./PageHeader";
import { TableCard, TableHeadRow } from "./StampList";
type Rule = {
id: string;
name: string;
description: string | null;
threshold: number;
enabled: boolean;
sortOrder: number;
};
export default function RuleList() {
const toast = useToast();
const [rules, setRules] = useState<Rule[]>([]);
const [loading, setLoading] = useState(true);
const [formState, setFormState] = useState<{ open: boolean; id: string | null }>({ open: false, id: null });
const fetchRules = async () => {
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;
try {
await adminFetch(`/rules/${id}`, { method: "DELETE" });
toast.show("已删除");
fetchRules();
} catch (e) {
toast.show(e instanceof Error ? e.message : "删除失败", "error");
}
};
const handleToggle = async (id: string, enabled: boolean) => {
await adminFetch(`/rules/${id}`, {
method: "PUT",
body: JSON.stringify({ enabled: !enabled }),
});
fetchRules();
};
return (
<>
<PageHeader
eyebrow="05 · Rules"
title="兑换规则"
caption="设置可兑换的奖品与所需图章数"
action={<PrimaryButton onClick={() => setFormState({ open: true, id: null })}></PrimaryButton>}
/>
{loading ? (
<LoadingBlock />
) : (
<TableCard>
{rules.length === 0 ? (
<EmptyState
message="尚未创建兑换规则"
action={
<PrimaryButton onClick={() => setFormState({ open: true, id: null })}>
</PrimaryButton>
}
/>
) : (
<table className="w-full">
<thead>
<TableHeadRow cols={["奖品", "描述", "所需图章", "状态", "操作"]} />
</thead>
<tbody>
{rules.map((rule, i) => (
<tr
key={rule.id}
className="border-t border-[var(--border-muted)] hover:bg-white/60 transition-colors animate-admin-row"
style={{ animationDelay: `${Math.min(i, 12) * 0.03}s` }}
>
<td className="px-5 py-4">
<p className="text-[15px] font-medium text-[var(--text-primary)]">{rule.name}</p>
</td>
<td className="px-5 py-4">
<p className="text-sm text-[var(--text-muted)] max-w-[340px] truncate">
{rule.description || "—"}
</p>
</td>
<td className="px-5 py-4 text-center w-[140px]">
<div className="inline-flex items-baseline gap-1.5">
<span
className="text-2xl text-[var(--terracotta)] leading-none"
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 600 }}
>
{rule.threshold}
</span>
<span className="text-[10px] tracking-[0.2em] uppercase text-[var(--text-muted)]">
</span>
</div>
</td>
<td className="px-5 py-4 text-center w-[110px]">
<StatusChip enabled={rule.enabled} onClick={() => handleToggle(rule.id, rule.enabled)} />
</td>
<td className="px-5 py-4 w-[140px]">
<div className="flex items-center justify-end gap-1">
<ActionButton title="编辑" onClick={() => setFormState({ open: true, id: rule.id })}>
{IconEdit}
</ActionButton>
<ActionButton
title="删除"
variant="danger"
onClick={() => handleDelete(rule.id, rule.name)}
>
{IconDelete}
</ActionButton>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</TableCard>
)}
<RuleForm
open={formState.open}
id={formState.id}
onClose={() => setFormState({ open: false, id: null })}
onSaved={fetchRules}
/>
</>
);
}

View File

@@ -4,6 +4,14 @@ import { adminFetch } from "./adminApi";
import { useToast } from "./Toast"; import { useToast } from "./Toast";
import { Field, ErrorRow, FormFooter, HintRow, fieldCls } from "./FormPrimitives"; import { Field, ErrorRow, FormFooter, HintRow, fieldCls } from "./FormPrimitives";
type Prize = {
id: string;
name: string;
description: string | null;
stock: number;
enabled: boolean;
};
type Stamp = { type Stamp = {
id: string; id: string;
name: string; name: string;
@@ -12,6 +20,7 @@ type Stamp = {
imageGrey: string; imageGrey: string;
sortOrder: number; sortOrder: number;
enabled: boolean; enabled: boolean;
prize: Prize | null;
}; };
type Props = { type Props = {
@@ -32,6 +41,11 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [prizeName, setPrizeName] = useState("");
const [prizeDescription, setPrizeDescription] = useState("");
const [prizeStock, setPrizeStock] = useState(0);
const [prizeEnabled, setPrizeEnabled] = useState(true);
const isEdit = !!currentId; const isEdit = !!currentId;
useEffect(() => { useEffect(() => {
@@ -44,6 +58,10 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
setSortOrder(0); setSortOrder(0);
setImageColor(""); setImageColor("");
setImageGrey(""); setImageGrey("");
setPrizeName("");
setPrizeDescription("");
setPrizeStock(0);
setPrizeEnabled(true);
return; return;
} }
adminFetch<Stamp[]>("/stamps").then((stamps) => { adminFetch<Stamp[]>("/stamps").then((stamps) => {
@@ -54,6 +72,17 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
setSortOrder(stamp.sortOrder); setSortOrder(stamp.sortOrder);
setImageColor(stamp.imageColor); setImageColor(stamp.imageColor);
setImageGrey(stamp.imageGrey); setImageGrey(stamp.imageGrey);
if (stamp.prize) {
setPrizeName(stamp.prize.name);
setPrizeDescription(stamp.prize.description || "");
setPrizeStock(stamp.prize.stock);
setPrizeEnabled(stamp.prize.enabled);
} else {
setPrizeName("");
setPrizeDescription("");
setPrizeStock(0);
setPrizeEnabled(true);
}
} }
}); });
}, [open, id]); }, [open, id]);
@@ -87,6 +116,10 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
setError("请输入图章名称"); setError("请输入图章名称");
return; return;
} }
if (isEdit && prizeName.trim() && prizeStock < 0) {
setError("库存不能为负数");
return;
}
setSaving(true); setSaving(true);
try { try {
const payload = { name: name.trim(), note: note.trim() || undefined, sortOrder }; const payload = { name: name.trim(), note: note.trim() || undefined, sortOrder };
@@ -95,6 +128,17 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
method: "PUT", method: "PUT",
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (prizeName.trim()) {
await adminFetch(`/stamps/${currentId}/prize`, {
method: "PUT",
body: JSON.stringify({
name: prizeName.trim(),
description: prizeDescription.trim() || undefined,
stock: prizeStock,
enabled: prizeEnabled,
}),
});
}
toast.show("已保存"); toast.show("已保存");
onSaved(); onSaved();
onClose(); onClose();
@@ -104,7 +148,7 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
setCurrentId(stamp.id); setCurrentId(stamp.id);
toast.show("已创建,现在可以上传图片"); toast.show("已创建,现在可以上传图片与配置奖品");
onSaved(); onSaved();
} }
} catch (e) { } catch (e) {
@@ -121,7 +165,7 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
size="md" size="md"
eyebrow={isEdit ? "Edit Stamp" : "New Stamp"} eyebrow={isEdit ? "Edit Stamp" : "New Stamp"}
title={isEdit ? "编辑图章" : "添加图章"} title={isEdit ? "编辑图章" : "添加图章"}
subtitle={isEdit ? "调整信息上传图片" : "先保存基础信息,再上传图章图片"} subtitle={isEdit ? "调整信息上传图片并配置关联奖品" : "先保存基础信息,再上传图片与配置奖品"}
> >
<div className="px-7 py-6 space-y-5"> <div className="px-7 py-6 space-y-5">
<Field label="名称" required> <Field label="名称" required>
@@ -153,22 +197,79 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
</Field> </Field>
{isEdit ? ( {isEdit ? (
<div className="grid grid-cols-2 gap-5"> <>
<ImageSlot <div className="grid grid-cols-2 gap-5">
label="彩色图章" <ImageSlot
kind="color" label="彩色图章"
image={imageColor} kind="color"
onUpload={(f) => handleUpload(f, "imageColor")} image={imageColor}
/> onUpload={(f) => handleUpload(f, "imageColor")}
<ImageSlot />
label="灰色图章" <ImageSlot
kind="grey" label="灰色图章"
image={imageGrey} kind="grey"
onUpload={(f) => handleUpload(f, "imageGrey")} image={imageGrey}
/> onUpload={(f) => handleUpload(f, "imageGrey")}
</div> />
</div>
<div className="pt-4 mt-2 border-t border-dashed border-[var(--border-muted)]">
<div className="flex items-baseline justify-between mb-3">
<span className="text-[13px] font-medium text-[var(--text-secondary)]"></span>
<span
className="text-[9px] tracking-[0.3em] uppercase text-[var(--gold)]"
style={{ fontFamily: "'Playfair Display', serif" }}
>
Prize
</span>
</div>
<div className="space-y-4">
<Field label="奖品名称" hint="留空表示此图章暂不提供兑换">
<input
value={prizeName}
onChange={(e) => setPrizeName(e.target.value)}
placeholder="如:朝天宫纪念书签"
className={fieldCls}
/>
</Field>
<Field label="奖品描述">
<textarea
value={prizeDescription}
onChange={(e) => setPrizeDescription(e.target.value)}
rows={2}
placeholder="选填,展示在用户兑换页"
className={fieldCls + " resize-none"}
/>
</Field>
<div className="flex items-end gap-5">
<Field label="库存" hint="≥ 0兑换后自动扣减">
<input
type="number"
min={0}
value={prizeStock}
onChange={(e) => setPrizeStock(Math.max(0, Number(e.target.value)))}
className={fieldCls + " w-32"}
/>
</Field>
<label className="flex items-center gap-2 pb-[10px] cursor-pointer">
<input
type="checkbox"
checked={prizeEnabled}
onChange={(e) => setPrizeEnabled(e.target.checked)}
className="w-4 h-4 accent-[var(--jade)]"
/>
<span className="text-[13px] text-[var(--text-secondary)]"></span>
</label>
</div>
</div>
</div>
</>
) : ( ) : (
<HintRow text="保存基础信息后,即可上传图章图片" /> <HintRow text="保存基础信息后,即可上传图章图片并配置关联奖品" />
)} )}
{error && <ErrorRow text={error} />} {error && <ErrorRow text={error} />}

View File

@@ -24,6 +24,13 @@ type Stamp = {
imageGrey: string; imageGrey: string;
sortOrder: number; sortOrder: number;
enabled: boolean; enabled: boolean;
prize: {
id: string;
name: string;
description: string | null;
stock: number;
enabled: boolean;
} | null;
}; };
export default function StampList() { export default function StampList() {
@@ -102,7 +109,7 @@ export default function StampList() {
) : ( ) : (
<table className="w-full"> <table className="w-full">
<thead> <thead>
<TableHeadRow cols={["图章", "名称 · 备注", "排序", "状态", "操作"]} /> <TableHeadRow cols={["图章", "名称 · 备注", "奖品 · 库存", "排序", "状态", "操作"]} />
</thead> </thead>
<tbody> <tbody>
{stamps.map((stamp, i) => ( {stamps.map((stamp, i) => (
@@ -130,6 +137,32 @@ export default function StampList() {
</p> </p>
)} )}
</td> </td>
<td className="px-5 py-4 w-[220px]">
{stamp.prize ? (
<>
<p className="text-sm text-[var(--text-primary)] truncate max-w-[200px]">
{stamp.prize.name}
</p>
<p className="text-xs mt-0.5">
<span
style={{
color:
stamp.prize.stock > 0
? "var(--text-muted)"
: "var(--terracotta)",
}}
>
{stamp.prize.stock}
</span>
{!stamp.prize.enabled && (
<span className="ml-2 text-[var(--text-muted)]">· </span>
)}
</p>
</>
) : (
<span className="text-xs text-[var(--text-muted)]/70 italic"></span>
)}
</td>
<td className="px-5 py-4 text-center w-[80px]"> <td className="px-5 py-4 text-center w-[80px]">
<span <span
className="text-[13px] text-[var(--text-secondary)]" className="text-[13px] text-[var(--text-secondary)]"

View File

@@ -1,26 +1,34 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { RedemptionRuleInfo } from "@stamp/shared"; import type { StampWithStatus } from "@stamp/shared";
type RedeemModalProps = { type RedeemModalProps = {
rules: RedemptionRuleInfo[]; stamp: StampWithStatus;
collectedCount: number; onRedeem: (stampId: string) => Promise<void>;
onRedeem: (ruleId: string) => Promise<void>;
onClose: () => void; onClose: () => void;
}; };
const CONFIRM_COUNTDOWN = 5; const CONFIRM_COUNTDOWN = 5;
export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }: RedeemModalProps) { type Mode = "redeemed" | "sold-out" | "unavailable" | "ready";
const [redeeming, setRedeeming] = useState<string | null>(null);
const [error, setError] = useState(""); function resolveMode(stamp: StampWithStatus): Mode {
const [confirmRuleId, setConfirmRuleId] = useState<string | null>(null); if (stamp.redeemed) return "redeemed";
if (!stamp.prize || !stamp.prize.enabled) return "unavailable";
if (stamp.prize.stock <= 0) return "sold-out";
return "ready";
}
export default function RedeemModal({ stamp, onRedeem, onClose }: RedeemModalProps) {
const [confirming, setConfirming] = useState(false);
const [countdown, setCountdown] = useState(CONFIRM_COUNTDOWN); const [countdown, setCountdown] = useState(CONFIRM_COUNTDOWN);
const [redeeming, setRedeeming] = useState(false);
const [error, setError] = useState("");
const confirmRule = confirmRuleId ? rules.find((r) => r.id === confirmRuleId) : null; const mode = resolveMode(stamp);
const prize = stamp.prize;
// 5-second countdown that restarts each time the confirm panel opens
useEffect(() => { useEffect(() => {
if (!confirmRuleId) return; if (!confirming) return;
setCountdown(CONFIRM_COUNTDOWN); setCountdown(CONFIRM_COUNTDOWN);
const interval = setInterval(() => { const interval = setInterval(() => {
setCountdown((c) => { setCountdown((c) => {
@@ -32,39 +40,57 @@ export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }
}); });
}, 1000); }, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [confirmRuleId]); }, [confirming]);
const openConfirm = (ruleId: string) => { const openConfirm = () => {
if (mode !== "ready") return;
setError(""); setError("");
setConfirmRuleId(ruleId); setConfirming(true);
}; };
const cancelConfirm = () => { const cancelConfirm = () => {
if (redeeming) return; if (redeeming) return;
setConfirmRuleId(null); setConfirming(false);
}; };
const doRedeem = async () => { const doRedeem = async () => {
if (!confirmRule || countdown > 0) return; if (countdown > 0 || redeeming || mode !== "ready") return;
setRedeeming(confirmRule.id); setRedeeming(true);
setError(""); setError("");
try { try {
await onRedeem(confirmRule.id); await onRedeem(stamp.id);
onClose(); onClose();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "兑换失败"); setError(e instanceof Error ? e.message : "兑换失败");
setConfirmRuleId(null); setConfirming(false);
} finally { } finally {
setRedeeming(null); setRedeeming(false);
} }
}; };
const buttonCopy = () => {
switch (mode) {
case "redeemed":
return "已兑换";
case "sold-out":
return "已兑完";
case "unavailable":
return "暂无奖品";
case "ready":
return "立即兑换";
}
};
const buttonBg = mode === "ready" ? "var(--jade)" : mode === "redeemed" ? "var(--gold)" : "var(--border-muted)";
const buttonColor = mode === "ready" || mode === "redeemed" ? "white" : "var(--text-muted)";
return ( return (
<div className="fixed inset-0 z-50 flex items-end justify-center animate-overlay-fade" <div
className="fixed inset-0 z-50 flex items-end justify-center animate-overlay-fade"
style={{ backgroundColor: "var(--overlay)" }} style={{ backgroundColor: "var(--overlay)" }}
onClick={(e) => { onClick={(e) => {
if (e.target !== e.currentTarget) return; if (e.target !== e.currentTarget) return;
if (confirmRuleId) return; // Don't dismiss during confirm flow if (confirming) return;
onClose(); onClose();
}} }}
> >
@@ -78,57 +104,75 @@ export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }
</button> </button>
</div> </div>
<p className="text-sm text-[var(--text-secondary)] mb-4"> {/* Stamp header */}
<span className="font-semibold text-[var(--jade)]">{collectedCount}</span> <div className="flex items-center gap-4 mb-5">
</p> <div
className="w-16 h-16 rounded-full bg-white overflow-hidden flex items-center justify-center border border-[var(--border-muted)] shrink-0"
{error && ( style={{ boxShadow: "0 2px 6px rgba(212,165,116,0.25), inset 0 0 0 1px rgba(212,165,116,0.15)" }}
<p className="text-sm text-[var(--terracotta)] mb-3">{error}</p> >
)} <img src={stamp.imageColor} alt={stamp.name} className="w-[92%] h-[92%] object-contain" />
</div>
<div className="space-y-3"> <div className="min-w-0">
{rules.map((rule) => { <p className="text-[10px] tracking-[0.25em] uppercase text-[var(--gold)] mb-0.5">Stamp</p>
const canRedeem = collectedCount >= rule.threshold; <p className="text-base font-semibold text-[var(--text-primary)] truncate">{stamp.name}</p>
return ( {stamp.collectedAt && (
<div <p className="text-xs text-[var(--text-muted)] mt-0.5">
key={rule.id} {new Date(stamp.collectedAt).toLocaleDateString("zh-CN")}
className="flex items-center justify-between p-4 rounded-xl border" </p>
style={{ )}
borderColor: canRedeem ? "var(--jade)" : "var(--border-muted)", </div>
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={() => openConfirm(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)",
}}
>
</button>
</div>
);
})}
</div> </div>
{/* Prize card */}
{prize ? (
<div className="rounded-xl border border-[var(--jade)] bg-[rgba(45,106,79,0.05)] p-4 mb-4">
<p className="text-[10px] tracking-[0.2em] text-[var(--jade)] uppercase mb-1.5">Reward</p>
<p className="text-sm font-semibold text-[var(--text-primary)]">{prize.name}</p>
{prize.description && (
<p className="text-xs text-[var(--text-muted)] mt-1 leading-relaxed">{prize.description}</p>
)}
<div className="mt-3 pt-3 border-t border-[var(--jade)]/15 flex items-baseline justify-between">
<span className="text-xs text-[var(--text-muted)]"></span>
<span
className="text-xl font-semibold"
style={{ color: prize.stock > 0 ? "var(--jade)" : "var(--terracotta)" }}
>
{prize.stock}
<span className="text-xs font-normal text-[var(--text-muted)] ml-1"></span>
</span>
</div>
</div>
) : (
<div className="rounded-xl border border-[var(--border-muted)] bg-white p-4 mb-4 text-center">
<p className="text-sm text-[var(--text-muted)]"></p>
</div>
)}
{mode === "redeemed" && (
<p className="text-xs text-[var(--text-muted)] text-center mb-4"></p>
)}
{mode === "sold-out" && (
<p className="text-xs text-[var(--terracotta)] text-center mb-4"></p>
)}
{error && <p className="text-sm text-[var(--terracotta)] mb-3 text-center">{error}</p>}
<button
onClick={openConfirm}
disabled={mode !== "ready"}
className="w-full py-3.5 rounded-xl text-sm font-medium transition-all disabled:cursor-not-allowed"
style={{
backgroundColor: buttonBg,
color: buttonColor,
boxShadow: mode === "ready" ? "0 2px 8px rgba(45,106,79,0.25)" : "none",
}}
>
{buttonCopy()}
</button>
</div> </div>
{/* Confirmation dialog — centered over the sheet, highest priority */} {/* Confirmation dialog */}
{confirmRule && ( {confirming && prize && (
<div <div
className="fixed inset-0 z-[60] flex items-center justify-center px-5 py-6 animate-overlay-fade" className="fixed inset-0 z-[60] flex items-center justify-center px-5 py-6 animate-overlay-fade"
style={{ backgroundColor: "rgba(26, 26, 46, 0.6)" }} style={{ backgroundColor: "rgba(26, 26, 46, 0.6)" }}
@@ -138,17 +182,18 @@ export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }
className="w-full max-w-sm bg-[var(--bg-cream)] rounded-2xl animate-scale-in overflow-hidden" className="w-full max-w-sm bg-[var(--bg-cream)] rounded-2xl animate-scale-in overflow-hidden"
style={{ boxShadow: "0 24px 60px rgba(26,26,46,0.35)" }} style={{ boxShadow: "0 24px 60px rgba(26,26,46,0.35)" }}
> >
{/* Warning at the top — most prominent, filled terracotta */} {/* Warning */}
<div <div className="px-5 py-4" style={{ backgroundColor: "var(--terracotta)", color: "#fff" }}>
className="px-5 py-4"
style={{
backgroundColor: "var(--terracotta)",
color: "#fff",
}}
>
<div className="flex gap-3"> <div className="flex gap-3">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" <svg
className="shrink-0 mt-0.5"> width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
className="shrink-0 mt-0.5"
>
<path d="M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /> <path d="M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg> </svg>
<div className="flex-1"> <div className="flex-1">
@@ -160,50 +205,37 @@ export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }
</div> </div>
</div> </div>
{/* Body */}
<div className="px-5 pt-5 pb-5"> <div className="px-5 pt-5 pb-5">
<h3 className="text-base font-semibold text-[var(--text-primary)] mb-4"></h3> <h3 className="text-base font-semibold text-[var(--text-primary)] mb-4"></h3>
{/* Reward */}
<div className="rounded-xl border border-[var(--jade)] bg-[rgba(45,106,79,0.05)] p-3.5 mb-3"> <div className="rounded-xl border border-[var(--jade)] bg-[rgba(45,106,79,0.05)] p-3.5 mb-3">
<p className="text-[10px] tracking-[0.2em] text-[var(--jade)] uppercase mb-1">Reward</p> <p className="text-[10px] tracking-[0.2em] text-[var(--jade)] uppercase mb-1">Reward</p>
<p className="text-sm font-semibold text-[var(--text-primary)]">{confirmRule.name}</p> <p className="text-sm font-semibold text-[var(--text-primary)]">{prize.name}</p>
{confirmRule.description && ( {prize.description && (
<p className="text-xs text-[var(--text-muted)] mt-0.5">{confirmRule.description}</p> <p className="text-xs text-[var(--text-muted)] mt-0.5">{prize.description}</p>
)} )}
</div> </div>
{/* Deduction summary */}
<div className="rounded-xl border border-[var(--border-default)] bg-white p-3.5 mb-2"> <div className="rounded-xl border border-[var(--border-default)] bg-white p-3.5 mb-2">
<div className="flex items-baseline justify-between"> <p className="text-xs text-[var(--text-muted)] leading-relaxed">
<span className="text-xs text-[var(--text-muted)]"></span> <span className="font-medium text-[var(--text-secondary)]">{stamp.name}</span>
<span className="text-xl font-semibold text-[var(--terracotta)]">{confirmRule.threshold}</span> <span className="font-medium text-[var(--jade)]"></span>
</div>
<p className="text-xs text-[var(--text-muted)] mt-1 leading-relaxed">
{confirmRule.threshold} {" "}
<span className="font-medium text-[var(--text-secondary)]">
{collectedCount - confirmRule.threshold}
</span>{" "}
</p> </p>
</div> </div>
<p className="text-[11px] text-[var(--text-muted)] text-center mb-4"> <p className="text-[11px] text-[var(--text-muted)] text-center mb-4"></p>
</p>
{/* Buttons */}
<div className="flex gap-2.5"> <div className="flex gap-2.5">
<button <button
onClick={cancelConfirm} onClick={cancelConfirm}
disabled={!!redeeming} disabled={redeeming}
className="flex-1 py-3 rounded-xl text-sm font-medium border border-[var(--border-default)] text-[var(--text-secondary)] bg-white disabled:opacity-40" className="flex-1 py-3 rounded-xl text-sm font-medium border border-[var(--border-default)] text-[var(--text-secondary)] bg-white disabled:opacity-40"
> >
</button> </button>
<button <button
onClick={doRedeem} onClick={doRedeem}
disabled={countdown > 0 || !!redeeming} disabled={countdown > 0 || redeeming}
className="flex-[1.3] py-3 rounded-xl text-sm font-medium transition-colors" className="flex-[1.3] py-3 rounded-xl text-sm font-medium transition-colors"
style={{ style={{
backgroundColor: countdown > 0 || redeeming ? "var(--border-default)" : "var(--jade)", backgroundColor: countdown > 0 || redeeming ? "var(--border-default)" : "var(--jade)",
@@ -211,11 +243,7 @@ export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }
boxShadow: countdown > 0 || redeeming ? "none" : "0 2px 8px rgba(45,106,79,0.25)", boxShadow: countdown > 0 || redeeming ? "none" : "0 2px 8px rgba(45,106,79,0.25)",
}} }}
> >
{redeeming {redeeming ? "兑换中..." : countdown > 0 ? `请阅读提示 ${countdown}s` : "确认兑换"}
? "兑换中..."
: countdown > 0
? `请阅读提示 ${countdown}s`
: "确认兑换"}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -3,10 +3,11 @@ type StampCardProps = {
imageColor: string; imageColor: string;
imageGrey: string; imageGrey: string;
collected: boolean; collected: boolean;
redeemed?: boolean;
onClick?: () => void; onClick?: () => void;
}; };
export default function StampCard({ name, imageColor, imageGrey, collected, onClick }: StampCardProps) { export default function StampCard({ name, imageColor, imageGrey, collected, redeemed, onClick }: StampCardProps) {
const src = collected ? imageColor : imageGrey; const src = collected ? imageColor : imageGrey;
return ( return (
@@ -43,13 +44,22 @@ export default function StampCard({ name, imageColor, imageGrey, collected, onCl
/> />
</div> </div>
{collected && ( {collected && !redeemed && (
<div className="absolute top-0 right-0 w-4 h-4 rounded-full bg-[var(--jade)] flex items-center justify-center shadow-sm z-10"> <div className="absolute top-0 right-0 w-4 h-4 rounded-full bg-[var(--jade)] flex items-center justify-center shadow-sm z-10">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3"> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3">
<polyline points="20 6 9 17 4 12" /> <polyline points="20 6 9 17 4 12" />
</svg> </svg>
</div> </div>
)} )}
{redeemed && (
<div
className="absolute -top-1 right-0 px-1.5 py-[1px] rounded-full text-[9px] font-semibold leading-tight shadow-sm z-10"
style={{ backgroundColor: "var(--gold)", color: "white", letterSpacing: "0.05em" }}
>
</div>
)}
</div> </div>
<span <span

View File

@@ -16,7 +16,8 @@ export default function StampGrid({ stamps, onStampClick }: StampGridProps) {
imageColor={stamp.imageColor} imageColor={stamp.imageColor}
imageGrey={stamp.imageGrey} imageGrey={stamp.imageGrey}
collected={stamp.collected} collected={stamp.collected}
onClick={() => onStampClick?.(stamp)} redeemed={stamp.redeemed}
onClick={stamp.collected ? () => onStampClick?.(stamp) : undefined}
/> />
))} ))}
</div> </div>

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import type { StampWithStatus, RedemptionRuleInfo, RedemptionRecord } from "@stamp/shared"; import type { StampWithStatus, RedemptionRecord } from "@stamp/shared";
import { apiFetch } from "../lib/api"; import { apiFetch } from "../lib/api";
import { useAuth } from "../lib/auth"; import { useAuth } from "../lib/auth";
import StampGrid from "../components/StampGrid"; import StampGrid from "../components/StampGrid";
@@ -11,27 +11,25 @@ export default function AlbumPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user, isLoading: authLoading } = useAuth(); const { user, isLoading: authLoading } = useAuth();
const [stamps, setStamps] = useState<StampWithStatus[]>([]); const [stamps, setStamps] = useState<StampWithStatus[]>([]);
const [rules, setRules] = useState<RedemptionRuleInfo[]>([]);
const [history, setHistory] = useState<RedemptionRecord[]>([]); const [history, setHistory] = useState<RedemptionRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showRedeem, setShowRedeem] = useState(false); const [selectedStampId, setSelectedStampId] = useState<string | null>(null);
const [showRegister, setShowRegister] = useState(false); const [showRegister, setShowRegister] = useState(false);
const collectedCount = stamps.filter((s) => s.collected).length; const collectedCount = stamps.filter((s) => s.collected).length;
const selectedStamp = selectedStampId ? stamps.find((s) => s.id === selectedStampId) ?? null : null;
const fetchData = async () => { const fetchData = async () => {
setLoading(true); setLoading(true);
try { try {
const [stampsData, rulesData] = await Promise.all([ const stampsData = await apiFetch<StampWithStatus[]>("/stamps");
apiFetch<StampWithStatus[]>("/stamps"),
apiFetch<RedemptionRuleInfo[]>("/redemption/rules"),
]);
setStamps(stampsData); setStamps(stampsData);
setRules(rulesData);
if (user) { if (user) {
const historyData = await apiFetch<RedemptionRecord[]>("/redemption/history"); const historyData = await apiFetch<RedemptionRecord[]>("/redemption/history");
setHistory(historyData); setHistory(historyData);
} else {
setHistory([]);
} }
} catch { } catch {
// Stamps endpoint works without auth // Stamps endpoint works without auth
@@ -44,20 +42,21 @@ export default function AlbumPage() {
if (!authLoading) fetchData(); if (!authLoading) fetchData();
}, [authLoading, user]); }, [authLoading, user]);
const handleRedeem = async (ruleId: string) => { const handleRedeem = async (stampId: string) => {
await apiFetch("/redemption/redeem", { await apiFetch("/redemption/redeem", {
method: "POST", method: "POST",
body: JSON.stringify({ ruleId }), body: JSON.stringify({ stampId }),
}); });
await fetchData(); await fetchData();
}; };
const handleRedeemClick = () => { const handleStampClick = (stamp: StampWithStatus) => {
if (!user) { if (!user) {
setShowRegister(true); setShowRegister(true);
return; return;
} }
setShowRedeem(true); if (!stamp.collected) return;
setSelectedStampId(stamp.id);
}; };
if (loading || authLoading) { if (loading || authLoading) {
@@ -108,40 +107,18 @@ export default function AlbumPage() {
style={{ width: stamps.length ? `${(collectedCount / stamps.length) * 100}%` : "0%" }} style={{ width: stamps.length ? `${(collectedCount / stamps.length) * 100}%` : "0%" }}
/> />
</div> </div>
{collectedCount > 0 && (
<p className="mt-3 text-[11px] text-[var(--text-muted)] leading-relaxed">
</p>
)}
</div> </div>
{/* Stamp Grid */} {/* Stamp Grid */}
<div className="px-4 pb-6"> <div className="px-4 pb-6">
<StampGrid stamps={stamps} /> <StampGrid stamps={stamps} onStampClick={handleStampClick} />
</div> </div>
{/* Redeem Section */}
{rules.length > 0 && (() => {
const availableCount = rules.filter((r) => collectedCount >= r.threshold).length;
const canRedeem = availableCount > 0;
return (
<div className="px-6 pb-6">
<button
onClick={handleRedeemClick}
disabled={!canRedeem}
className="w-full py-3.5 rounded-xl text-sm font-medium transition-all disabled:cursor-not-allowed flex items-center justify-center gap-2"
style={{
backgroundColor: canRedeem ? "var(--jade)" : "var(--border-muted)",
color: canRedeem ? "white" : "var(--text-muted)",
boxShadow: canRedeem ? "0 2px 8px rgba(45,106,79,0.25)" : "none",
}}
>
<span>{canRedeem ? "兑换奖品" : "继续收集以解锁奖品"}</span>
{canRedeem && (
<span className="text-xs px-2 py-0.5 rounded-full bg-white/20">
{availableCount}
</span>
)}
</button>
</div>
);
})()}
{/* Redemption History */} {/* Redemption History */}
{history.length > 0 && ( {history.length > 0 && (
<div className="px-6 pb-8"> <div className="px-6 pb-8">
@@ -149,13 +126,13 @@ export default function AlbumPage() {
<div className="space-y-2"> <div className="space-y-2">
{history.map((r) => ( {history.map((r) => (
<div key={r.id} className="flex items-center justify-between py-2 px-3 bg-white/60 rounded-lg"> <div key={r.id} className="flex items-center justify-between py-2 px-3 bg-white/60 rounded-lg">
<div> <div className="min-w-0">
<p className="text-sm text-[var(--text-primary)]">{r.ruleName}</p> <p className="text-sm text-[var(--text-primary)] truncate">{r.prizeName}</p>
<p className="text-xs text-[var(--text-muted)]"> <p className="text-xs text-[var(--text-muted)]">
{new Date(r.redeemedAt).toLocaleDateString("zh-CN")} {r.stampName} · {new Date(r.redeemedAt).toLocaleDateString("zh-CN")}
</p> </p>
</div> </div>
<span className="text-xs text-[var(--jade)]"></span> <span className="text-xs text-[var(--jade)] shrink-0 ml-3"></span>
</div> </div>
))} ))}
</div> </div>
@@ -163,12 +140,11 @@ export default function AlbumPage() {
)} )}
{/* Modals */} {/* Modals */}
{showRedeem && ( {selectedStamp && (
<RedeemModal <RedeemModal
rules={rules} stamp={selectedStamp}
collectedCount={collectedCount}
onRedeem={handleRedeem} onRedeem={handleRedeem}
onClose={() => setShowRedeem(false)} onClose={() => setSelectedStampId(null)}
/> />
)} )}

View File

@@ -0,0 +1,60 @@
-- CreateTable
CREATE TABLE "Prize" (
"id" TEXT NOT NULL PRIMARY KEY,
"stampId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"stock" INTEGER NOT NULL DEFAULT 0,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Prize_stampId_fkey" FOREIGN KEY ("stampId") REFERENCES "Stamp" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Prize_stampId_key" ON "Prize"("stampId");
-- Backfill: create a default Prize for every existing Stamp
INSERT INTO "Prize" ("id", "stampId", "name", "description", "stock", "enabled", "createdAt", "updatedAt")
SELECT
lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-' || lower(hex(randomblob(2))) || '-' || lower(hex(randomblob(2))) || '-' || lower(hex(randomblob(6))),
s."id",
s."name" || ' · 纪念章',
'在「' || s."name" || '」集到的专属纪念奖品',
100,
true,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "Stamp" s;
-- Drop legacy rows: old Redemption records (ruleId/stampCount model) cannot be mapped to the new
-- one-stamp-one-prize schema, and RedemptionRule is being retired. Intentional data loss.
DELETE FROM "Redemption";
DELETE FROM "RedemptionRule";
-- RedefineTable Redemption
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Redemption" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"stampId" TEXT NOT NULL,
"prizeId" TEXT NOT NULL,
"prizeName" TEXT 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_stampId_fkey" FOREIGN KEY ("stampId") REFERENCES "Stamp" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Redemption_prizeId_fkey" FOREIGN KEY ("prizeId") REFERENCES "Prize" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
DROP TABLE "Redemption";
ALTER TABLE "new_Redemption" RENAME TO "Redemption";
CREATE UNIQUE INDEX "Redemption_userId_stampId_key" ON "Redemption"("userId", "stampId");
CREATE INDEX "Redemption_userId_idx" ON "Redemption"("userId");
-- DropTable (now safe, no more FK references)
DROP TABLE "RedemptionRule";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -29,6 +29,21 @@ model Stamp {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
collections Collection[] collections Collection[]
redemptions Redemption[]
prize Prize?
}
model Prize {
id String @id @default(uuid())
stampId String @unique
name String
description String?
stock Int @default(0)
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
stamp Stamp @relation(fields: [stampId], references: [id], onDelete: Cascade)
redemptions Redemption[]
} }
model Collection { model Collection {
@@ -43,27 +58,18 @@ model Collection {
@@index([userId]) @@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 { model Redemption {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
ruleId String stampId String
stampCount Int prizeId String
redeemedAt DateTime @default(now()) prizeName String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) redeemedAt DateTime @default(now())
rule RedemptionRule @relation(fields: [ruleId], references: [id]) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
stamp Stamp @relation(fields: [stampId], references: [id])
prize Prize @relation(fields: [prizeId], references: [id])
@@unique([userId, stampId])
@@index([userId]) @@index([userId])
} }