feat: 落地页改造 + 集章弹窗全状态品牌/奖品说明

- 落地页:顶部改为活动海报,底部替换为「活动规则」5 条编号列表
- 集章收集弹窗 (StampPopup):新增奖品规则卡片展示 Prize 信息
- 集章册 (AlbumPage / StampGrid):所有状态图章均可点击查看详情
- 兑换弹窗 (RedeemModal):新增 uncollected 分支,统一承载未收集/
  已集齐/已兑换三种状态;新增可选品牌说明区
- 后端 /api/stamps/:id 补充返回 prize 字段
- 管理后台字段标签改名:备注 → 品牌说明;奖品描述 → 奖品说明
- 新增一次性脚本 update-brand-rules,批量写入 16 条品牌权益文案

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 17:16:50 +08:00
parent 394b643304
commit 2c179cd19a
11 changed files with 296 additions and 161 deletions

View File

@@ -9,7 +9,8 @@
"start": "node dist/index.js",
"seed": "tsx src/seed.ts",
"seed-articles": "tsx src/seed-articles.ts",
"seed-music": "tsx src/seed-music.ts"
"seed-music": "tsx src/seed-music.ts",
"update-brand-rules": "tsx src/scripts/update-brand-rules.ts"
},
"dependencies": {
"@stamp/shared": "workspace:*",

View File

@@ -54,7 +54,10 @@ router.get("/", optionalAuth, async (req, res) => {
});
router.get("/:id", async (req, res) => {
const stamp = await prisma.stamp.findUnique({ where: { id: req.params.id } });
const stamp = await prisma.stamp.findUnique({
where: { id: req.params.id },
include: { prize: true },
});
if (!stamp) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "图章不存在" } });
return;
@@ -68,6 +71,15 @@ router.get("/:id", async (req, res) => {
imageColor: stamp.imageColor,
imageGrey: stamp.imageGrey,
sortOrder: stamp.sortOrder,
prize: stamp.prize
? {
id: stamp.prize.id,
name: stamp.prize.name,
description: stamp.prize.description,
stock: stamp.prize.stock,
enabled: stamp.prize.enabled,
}
: null,
},
});
});

View File

@@ -0,0 +1,125 @@
import { prisma } from "@stamp/shared";
type Rule = { matchKey: string; canonicalName: string; description: string };
const RULES: Rule[] = [
{ matchKey: "孟令军", canonicalName: "孟令军炒货",
description: "进店消费 19.9 元,可赠送人气爆款谷物锅巴 1 份。" },
{ matchKey: "春山", canonicalName: "春山酒窖",
description: "全场产品 85 折优惠。" },
{ matchKey: "金陵绣男", canonicalName: "金陵绣男",
description: "全场产品 8 折优惠。" },
{ matchKey: "陶玉梅", canonicalName: "南京陶玉梅服饰(梦幻城店)",
description: "可获得「非遗宋锦书签制作」体验券 1 张。" },
{ matchKey: "LBZ", canonicalName: "LBZ 量不准咖啡",
description: "「三元巷」定制咖啡 8 折优惠。" },
{ matchKey: "芳婆", canonicalName: "芳婆糕团",
description: "单笔消费满 10 元,即赠糕点 1 块,每日限量 66 份,送完即止。" },
{ matchKey: "紫金", canonicalName: "紫金农商银行秦淮支行",
description: "到店拍照打卡即可获得「南京市民俗(非遗)博物馆 甘家大院」参观券 1 张。" },
{ matchKey: "尹氏", canonicalName: "尹氏汤包",
description: "可获得「亲子家庭汤包制作活动」体验券 1 张。" },
{ matchKey: "闲鱼", canonicalName: "闲鱼循环商店",
description: "到店参与寄卖服务,即可加盖闲鱼文创纪念章。" },
{ matchKey: "闽南", canonicalName: "闽南茶叶店",
description: "1. 全店茶叶产品 85 折优惠2. 可获得「茶分享和体验活动」体验券 1 张。" },
{ matchKey: "移动", canonicalName: "南京移动朝天宫双塘分局",
description: "1. 免费手机贴膜 1 次仅限直面屏手机2. 免费领取 80G 流量体验卡 1 张3. 购买 AI 手机返 100 元话费。" },
{ matchKey: "二条", canonicalName: "二条商店",
description: "全店当日单次实付满 380 元送二条原创小鲍挂件 1 个。" },
{ matchKey: "书", canonicalName: "锦创书城",
description: "1. 图书与咖啡产品享 7 折,文创产品享 8 折2. 到店打卡赠送定制书签 1 枚3. 可获得书城线下主题活动体验券 1 张。" },
{ matchKey: "魏", canonicalName: "魏虾神",
description: "到店就餐即赠奶皮子酸奶酪 1 份。" },
{ matchKey: "李记", canonicalName: "李记清真馆",
description: "可获得「亲子包锅贴体验活动」体验券 1 张。" },
{ matchKey: "农家", canonicalName: "农家小院",
description: "(小红书打卡、朋友圈转发)可享菜品 8 折优惠。" },
];
async function main() {
const apply = process.argv.includes("--apply");
const stamps = await prisma.stamp.findMany({ include: { prize: true } });
console.log(`Found ${stamps.length} stamps in DB.\n`);
console.log("Current stamp names:");
stamps.forEach((s) => console.log(` - ${s.name}`));
console.log();
type Entry = { stampId: string; old: string; next: string; desc: string };
const plan: Entry[] = [];
const issues: string[] = [];
const usedStampIds = new Set<string>();
for (const rule of RULES) {
const hits = stamps.filter((s) => s.name.includes(rule.matchKey) && !usedStampIds.has(s.id));
if (hits.length === 0) {
issues.push(`❌ "${rule.matchKey}" → 0 matches (target: ${rule.canonicalName})`);
continue;
}
if (hits.length > 1) {
issues.push(
`⚠️ "${rule.matchKey}" → ${hits.length} matches (${hits.map((h) => h.name).join(", ")}) — 使用更精确的 matchKey`,
);
continue;
}
const stamp = hits[0];
usedStampIds.add(stamp.id);
plan.push({ stampId: stamp.id, old: stamp.name, next: rule.canonicalName, desc: rule.description });
}
console.log("=== Match plan ===");
plan.forEach((p) => {
const rename = p.old !== p.next ? ` [RENAME]` : ``;
console.log(` "${p.old}"${rename}`);
console.log(` ⟶ "${p.next}"`);
console.log(` ${p.desc}\n`);
});
if (issues.length) {
console.log("=== Issues ===");
issues.forEach((i) => console.log(` ${i}`));
console.log("\n请修正 matchKey 后重试。");
process.exit(1);
}
const leftover = stamps.filter((s) => !usedStampIds.has(s.id));
if (leftover.length) {
console.log(`=== 未被映射覆盖的图章(保持不变) ===`);
leftover.forEach((s) => console.log(` - ${s.name}`));
console.log();
}
if (!apply) {
console.log(`DRY RUN — 不写库。传入 --apply 执行写入。`);
return;
}
console.log(`正在写入 ${plan.length} 条...\n`);
for (const p of plan) {
await prisma.$transaction(async (tx) => {
if (p.old !== p.next) {
await tx.stamp.update({ where: { id: p.stampId }, data: { name: p.next } });
}
await tx.prize.upsert({
where: { stampId: p.stampId },
create: {
stampId: p.stampId,
name: `${p.next} · 品牌权益`,
description: p.desc,
stock: 100,
enabled: true,
},
update: { description: p.desc },
});
});
console.log(`${p.next}`);
}
console.log(`\n完成。已更新 ${plan.length} 枚图章。`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());