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

@@ -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: {
name: parsed.data.name,
description: parsed.data.description,
threshold: parsed.data.threshold,
enabled: parsed.data.enabled ?? true,
sortOrder: parsed.data.sortOrder ?? 0,
},
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 ?? 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 });
});
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 { stampId } = parsed.data;
const userId = req.userId!;
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) => {
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({
where: { userId: req.userId },
select: { stampId: true, collectedAt: true },
});
userCollections.forEach((c) => {
collections.add(c.stampId);
collectionMap.set(c.stampId, c.collectedAt);
});
const [userCollections, userRedemptions] = await Promise.all([
prisma.collection.findMany({
where: { userId: req.userId },
select: { stampId: true, collectedAt: true },
}),
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,22 +197,79 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
</Field>
{isEdit ? (
<div className="grid grid-cols-2 gap-5">
<ImageSlot
label="彩色图章"
kind="color"
image={imageColor}
onUpload={(f) => handleUpload(f, "imageColor")}
/>
<ImageSlot
label="灰色图章"
kind="grey"
image={imageGrey}
onUpload={(f) => handleUpload(f, "imageGrey")}
/>
</div>
<>
<div className="grid grid-cols-2 gap-5">
<ImageSlot
label="彩色图章"
kind="color"
image={imageColor}
onUpload={(f) => handleUpload(f, "imageColor")}
/>
<ImageSlot
label="灰色图章"
kind="grey"
image={imageGrey}
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 (
<div
key={rule.id}
className="flex items-center justify-between p-4 rounded-xl border"
style={{
borderColor: canRedeem ? "var(--jade)" : "var(--border-muted)",
backgroundColor: canRedeem ? "rgba(45, 106, 79, 0.05)" : "white",
}}
>
<div className="flex-1 min-w-0 mr-3">
<p className="text-sm font-medium text-[var(--text-primary)] truncate">
{rule.name}
</p>
{rule.description && (
<p className="text-xs text-[var(--text-muted)] mt-0.5 truncate">{rule.description}</p>
)}
<p className="text-xs text-[var(--text-muted)] mt-1">
{rule.threshold}
</p>
</div>
<button
onClick={() => 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>
);
})}
{/* Stamp header */}
<div className="flex items-center gap-4 mb-5">
<div
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)" }}
>
<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}
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>
{/* 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)}
/>
)}