Compare commits

..

2 Commits

Author SHA1 Message Date
f87d16021e feat: 移动端奖品卡片恢复库存显示与奖品名称
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 20:18:04 +08:00
3b3878ea5c refactor: 奖品命名去除"纪念章"字样
奖品不一定是纪念章(可能是实物、折扣券、体验券等),
统一改为"XX · 专属奖品"。新增一次性数据清理脚本,
同步改写存量 Prize 与 Redemption 历史快照。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 22:10:38 +08:00
5 changed files with 108 additions and 9 deletions

View File

@@ -0,0 +1,73 @@
import { prisma } from "@stamp/shared";
const OLD_SUFFIX = "· 纪念章";
const NEW_SUFFIX = "· 专属奖品";
const OLD_DESC_RE = /^在「(.+)」集到的专属纪念奖品$/;
const NEW_DESC = (brand: string) => `在「${brand}」可兑换的专属奖品`;
async function main() {
const apply = process.argv.includes("--apply");
const prizes = await prisma.prize.findMany();
const prizeNameHits = prizes.filter((p) => p.name.endsWith(OLD_SUFFIX));
const prizeDescHits = prizes.filter((p) => p.description && OLD_DESC_RE.test(p.description));
const redemptions = await prisma.redemption.findMany();
const redemptionHits = redemptions.filter((r) => r.prizeName.endsWith(OLD_SUFFIX));
console.log(`=== Prize.name (${prizeNameHits.length}) ===`);
prizeNameHits.slice(0, 20).forEach((p) => {
const next = p.name.slice(0, -OLD_SUFFIX.length) + NEW_SUFFIX;
console.log(` "${p.name}" → "${next}"`);
});
if (prizeNameHits.length > 20) console.log(` ...共 ${prizeNameHits.length}`);
console.log(`\n=== Prize.description (${prizeDescHits.length}) ===`);
prizeDescHits.slice(0, 20).forEach((p) => {
const m = p.description!.match(OLD_DESC_RE)!;
console.log(` "${p.description}" → "${NEW_DESC(m[1])}"`);
});
if (prizeDescHits.length > 20) console.log(` ...共 ${prizeDescHits.length}`);
console.log(`\n=== Redemption.prizeName (${redemptionHits.length}) ===`);
redemptionHits.slice(0, 20).forEach((r) => {
const next = r.prizeName.slice(0, -OLD_SUFFIX.length) + NEW_SUFFIX;
console.log(` "${r.prizeName}" → "${next}"`);
});
if (redemptionHits.length > 20) console.log(` ...共 ${redemptionHits.length}`);
const total = prizeNameHits.length + prizeDescHits.length + redemptionHits.length;
if (total === 0) {
console.log("\n✓ 没有需要替换的记录。");
return;
}
if (!apply) {
console.log(`\nDRY RUN — 共 ${total} 条待改。传入 --apply 执行写入。`);
return;
}
console.log(`\n正在写入...`);
await prisma.$transaction(async (tx) => {
for (const p of prizeNameHits) {
const next = p.name.slice(0, -OLD_SUFFIX.length) + NEW_SUFFIX;
await tx.prize.update({ where: { id: p.id }, data: { name: next } });
}
for (const p of prizeDescHits) {
const m = p.description!.match(OLD_DESC_RE)!;
await tx.prize.update({ where: { id: p.id }, data: { description: NEW_DESC(m[1]) } });
}
for (const r of redemptionHits) {
const next = r.prizeName.slice(0, -OLD_SUFFIX.length) + NEW_SUFFIX;
await tx.redemption.update({ where: { id: r.id }, data: { prizeName: next } });
}
});
console.log(`\n完成。已更新 ${total} 条。`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@@ -36,8 +36,8 @@ async function seed() {
sortOrder: idx + 1, sortOrder: idx + 1,
prize: { prize: {
create: { create: {
name: `${s.name} · 纪念章`, name: `${s.name} · 专属奖品`,
description: `在「${s.name}集到的专属纪念奖品`, description: `在「${s.name}可兑换的专属奖品`,
stock: 100, stock: 100,
enabled: true, enabled: true,
}, },

View File

@@ -229,7 +229,7 @@ export default function StampForm({ open, id, onClose, onSaved }: Props) {
<input <input
value={prizeName} value={prizeName}
onChange={(e) => setPrizeName(e.target.value)} onChange={(e) => setPrizeName(e.target.value)}
placeholder="如:朝天宫纪念书签" placeholder="如:品牌 8 折券 / 定制书签"
className={fieldCls} className={fieldCls}
/> />
</Field> </Field>

View File

@@ -151,11 +151,20 @@ export default function RedeemModal({ stamp, onRedeem, onClose }: RedeemModalPro
{prize ? ( {prize ? (
<div className="rounded-xl border border-[var(--jade)] bg-[rgba(45,106,79,0.05)] p-4 mb-4"> <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-2">Reward</p> <p className="text-[10px] tracking-[0.2em] text-[var(--jade)] uppercase mb-2">Reward</p>
{prize.description ? ( <p className="text-sm font-medium text-[var(--text-primary)] mb-1">{prize.name}</p>
<p className="text-sm text-[var(--text-primary)] leading-relaxed">{prize.description}</p> {prize.description && (
) : ( <p className="text-sm text-[var(--text-secondary)] leading-relaxed">{prize.description}</p>
<p className="text-sm text-[var(--text-muted)]"></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-sm 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>
) : ( ) : (
<div className="rounded-xl border border-[var(--border-muted)] bg-white p-4 mb-4 text-center"> <div className="rounded-xl border border-[var(--border-muted)] bg-white p-4 mb-4 text-center">
@@ -171,6 +180,9 @@ export default function RedeemModal({ stamp, onRedeem, onClose }: RedeemModalPro
{mode === "redeemed" && ( {mode === "redeemed" && (
<p className="text-xs text-[var(--text-muted)] text-center mb-4"></p> <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>} {error && <p className="text-sm text-[var(--terracotta)] mb-3 text-center">{error}</p>}
@@ -227,6 +239,7 @@ export default function RedeemModal({ stamp, onRedeem, onClose }: RedeemModalPro
<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-2">Reward</p> <p className="text-[10px] tracking-[0.2em] text-[var(--jade)] uppercase mb-2">Reward</p>
<p className="text-sm font-medium text-[var(--text-primary)] mb-1">{prize.name}</p>
{prize.description && ( {prize.description && (
<p className="text-sm text-[var(--text-primary)] leading-relaxed">{prize.description}</p> <p className="text-sm text-[var(--text-primary)] leading-relaxed">{prize.description}</p>
)} )}

View File

@@ -49,10 +49,23 @@ export default function StampPopup({ name, imageColor, note, prize, status, onCo
{note && <p className="text-xs text-[var(--text-muted)] mb-3 leading-relaxed">{note}</p>} {note && <p className="text-xs text-[var(--text-muted)] mb-3 leading-relaxed">{note}</p>}
{/* Prize rule (preview only) */} {/* Prize rule (preview only) */}
{status === "preview" && prize?.description && ( {status === "preview" && prize && (
<div className="mt-3 mb-1 rounded-xl border border-[var(--jade)] bg-[rgba(45,106,79,0.05)] p-3.5 text-left"> <div className="mt-3 mb-1 rounded-xl border border-[var(--jade)] bg-[rgba(45,106,79,0.05)] p-3.5 text-left">
<p className="text-[10px] tracking-[0.2em] text-[var(--jade)] uppercase mb-2">Reward</p> <p className="text-[10px] tracking-[0.2em] text-[var(--jade)] uppercase mb-2">Reward</p>
<p className="text-sm text-[var(--text-primary)] leading-relaxed">{prize.description}</p> <p className="text-sm font-medium text-[var(--text-primary)] mb-1">{prize.name}</p>
{prize.description && (
<p className="text-sm text-[var(--text-secondary)] leading-relaxed">{prize.description}</p>
)}
<div className="mt-2.5 pt-2.5 border-t border-[var(--jade)]/15 flex items-baseline justify-between">
<span className="text-xs text-[var(--text-muted)]"></span>
<span
className="text-sm 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>
)} )}