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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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} />}
|
||||||
|
|||||||
@@ -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)]"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user