feat: 导入 16 枚图章并重构图册为 4x4 圆形布局

- 按 A4 排列顺序导入 16 枚真实商户图章(彩色 + 灰色)到数据库
- 图章素材存放于 packages/server/uploads/stamps/,命名与 sortOrder 一致
- 图册页布局由 3 列改为 4 列,StampCard 采用圆形白底容器承托透明圆章
- 去除邮票打孔/方形渐变背景,已收集态增加金色内描边与柔光阴影
- 优化进度区与兑换按钮视觉:突出数字、显示差额提示、禁用态文案

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 14:10:57 +08:00
parent db74381f13
commit 0319557723
69 changed files with 133 additions and 108 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

View File

@@ -1,63 +1,71 @@
import { prisma } from "@stamp/shared"; import { prisma } from "@stamp/shared";
const stampData = [
{ name: "尹氏汤包" },
{ name: "中国移动 5G" },
{ name: "紫金农商银行" },
{ name: "孟令军炒货铺" },
{ name: "春山酒窖" },
{ name: "金陵绣男" },
{ name: "LBZ" },
{ name: "二条商店" },
{ name: "陶玉梅" },
{ name: "芳婆糕团" },
{ name: "书锦城创" },
{ name: "闲鱼循环商店" },
{ name: "闽南茶叶店" },
{ name: "魏鬼虾神" },
{ name: "农家小院" },
{ name: "李记清真馆" },
];
async function seed() { async function seed() {
console.log("Seeding database..."); console.log("Seeding database...");
// Create sample stamps // Clear existing stamps (cascades to collections)
const stamps = await Promise.all([ await prisma.stamp.deleteMany();
prisma.stamp.create({
data: { name: "古桥印记", note: "始建于明代的石拱桥", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 1 }, const stamps = await Promise.all(
stampData.map((s, idx) => {
const pos = String(idx + 1).padStart(2, "0");
return prisma.stamp.create({
data: {
name: s.name,
imageColor: `/uploads/stamps/stamp-${pos}-color.jpg`,
imageGrey: `/uploads/stamps/stamp-${pos}-grey.jpg`,
sortOrder: idx + 1,
},
});
}), }),
prisma.stamp.create({ );
data: { name: "老街风韵", note: "百年历史的商业老街", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 2 },
}),
prisma.stamp.create({
data: { name: "园林雅趣", note: "江南古典园林", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 3 },
}),
prisma.stamp.create({
data: { name: "茶馆时光", note: "百年老茶馆", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 4 },
}),
prisma.stamp.create({
data: { name: "水乡晨曲", note: "清晨的水乡渔市", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 5 },
}),
prisma.stamp.create({
data: { name: "戏台余韵", note: "古戏台与昆曲", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 6 },
}),
prisma.stamp.create({
data: { name: "巷弄深处", note: "青石板铺就的幽深小巷", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 7 },
}),
prisma.stamp.create({
data: { name: "月下拱桥", note: "夜晚灯火映照的拱桥", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 8 },
}),
prisma.stamp.create({
data: { name: "匠心工坊", note: "传统手工艺作坊", imageColor: "/uploads/placeholder-color.svg", imageGrey: "/uploads/placeholder-grey.svg", sortOrder: 9 },
}),
]);
console.log(`Created ${stamps.length} stamps`); console.log(`Created ${stamps.length} stamps`);
// Create redemption rules // Create redemption rules if none exist
const existingRules = await prisma.redemptionRule.count();
if (existingRules === 0) {
const rules = await Promise.all([ const rules = await Promise.all([
prisma.redemptionRule.create({ prisma.redemptionRule.create({
data: { name: "纪念书签", description: "精美城市纪念书签一枚", threshold: 3, sortOrder: 1 }, data: { name: "纪念书签", description: "精美城市纪念书签一枚", threshold: 4, sortOrder: 1 },
}), }),
prisma.redemptionRule.create({ prisma.redemptionRule.create({
data: { name: "手绘明信片套装", description: "一套 5 张手绘城市明信片", threshold: 5, sortOrder: 2 }, data: { name: "手绘明信片套装", description: "一套 5 张手绘城市明信片", threshold: 8, sortOrder: 2 },
}), }),
prisma.redemptionRule.create({ prisma.redemptionRule.create({
data: { name: "限定徽章礼盒", description: "城市限定版金属徽章礼盒", threshold: 7, sortOrder: 3 }, data: { name: "限定徽章礼盒", description: "城市限定版金属徽章礼盒", threshold: 12, sortOrder: 3 },
}), }),
prisma.redemptionRule.create({ prisma.redemptionRule.create({
data: { name: "城市记忆礼包", description: "包含纪念 T 恤、帆布袋和城市画册", threshold: 9, sortOrder: 4 }, data: { name: "城市记忆礼包", description: "包含纪念 T 恤、帆布袋和城市画册", threshold: 16, sortOrder: 4 },
}), }),
]); ]);
console.log(`Created ${rules.length} redemption rules`); console.log(`Created ${rules.length} redemption rules`);
} else {
console.log(`Kept existing ${existingRules} redemption rules`);
}
// Print stamp IDs for QR code testing
console.log("\nStamp IDs for testing:"); console.log("\nStamp IDs for testing:");
stamps.forEach((s) => { stamps.forEach((s) => {
console.log(` ${s.name}: /collect/${s.id}`); console.log(` ${s.sortOrder}. ${s.name}: /collect/${s.id}`);
}); });
console.log("\nSeed complete!"); console.log("\nSeed complete!");

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

View File

@@ -7,58 +7,53 @@ type StampCardProps = {
}; };
export default function StampCard({ name, imageColor, imageGrey, collected, onClick }: StampCardProps) { export default function StampCard({ name, imageColor, imageGrey, collected, onClick }: StampCardProps) {
const src = collected ? imageColor : imageGrey;
return ( return (
<button <button
onClick={onClick} onClick={onClick}
className="flex flex-col items-center gap-2 p-3 rounded-xl transition-transform active:scale-95" className="flex flex-col items-center gap-1.5 p-1 transition-transform active:scale-95"
> >
{/* Stamp image with perforated border effect */} <div className="relative w-full aspect-square">
<div <div
className="relative w-full aspect-square rounded-lg overflow-hidden className="w-full h-full rounded-full bg-white overflow-hidden flex items-center justify-center border border-[var(--border-muted)]"
shadow-[var(--shadow-sm)] border border-[var(--border-muted)]"
style={{ style={{
background: collected boxShadow: collected
? "linear-gradient(135deg, #fdf6ee 0%, #f8eed8 100%)" ? "0 2px 6px rgba(212,165,116,0.25), inset 0 0 0 1px rgba(212,165,116,0.15)"
: "linear-gradient(135deg, #f0f0f0 0%, #e8e8e8 100%)", : "0 1px 3px rgba(0,0,0,0.05)",
}} }}
> >
{/* Perforated edge effect */}
<div className="absolute inset-0 stamp-border pointer-events-none z-10" />
<img <img
src={collected ? imageColor : imageGrey} src={src}
alt={name} alt={name}
className="w-full h-full object-contain p-3" className="w-[92%] h-[92%] object-contain"
style={collected ? {} : { filter: "grayscale(1) opacity(0.4)" }} style={{ opacity: collected ? 1 : 0.55 }}
onError={(e) => { onError={(e) => {
// Fallback for missing images: show placeholder
const target = e.target as HTMLImageElement; const target = e.target as HTMLImageElement;
target.style.display = "none"; target.style.display = "none";
target.parentElement!.innerHTML += ` target.parentElement!.innerHTML += `
<div class="w-full h-full flex items-center justify-center text-3xl" <div class="w-full h-full flex items-center justify-center"
style="color: ${collected ? "var(--gold)" : "var(--text-muted)"}; opacity: ${collected ? 1 : 0.3}"> style="color: var(--text-muted); opacity: 0.3">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/> <circle cx="12" cy="12" r="9"/>
<circle cx="12" cy="12" r="4"/>
</svg> </svg>
</div> </div>
`; `;
}} }}
/> />
</div>
{/* Collected badge */}
{collected && ( {collected && (
<div className="absolute top-1 right-1 w-5 h-5 rounded-full bg-[var(--jade)] flex items-center justify-center"> <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="12" height="12" 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>
)} )}
</div> </div>
{/* Stamp name */}
<span <span
className="text-xs font-medium truncate w-full text-center" className="text-[10px] leading-tight truncate w-full text-center"
style={{ color: collected ? "var(--text-primary)" : "var(--text-muted)" }} style={{ color: collected ? "var(--text-primary)" : "var(--text-muted)" }}
> >
{name} {name}

View File

@@ -8,7 +8,7 @@ type StampGridProps = {
export default function StampGrid({ stamps, onStampClick }: StampGridProps) { export default function StampGrid({ stamps, onStampClick }: StampGridProps) {
return ( return (
<div className="grid grid-cols-3 gap-3 stagger-children"> <div className="grid grid-cols-4 gap-3 stagger-children">
{stamps.map((stamp) => ( {stamps.map((stamp) => (
<StampCard <StampCard
key={stamp.id} key={stamp.id}

View File

@@ -84,14 +84,25 @@ export default function AlbumPage() {
</div> </div>
{/* Progress */} {/* Progress */}
<div className="px-6 pt-5 pb-4"> <div className="px-6 pt-5 pb-5">
<div className="flex items-center justify-between mb-2"> <div className="flex items-end justify-between mb-2">
<span className="text-sm text-[var(--text-secondary)]"></span> <div>
<span className="text-sm font-semibold text-[var(--gold)]"> <div className="text-xs text-[var(--text-muted)] mb-0.5"></div>
{collectedCount} / {stamps.length} <div className="flex items-baseline gap-1">
</span> <span className="text-2xl font-semibold text-[var(--text-primary)]">{collectedCount}</span>
<span className="text-sm text-[var(--text-muted)]">/ {stamps.length}</span>
</div> </div>
<div className="h-2 bg-[var(--border-muted)] rounded-full overflow-hidden"> </div>
{stamps.length > 0 && collectedCount < stamps.length && (
<span className="text-xs text-[var(--text-secondary)] pb-1">
{stamps.length - collectedCount}
</span>
)}
{stamps.length > 0 && collectedCount === stamps.length && (
<span className="text-xs font-medium text-[var(--gold)] pb-1"> </span>
)}
</div>
<div className="h-1.5 bg-[var(--border-muted)] rounded-full overflow-hidden">
<div <div
className="h-full bg-gradient-to-r from-[var(--gold)] to-[var(--terracotta)] rounded-full transition-all duration-500" className="h-full bg-gradient-to-r from-[var(--gold)] to-[var(--terracotta)] rounded-full transition-all duration-500"
style={{ width: stamps.length ? `${(collectedCount / stamps.length) * 100}%` : "0%" }} style={{ width: stamps.length ? `${(collectedCount / stamps.length) * 100}%` : "0%" }}
@@ -105,20 +116,31 @@ export default function AlbumPage() {
</div> </div>
{/* Redeem Section */} {/* Redeem Section */}
{rules.length > 0 && ( {rules.length > 0 && (() => {
const availableCount = rules.filter((r) => collectedCount >= r.threshold).length;
const canRedeem = availableCount > 0;
return (
<div className="px-6 pb-6"> <div className="px-6 pb-6">
<button <button
onClick={handleRedeemClick} onClick={handleRedeemClick}
className="w-full py-3 rounded-xl text-sm font-medium transition-colors" 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={{ style={{
backgroundColor: collectedCount > 0 ? "var(--jade)" : "var(--border-muted)", backgroundColor: canRedeem ? "var(--jade)" : "var(--border-muted)",
color: collectedCount > 0 ? "white" : "var(--text-muted)", color: canRedeem ? "white" : "var(--text-muted)",
boxShadow: canRedeem ? "0 2px 8px rgba(45,106,79,0.25)" : "none",
}} }}
> >
({rules.filter((r) => collectedCount >= r.threshold).length} ) <span>{canRedeem ? "兑换奖品" : "继续收集以解锁奖品"}</span>
{canRedeem && (
<span className="text-xs px-2 py-0.5 rounded-full bg-white/20">
{availableCount}
</span>
)}
</button> </button>
</div> </div>
)} );
})()}
{/* Redemption History */} {/* Redemption History */}
{history.length > 0 && ( {history.length > 0 && (