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
CityWalk 图章收集系统 — 移动端 H5 + 管理后台。游客在城市点位扫描二维码收集图章,集满可兑换奖品兑换后图章清空,支持重复收集
CityWalk 图章收集系统 — 移动端 H5 + 管理后台。游客在城市点位扫描二维码收集图章,每枚图章绑定一个特定奖品(带库存),已收集的图章可兑换对应奖品兑换后图章保持彩色点亮并标记为"已兑换",同一图章不可二次收集或二次兑换
## Commands
@@ -17,7 +17,7 @@ pnpm dev:web # Vite dev server on :5173
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)
pnpm db:seed # Seed sample data (16 stamps, each with a Prize of stock 100)
# Build
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)
/admin → AdminLogin
/admin/stamps → Stamp CRUD + QR code generation
/admin/rules → Redemption rule CRUD
/admin/redemptions → Redemption history + stats
```
@@ -58,7 +57,7 @@ QR codes encode `/collect/{stampId}` → redirects to `/?stamp={stampId}` → La
### 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

View File

@@ -32,5 +32,5 @@ packages/
server/ Express API认证、图章、兑换、管理
web/ React SPA移动端 H5 + PC 管理后台)
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 =====
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 });
});
@@ -121,69 +124,58 @@ router.get("/stamps/:id/qrcode", async (req, res) => {
res.json({ success: true, data: { qrDataUrl, collectUrl, stampName: stamp.name } });
});
// ===== Redemption Rules CRUD =====
// ===== Prize (per-stamp) =====
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({
const prizeSchema = z.object({
name: z.string().min(1, "奖品名称不能为空"),
description: z.string().optional(),
threshold: z.number().int().min(1, "兑换门槛至少为 1"),
stock: z.number().int().min(0, "库存不能为负数"),
enabled: z.boolean().optional(),
sortOrder: z.number().int().optional(),
});
router.post("/rules", async (req, res) => {
const parsed = ruleSchema.safeParse(req.body);
router.put("/stamps/:id/prize", async (req, res) => {
const parsed = prizeSchema.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: {
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 data = {
name: parsed.data.name,
description: parsed.data.description,
threshold: parsed.data.threshold,
description: parsed.data.description ?? null,
stock: parsed.data.stock,
enabled: parsed.data.enabled ?? true,
sortOrder: parsed.data.sortOrder ?? 0,
},
};
const prize = await prisma.prize.upsert({
where: { stampId: stamp.id },
create: { stampId: stamp.id, ...data },
update: data,
});
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 });
res.json({ success: true, data: prize });
});
// ===== 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 } } },
include: {
user: { select: { username: true, phone: true } },
stamp: { select: { name: true } },
},
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) => {

View File

@@ -5,17 +5,8 @@ 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 格式不正确"),
stampId: z.string().uuid("图章 ID 格式不正确"),
});
router.post("/redeem", requireAuth, async (req, res) => {
@@ -25,64 +16,94 @@ router.post("/redeem", requireAuth, async (req, res) => {
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 { stampId } = parsed.data;
const userId = req.userId!;
try {
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 },
const collection = await tx.collection.findUnique({
where: { userId_stampId: { userId, stampId } },
});
await tx.collection.deleteMany({
where: { id: { in: toDelete.map((c) => c.id) } },
if (!collection) {
throw new RedeemError("NOT_COLLECTED", "你还没有收集这枚图章", 400);
}
const already = await tx.redemption.findUnique({
where: { userId_stampId: { userId, stampId } },
});
const record = await tx.redemption.create({
data: { userId: req.userId!, ruleId: rule.id, stampCount: rule.threshold },
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 } } },
});
return record;
});
res.json({
success: true,
data: {
id: redemption.id,
ruleName: rule.name,
stampCount: redemption.stampCount,
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;
}
});
router.get("/history", requireAuth, async (req, res) => {
const records = await prisma.redemption.findMany({
where: { userId: req.userId! },
include: { rule: { select: { name: true } } },
include: { stamp: { select: { name: true } } },
orderBy: { redeemedAt: "desc" },
});
const data = records.map((r) => ({
id: r.id,
ruleName: r.rule.name,
stampCount: r.stampCount,
stampId: r.stampId,
stampName: r.stamp.name,
prizeName: r.prizeName,
redeemedAt: r.redeemedAt.toISOString(),
}));
res.json({ success: true, data });
});
class RedeemError extends Error {
constructor(
public code: string,
message: string,
public status: number,
) {
super(message);
}
}
export default router;

View File

@@ -8,20 +8,25 @@ router.get("/", optionalAuth, async (req, res) => {
const stamps = await prisma.stamp.findMany({
where: { enabled: true },
orderBy: { sortOrder: "asc" },
include: { prize: true },
});
let collections: Set<string> = new Set();
let collectionMap: Map<string, Date> = new Map();
const collectionMap = new Map<string, Date>();
const redeemedSet = new Set<string>();
if (req.userId) {
const userCollections = await prisma.collection.findMany({
const [userCollections, userRedemptions] = await Promise.all([
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);
});
}),
prisma.redemption.findMany({
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) => ({
@@ -31,8 +36,18 @@ router.get("/", optionalAuth, async (req, res) => {
imageColor: s.imageColor,
imageGrey: s.imageGrey,
sortOrder: s.sortOrder,
collected: collections.has(s.id),
collected: collectionMap.has(s.id),
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 });

View File

@@ -22,7 +22,7 @@ const stampData = [
async function seed() {
console.log("Seeding database...");
// Clear existing stamps (cascades to collections)
// Clear existing stamps (cascades to collections + prize)
await prisma.stamp.deleteMany();
const stamps = await Promise.all(
@@ -34,34 +34,20 @@ async function seed() {
imageColor: `/uploads/stamps/stamp-${pos}-color.jpg`,
imageGrey: `/uploads/stamps/stamp-${pos}-grey.jpg`,
sortOrder: idx + 1,
prize: {
create: {
name: `${s.name} · 纪念章`,
description: `在「${s.name}」集到的专属纪念奖品`,
stock: 100,
enabled: true,
},
},
},
});
}),
);
console.log(`Created ${stamps.length} stamps`);
// 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(`Created ${stamps.length} stamps with prizes`);
console.log("\nStamp IDs for testing:");
stamps.forEach((s) => {

View File

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

View File

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

View File

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

View File

@@ -5,11 +5,10 @@ import { TableCard, TableHeadRow } from "./StampList";
type RedemptionRecord = {
id: string;
userId: string;
stampCount: number;
redeemedAt: string;
user: { username: string; phone: string };
rule: { name: string };
stampName: string;
prizeName: string;
};
type Stats = {
@@ -44,7 +43,7 @@ export default function RedemptionLog() {
return (
<>
<PageHeader
eyebrow="07 · Log"
eyebrow="06 · Log"
title="兑换记录"
caption="账户、图章收集与兑换的完整轨迹"
/>
@@ -119,7 +118,7 @@ export default function RedemptionLog() {
) : (
<table className="w-full">
<thead>
<TableHeadRow cols={["用户", "手机号", "兑换奖品", "扣除枚数", "时间"]} />
<TableHeadRow cols={["用户", "手机号", "图章", "奖品", "时间"]} />
</thead>
<tbody>
{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>
</td>
<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 className="px-5 py-4 text-center w-[140px]">
<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 className="px-5 py-4">
<span className="text-sm text-[var(--text-primary)] font-medium">{r.prizeName}</span>
</td>
<td className="px-5 py-4 text-right w-[200px]">
<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 { Field, ErrorRow, FormFooter, HintRow, fieldCls } from "./FormPrimitives";
type Prize = {
id: string;
name: string;
description: string | null;
stock: number;
enabled: boolean;
};
type Stamp = {
id: string;
name: string;
@@ -12,6 +20,7 @@ type Stamp = {
imageGrey: string;
sortOrder: number;
enabled: boolean;
prize: Prize | null;
};
type Props = {
@@ -32,6 +41,11 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
const [saving, setSaving] = useState(false);
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;
useEffect(() => {
@@ -44,6 +58,10 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
setSortOrder(0);
setImageColor("");
setImageGrey("");
setPrizeName("");
setPrizeDescription("");
setPrizeStock(0);
setPrizeEnabled(true);
return;
}
adminFetch<Stamp[]>("/stamps").then((stamps) => {
@@ -54,6 +72,17 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
setSortOrder(stamp.sortOrder);
setImageColor(stamp.imageColor);
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]);
@@ -87,6 +116,10 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
setError("请输入图章名称");
return;
}
if (isEdit && prizeName.trim() && prizeStock < 0) {
setError("库存不能为负数");
return;
}
setSaving(true);
try {
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",
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("已保存");
onSaved();
onClose();
@@ -104,7 +148,7 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
body: JSON.stringify(payload),
});
setCurrentId(stamp.id);
toast.show("已创建,现在可以上传图片");
toast.show("已创建,现在可以上传图片与配置奖品");
onSaved();
}
} catch (e) {
@@ -121,7 +165,7 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
size="md"
eyebrow={isEdit ? "Edit Stamp" : "New Stamp"}
title={isEdit ? "编辑图章" : "添加图章"}
subtitle={isEdit ? "调整信息上传图片" : "先保存基础信息,再上传图章图片"}
subtitle={isEdit ? "调整信息上传图片并配置关联奖品" : "先保存基础信息,再上传图片与配置奖品"}
>
<div className="px-7 py-6 space-y-5">
<Field label="名称" required>
@@ -153,6 +197,7 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
</Field>
{isEdit ? (
<>
<div className="grid grid-cols-2 gap-5">
<ImageSlot
label="彩色图章"
@@ -167,8 +212,64 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
onUpload={(f) => handleUpload(f, "imageGrey")}
/>
</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} />}

View File

@@ -24,6 +24,13 @@ type Stamp = {
imageGrey: string;
sortOrder: number;
enabled: boolean;
prize: {
id: string;
name: string;
description: string | null;
stock: number;
enabled: boolean;
} | null;
};
export default function StampList() {
@@ -102,7 +109,7 @@ export default function StampList() {
) : (
<table className="w-full">
<thead>
<TableHeadRow cols={["图章", "名称 · 备注", "排序", "状态", "操作"]} />
<TableHeadRow cols={["图章", "名称 · 备注", "奖品 · 库存", "排序", "状态", "操作"]} />
</thead>
<tbody>
{stamps.map((stamp, i) => (
@@ -130,6 +137,32 @@ export default function StampList() {
</p>
)}
</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]">
<span
className="text-[13px] text-[var(--text-secondary)]"

View File

@@ -1,26 +1,34 @@
import { useEffect, useState } from "react";
import type { RedemptionRuleInfo } from "@stamp/shared";
import type { StampWithStatus } from "@stamp/shared";
type RedeemModalProps = {
rules: RedemptionRuleInfo[];
collectedCount: number;
onRedeem: (ruleId: string) => Promise<void>;
stamp: StampWithStatus;
onRedeem: (stampId: string) => Promise<void>;
onClose: () => void;
};
const CONFIRM_COUNTDOWN = 5;
export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }: RedeemModalProps) {
const [redeeming, setRedeeming] = useState<string | null>(null);
const [error, setError] = useState("");
const [confirmRuleId, setConfirmRuleId] = useState<string | null>(null);
type Mode = "redeemed" | "sold-out" | "unavailable" | "ready";
function resolveMode(stamp: StampWithStatus): Mode {
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 [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(() => {
if (!confirmRuleId) return;
if (!confirming) return;
setCountdown(CONFIRM_COUNTDOWN);
const interval = setInterval(() => {
setCountdown((c) => {
@@ -32,39 +40,57 @@ export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }
});
}, 1000);
return () => clearInterval(interval);
}, [confirmRuleId]);
}, [confirming]);
const openConfirm = (ruleId: string) => {
const openConfirm = () => {
if (mode !== "ready") return;
setError("");
setConfirmRuleId(ruleId);
setConfirming(true);
};
const cancelConfirm = () => {
if (redeeming) return;
setConfirmRuleId(null);
setConfirming(false);
};
const doRedeem = async () => {
if (!confirmRule || countdown > 0) return;
setRedeeming(confirmRule.id);
if (countdown > 0 || redeeming || mode !== "ready") return;
setRedeeming(true);
setError("");
try {
await onRedeem(confirmRule.id);
await onRedeem(stamp.id);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "兑换失败");
setConfirmRuleId(null);
setConfirming(false);
} 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 (
<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)" }}
onClick={(e) => {
if (e.target !== e.currentTarget) return;
if (confirmRuleId) return; // Don't dismiss during confirm flow
if (confirming) return;
onClose();
}}
>
@@ -78,57 +104,75 @@ export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }
</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 (
{/* Stamp header */}
<div className="flex items-center gap-4 mb-5">
<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",
}}
className="w-16 h-16 rounded-full bg-white overflow-hidden flex items-center justify-center border border-[var(--border-muted)] shrink-0"
style={{ boxShadow: "0 2px 6px rgba(212,165,116,0.25), inset 0 0 0 1px rgba(212,165,116,0.15)" }}
>
<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>
<img src={stamp.imageColor} alt={stamp.name} className="w-[92%] h-[92%] object-contain" />
</div>
<div className="min-w-0">
<p className="text-[10px] tracking-[0.25em] uppercase text-[var(--gold)] mb-0.5">Stamp</p>
<p className="text-base font-semibold text-[var(--text-primary)] truncate">{stamp.name}</p>
{stamp.collectedAt && (
<p className="text-xs text-[var(--text-muted)] mt-0.5">
{new Date(stamp.collectedAt).toLocaleDateString("zh-CN")}
</p>
)}
</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(rule.id)}
disabled={!canRedeem || !!redeeming}
className="shrink-0 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
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: canRedeem ? "var(--jade)" : "var(--border-muted)",
color: canRedeem ? "white" : "var(--text-muted)",
backgroundColor: buttonBg,
color: buttonColor,
boxShadow: mode === "ready" ? "0 2px 8px rgba(45,106,79,0.25)" : "none",
}}
>
{buttonCopy()}
</button>
</div>
);
})}
</div>
</div>
{/* Confirmation dialog — centered over the sheet, highest priority */}
{confirmRule && (
{/* Confirmation dialog */}
{confirming && prize && (
<div
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)" }}
@@ -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"
style={{ boxShadow: "0 24px 60px rgba(26,26,46,0.35)" }}
>
{/* Warning at the top — most prominent, filled terracotta */}
<div
className="px-5 py-4"
style={{
backgroundColor: "var(--terracotta)",
color: "#fff",
}}
>
{/* Warning */}
<div className="px-5 py-4" style={{ backgroundColor: "var(--terracotta)", color: "#fff" }}>
<div className="flex gap-3">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2"
className="shrink-0 mt-0.5">
<svg
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" />
</svg>
<div className="flex-1">
@@ -160,50 +205,37 @@ export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }
</div>
</div>
{/* Body */}
<div className="px-5 pt-5 pb-5">
<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">
<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>
{confirmRule.description && (
<p className="text-xs text-[var(--text-muted)] mt-0.5">{confirmRule.description}</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-0.5">{prize.description}</p>
)}
</div>
{/* Deduction summary */}
<div className="rounded-xl border border-[var(--border-default)] bg-white p-3.5 mb-2">
<div className="flex items-baseline justify-between">
<span className="text-xs text-[var(--text-muted)]"></span>
<span className="text-xl font-semibold text-[var(--terracotta)]">{confirmRule.threshold}</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 className="text-xs text-[var(--text-muted)] leading-relaxed">
<span className="font-medium text-[var(--text-secondary)]">{stamp.name}</span>
<span className="font-medium text-[var(--jade)]"></span>
</p>
</div>
<p className="text-[11px] text-[var(--text-muted)] text-center mb-4">
</p>
<p className="text-[11px] text-[var(--text-muted)] text-center mb-4"></p>
{/* Buttons */}
<div className="flex gap-2.5">
<button
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"
>
</button>
<button
onClick={doRedeem}
disabled={countdown > 0 || !!redeeming}
disabled={countdown > 0 || redeeming}
className="flex-[1.3] py-3 rounded-xl text-sm font-medium transition-colors"
style={{
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)",
}}
>
{redeeming
? "兑换中..."
: countdown > 0
? `请阅读提示 ${countdown}s`
: "确认兑换"}
{redeeming ? "兑换中..." : countdown > 0 ? `请阅读提示 ${countdown}s` : "确认兑换"}
</button>
</div>
</div>

View File

@@ -3,10 +3,11 @@ type StampCardProps = {
imageColor: string;
imageGrey: string;
collected: boolean;
redeemed?: boolean;
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;
return (
@@ -43,13 +44,22 @@ export default function StampCard({ name, imageColor, imageGrey, collected, onCl
/>
</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">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3">
<polyline points="20 6 9 17 4 12" />
</svg>
</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>
<span

View File

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

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
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 { useAuth } from "../lib/auth";
import StampGrid from "../components/StampGrid";
@@ -11,27 +11,25 @@ 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 [selectedStampId, setSelectedStampId] = useState<string | null>(null);
const [showRegister, setShowRegister] = useState(false);
const collectedCount = stamps.filter((s) => s.collected).length;
const selectedStamp = selectedStampId ? stamps.find((s) => s.id === selectedStampId) ?? null : null;
const fetchData = async () => {
setLoading(true);
try {
const [stampsData, rulesData] = await Promise.all([
apiFetch<StampWithStatus[]>("/stamps"),
apiFetch<RedemptionRuleInfo[]>("/redemption/rules"),
]);
const stampsData = await apiFetch<StampWithStatus[]>("/stamps");
setStamps(stampsData);
setRules(rulesData);
if (user) {
const historyData = await apiFetch<RedemptionRecord[]>("/redemption/history");
setHistory(historyData);
} else {
setHistory([]);
}
} catch {
// Stamps endpoint works without auth
@@ -44,20 +42,21 @@ export default function AlbumPage() {
if (!authLoading) fetchData();
}, [authLoading, user]);
const handleRedeem = async (ruleId: string) => {
const handleRedeem = async (stampId: string) => {
await apiFetch("/redemption/redeem", {
method: "POST",
body: JSON.stringify({ ruleId }),
body: JSON.stringify({ stampId }),
});
await fetchData();
};
const handleRedeemClick = () => {
const handleStampClick = (stamp: StampWithStatus) => {
if (!user) {
setShowRegister(true);
return;
}
setShowRedeem(true);
if (!stamp.collected) return;
setSelectedStampId(stamp.id);
};
if (loading || authLoading) {
@@ -108,40 +107,18 @@ export default function AlbumPage() {
style={{ width: stamps.length ? `${(collectedCount / stamps.length) * 100}%` : "0%" }}
/>
</div>
{collectedCount > 0 && (
<p className="mt-3 text-[11px] text-[var(--text-muted)] leading-relaxed">
</p>
)}
</div>
{/* Stamp Grid */}
<div className="px-4 pb-6">
<StampGrid stamps={stamps} />
<StampGrid stamps={stamps} onStampClick={handleStampClick} />
</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 */}
{history.length > 0 && (
<div className="px-6 pb-8">
@@ -149,13 +126,13 @@ export default function AlbumPage() {
<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>
<div className="min-w-0">
<p className="text-sm text-[var(--text-primary)] truncate">{r.prizeName}</p>
<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>
</div>
<span className="text-xs text-[var(--jade)]"></span>
<span className="text-xs text-[var(--jade)] shrink-0 ml-3"></span>
</div>
))}
</div>
@@ -163,12 +140,11 @@ export default function AlbumPage() {
)}
{/* Modals */}
{showRedeem && (
{selectedStamp && (
<RedeemModal
rules={rules}
collectedCount={collectedCount}
stamp={selectedStamp}
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())
updatedAt DateTime @updatedAt
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 {
@@ -43,27 +58,18 @@ model Collection {
@@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
stampId String
prizeId String
prizeName String
redeemedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
rule RedemptionRule @relation(fields: [ruleId], references: [id])
stamp Stamp @relation(fields: [stampId], references: [id])
prize Prize @relation(fields: [prizeId], references: [id])
@@unique([userId, stampId])
@@index([userId])
}