Compare commits

..

9 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
f84815611d feat: dev 2026-04-20 18:06:35 +08:00
f2c71ff91a feat: 新增视频播放页面
- 新增 /video/:id 路由与 VideoPage 组件
- 视频文件通过 /uploads/videos/{id}.mp4 静态访问
- .gitignore 排除 videos 目录,避免大文件入库

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 17:34:03 +08:00
bcb167b67d refactor: 奖品卡片仅展示说明文案,移动端隐藏库存显示
- StampPopup / RedeemModal 的 Reward 卡片去掉 prize.name 行,
  将 description 作为主要文案展示
- 移动端 RedeemModal 隐藏「剩余库存 X 枚」与「奖品库存已耗尽」
  文案,库存扣减与 sold-out 状态的后端逻辑保持不变
- 确认兑换弹窗内的 Reward 卡片同步调整

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:27:46 +08:00
2c179cd19a 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>
2026-04-20 17:16:50 +08:00
394b643304 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>
2026-04-20 15:30:28 +08:00
52169ac71d feat: 新增数据看板与用户管理模块
- 数据看板:注册用户 / 图章收集 / 兑换次数 三张卡片,展示总数及本日 / 本周 / 本月新增
- 时间边界按 Asia/Shanghai 计算,周一为一周起点
- 用户管理:只读列表展示用户名、手机号、已收集、已兑换及注册时间,支持搜索
- 登录后默认跳转到数据看板,侧边栏重新编号为 7 项

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 19:37:05 +08:00
b4a0e23c7e refactor: 重构管理后台为现代化编辑风 UI 并改用模态交互
- 参考收集端落地页的奶油纸质感 + 深海蓝侧栏 + Playfair Display + 金/陶/玉配色,重塑整体视觉
- 编辑、二维码从跳转路由改为模态弹窗,新增"复制链接"快捷操作
- 抽取 Modal / Toast / QRCodeModal / PageHeader / FormPrimitives 通用基建
- 合并三份 QRCode 页面为统一组件,精简路由配置

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 19:18:37 +08:00
47 changed files with 3113 additions and 1727 deletions

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@ dist/
.env .env
uploads/* uploads/*
!uploads/.gitkeep !uploads/.gitkeep
packages/server/uploads/videos/
.DS_Store .DS_Store
*.tsbuildinfo *.tsbuildinfo
.claude/settings.local.json .claude/settings.local.json

View File

@@ -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

View File

@@ -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
``` ```

View File

@@ -10,7 +10,8 @@
"db:push": "prisma db push", "db:push": "prisma db push",
"db:seed": "pnpm --filter @stamp/server seed", "db:seed": "pnpm --filter @stamp/server seed",
"db:seed-articles": "pnpm --filter @stamp/server seed-articles", "db:seed-articles": "pnpm --filter @stamp/server seed-articles",
"db:seed-music": "pnpm --filter @stamp/server seed-music" "db:seed-music": "pnpm --filter @stamp/server seed-music",
"db:update-brand-rules": "pnpm --filter @stamp/server update-brand-rules"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"

View File

@@ -9,7 +9,8 @@
"start": "node dist/index.js", "start": "node dist/index.js",
"seed": "tsx src/seed.ts", "seed": "tsx src/seed.ts",
"seed-articles": "tsx src/seed-articles.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": { "dependencies": {
"@stamp/shared": "workspace:*", "@stamp/shared": "workspace:*",

View File

@@ -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) => {
@@ -195,6 +187,71 @@ router.get("/stats", async (_req, res) => {
res.json({ success: true, data: { userCount, collectionCount, redemptionCount } }); res.json({ success: true, data: { userCount, collectionCount, redemptionCount } });
}); });
// Asia/Shanghai 边界UTC+8无夏令时
function shanghaiBoundaries() {
const OFFSET_MS = 8 * 60 * 60 * 1000;
const nowShanghai = new Date(Date.now() + OFFSET_MS);
const y = nowShanghai.getUTCFullYear();
const m = nowShanghai.getUTCMonth();
const d = nowShanghai.getUTCDate();
const weekdaySun0 = nowShanghai.getUTCDay();
const mondayOffset = (weekdaySun0 + 6) % 7;
const startOfToday = new Date(Date.UTC(y, m, d) - OFFSET_MS);
const startOfWeek = new Date(startOfToday.getTime() - mondayOffset * 86_400_000);
const startOfMonth = new Date(Date.UTC(y, m, 1) - OFFSET_MS);
return { startOfToday, startOfWeek, startOfMonth };
}
router.get("/dashboard", async (_req, res) => {
const { startOfToday, startOfWeek, startOfMonth } = shanghaiBoundaries();
const [
uTotal, uDay, uWeek, uMonth,
cTotal, cDay, cWeek, cMonth,
rTotal, rDay, rWeek, rMonth,
] = await Promise.all([
prisma.user.count(),
prisma.user.count({ where: { createdAt: { gte: startOfToday } } }),
prisma.user.count({ where: { createdAt: { gte: startOfWeek } } }),
prisma.user.count({ where: { createdAt: { gte: startOfMonth } } }),
prisma.collection.count(),
prisma.collection.count({ where: { collectedAt: { gte: startOfToday } } }),
prisma.collection.count({ where: { collectedAt: { gte: startOfWeek } } }),
prisma.collection.count({ where: { collectedAt: { gte: startOfMonth } } }),
prisma.redemption.count(),
prisma.redemption.count({ where: { redeemedAt: { gte: startOfToday } } }),
prisma.redemption.count({ where: { redeemedAt: { gte: startOfWeek } } }),
prisma.redemption.count({ where: { redeemedAt: { gte: startOfMonth } } }),
]);
res.json({
success: true,
data: {
users: { total: uTotal, today: uDay, thisWeek: uWeek, thisMonth: uMonth },
collections: { total: cTotal, today: cDay, thisWeek: cWeek, thisMonth: cMonth },
redemptions: { total: rTotal, today: rDay, thisWeek: rWeek, thisMonth: rMonth },
},
});
});
router.get("/users", async (_req, res) => {
const users = await prisma.user.findMany({
orderBy: { createdAt: "desc" },
include: {
_count: { select: { collections: true, redemptions: true } },
},
});
const data = users.map((u) => ({
id: u.id,
username: u.username,
phone: u.phone,
createdAt: u.createdAt,
collectionCount: u._count.collections,
redemptionCount: u._count.redemptions,
}));
res.json({ success: true, data });
});
// ===== Articles CRUD ===== // ===== Articles CRUD =====
router.get("/articles", async (_req, res) => { router.get("/articles", async (_req, res) => {

View File

@@ -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;

View File

@@ -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,15 +36,28 @@ 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 });
}); });
router.get("/:id", 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) { if (!stamp) {
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "图章不存在" } }); res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "图章不存在" } });
return; return;
@@ -53,6 +71,15 @@ router.get("/:id", async (req, res) => {
imageColor: stamp.imageColor, imageColor: stamp.imageColor,
imageGrey: stamp.imageGrey, imageGrey: stamp.imageGrey,
sortOrder: stamp.sortOrder, 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,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

@@ -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());

View File

@@ -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) => {

View File

@@ -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;
}; };

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

View File

@@ -4,20 +4,15 @@ import LandingPage from "./pages/LandingPage";
import AlbumPage from "./pages/AlbumPage"; import AlbumPage from "./pages/AlbumPage";
import ArticlePage from "./pages/ArticlePage"; import ArticlePage from "./pages/ArticlePage";
import MusicPage from "./pages/MusicPage"; import MusicPage from "./pages/MusicPage";
import VideoPage from "./pages/VideoPage";
import AdminLogin from "./admin/AdminLogin"; import AdminLogin from "./admin/AdminLogin";
import AdminGuard from "./admin/AdminGuard"; import AdminGuard from "./admin/AdminGuard";
import AdminLayout from "./admin/AdminLayout"; import AdminLayout from "./admin/AdminLayout";
import Dashboard from "./admin/Dashboard";
import StampList from "./admin/StampList"; import StampList from "./admin/StampList";
import StampForm from "./admin/StampForm";
import StampQRCode from "./admin/StampQRCode";
import ArticleList from "./admin/ArticleList"; import ArticleList from "./admin/ArticleList";
import ArticleForm from "./admin/ArticleForm";
import ArticleQRCode from "./admin/ArticleQRCode";
import MusicList from "./admin/MusicList"; import MusicList from "./admin/MusicList";
import MusicForm from "./admin/MusicForm"; import UsersList from "./admin/UsersList";
import MusicQRCode from "./admin/MusicQRCode";
import RuleList from "./admin/RuleList";
import RuleForm from "./admin/RuleForm";
import RedemptionLog from "./admin/RedemptionLog"; import RedemptionLog from "./admin/RedemptionLog";
function CollectRedirect() { function CollectRedirect() {
@@ -35,26 +30,17 @@ export default function App() {
<Route path="/collect/:stampId" element={<CollectRedirect />} /> <Route path="/collect/:stampId" element={<CollectRedirect />} />
<Route path="/article/:id" element={<ArticlePage />} /> <Route path="/article/:id" element={<ArticlePage />} />
<Route path="/music/:id" element={<MusicPage />} /> <Route path="/music/:id" element={<MusicPage />} />
<Route path="/video/:id" element={<VideoPage />} />
{/* Admin panel */} {/* Admin panel */}
<Route path="/admin" element={<AdminLogin />} /> <Route path="/admin" element={<AdminLogin />} />
<Route element={<AdminGuard />}> <Route element={<AdminGuard />}>
<Route element={<AdminLayout />}> <Route element={<AdminLayout />}>
<Route path="/admin/dashboard" element={<Dashboard />} />
<Route path="/admin/stamps" element={<StampList />} /> <Route path="/admin/stamps" element={<StampList />} />
<Route path="/admin/stamps/new" element={<StampForm />} />
<Route path="/admin/stamps/:id/edit" element={<StampForm />} />
<Route path="/admin/stamps/:id/qrcode" element={<StampQRCode />} />
<Route path="/admin/articles" element={<ArticleList />} /> <Route path="/admin/articles" element={<ArticleList />} />
<Route path="/admin/articles/new" element={<ArticleForm />} />
<Route path="/admin/articles/:id/edit" element={<ArticleForm />} />
<Route path="/admin/articles/:id/qrcode" element={<ArticleQRCode />} />
<Route path="/admin/music" element={<MusicList />} /> <Route path="/admin/music" element={<MusicList />} />
<Route path="/admin/music/new" element={<MusicForm />} /> <Route path="/admin/users" element={<UsersList />} />
<Route path="/admin/music/:id/edit" element={<MusicForm />} />
<Route path="/admin/music/:id/qrcode" element={<MusicQRCode />} />
<Route path="/admin/rules" element={<RuleList />} />
<Route path="/admin/rules/new" element={<RuleForm />} />
<Route path="/admin/rules/:id/edit" element={<RuleForm />} />
<Route path="/admin/redemptions" element={<RedemptionLog />} /> <Route path="/admin/redemptions" element={<RedemptionLog />} />
</Route> </Route>
</Route> </Route>

View File

@@ -1,11 +1,13 @@
import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { ToastProvider } from "./Toast";
const navItems = [ const navItems = [
{ path: "/admin/stamps", label: "图章管理" }, { path: "/admin/dashboard", label: "数据看板", eyebrow: "01", tag: "Dashboard" },
{ path: "/admin/articles", label: "章管理" }, { path: "/admin/stamps", label: "章管理", eyebrow: "02", tag: "Stamps" },
{ path: "/admin/music", label: "音乐管理" }, { path: "/admin/articles", label: "文章管理", eyebrow: "03", tag: "Articles" },
{ path: "/admin/rules", label: "兑换规则" }, { path: "/admin/music", label: "音乐管理", eyebrow: "04", tag: "Music" },
{ path: "/admin/redemptions", label: "兑换记录" }, { path: "/admin/users", label: "用户管理", eyebrow: "05", tag: "Users" },
{ path: "/admin/redemptions", label: "兑换记录", eyebrow: "06", tag: "Log" },
]; ];
export default function AdminLayout() { export default function AdminLayout() {
@@ -17,40 +19,131 @@ export default function AdminLayout() {
}; };
return ( return (
<div className="min-h-screen bg-gray-50 flex"> <ToastProvider>
{/* Sidebar */} <div className="min-h-screen flex bg-[var(--bg-cream)] grain-overlay">
<aside className="w-56 bg-white border-r border-gray-200 flex flex-col shrink-0"> {/* ═══════════ Sidebar ═══════════ */}
<div className="px-5 py-4 border-b border-gray-200"> <aside className="w-64 shrink-0 relative flex flex-col text-[var(--text-inverted)]">
<h1 className="text-base font-semibold text-gray-800"></h1> <div className="absolute inset-0 bg-[var(--bg-dark-deep)]" />
</div> <div
<nav className="flex-1 py-3"> className="absolute inset-0 pointer-events-none"
{navItems.map((item) => ( style={{
<NavLink backgroundImage: `
key={item.path} radial-gradient(ellipse 80% 40% at 50% 0%, rgba(212, 165, 116, 0.08) 0%, transparent 70%),
to={item.path} radial-gradient(circle at 20% 90%, rgba(199, 91, 57, 0.06) 0%, transparent 60%)
className={({ isActive }) => `,
`block px-5 py-2.5 text-sm transition-colors ${ }}
isActive />
? "text-blue-600 bg-blue-50 font-medium border-r-2 border-blue-600" <div
: "text-gray-600 hover:bg-gray-50" className="absolute inset-0 opacity-[0.025] pointer-events-none"
}` style={{
} backgroundImage: `
> linear-gradient(rgba(212, 165, 116, 0.6) 1px, transparent 1px),
{item.label} linear-gradient(90deg, rgba(212, 165, 116, 0.6) 1px, transparent 1px)
</NavLink> `,
))} backgroundSize: "40px 40px",
</nav> }}
<div className="px-5 py-3 border-t border-gray-200"> />
<button onClick={handleLogout} className="text-sm text-gray-500 hover:text-gray-700">
退
</button>
</div>
</aside>
{/* Main content */} {/* Brand */}
<main className="flex-1 p-6 overflow-auto"> <div className="relative px-7 pt-8 pb-7">
<Outlet /> <div className="flex items-center gap-2.5 mb-3">
</main> <span className="block w-5 h-px bg-[var(--gold)]/60" />
</div> <span
className="text-[var(--gold)] text-[10px] tracking-[0.4em] uppercase"
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 500 }}
>
Atelier
</span>
</div>
<h1
className="text-[var(--text-inverted)] text-[22px] leading-tight"
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 700, letterSpacing: "-0.01em" }}
>
CityWalk
<br />
<span className="text-[var(--gold)]"></span>
</h1>
<div className="mt-4 h-px bg-gradient-to-r from-[var(--gold)]/40 via-[var(--gold)]/10 to-transparent" />
</div>
{/* Nav */}
<nav className="relative flex-1 px-4">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`group relative block px-3 py-3 my-0.5 rounded-lg transition-all duration-300 ${
isActive
? "bg-[rgba(212,165,116,0.08)]"
: "hover:bg-white/[0.04]"
}`
}
>
{({ isActive }) => (
<>
<span
className={`absolute left-0 top-1/2 -translate-y-1/2 w-[3px] rounded-r transition-all duration-300 ${
isActive ? "h-5 bg-[var(--gold)]" : "h-0 bg-transparent"
}`}
/>
<div className="flex items-baseline gap-3">
<span
className={`text-[10px] tracking-[0.3em] uppercase shrink-0 transition-colors ${
isActive ? "text-[var(--gold)]" : "text-[var(--text-inverted)]/30"
}`}
style={{ fontFamily: "'Playfair Display', serif" }}
>
{item.eyebrow}
</span>
<span
className={`text-[14px] transition-colors ${
isActive
? "text-[var(--text-inverted)] font-medium"
: "text-[var(--text-inverted)]/60 group-hover:text-[var(--text-inverted)]/90"
}`}
>
{item.label}
</span>
</div>
<div
className={`ml-[38px] mt-0.5 text-[9px] tracking-[0.32em] uppercase transition-colors ${
isActive ? "text-[var(--gold)]/60" : "text-[var(--text-inverted)]/15"
}`}
style={{ fontFamily: "'Playfair Display', serif" }}
>
{item.tag}
</div>
</>
)}
</NavLink>
))}
</nav>
{/* Footer */}
<div className="relative px-7 py-5 border-t border-white/5">
<button
onClick={handleLogout}
className="group flex items-center gap-2.5 text-[var(--text-inverted)]/45 hover:text-[var(--gold)] text-sm transition-colors"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9" />
</svg>
<span>退</span>
</button>
<p className="mt-3 text-[9px] tracking-[0.3em] uppercase text-[var(--text-inverted)]/20">
v1.0 · Curated by Stamp
</p>
</div>
</aside>
{/* ═══════════ Main ═══════════ */}
<main className="flex-1 overflow-auto paper-texture">
<div className="min-h-full px-10 py-10">
<Outlet />
</div>
</main>
</div>
</ToastProvider>
); );
} }

View File

@@ -15,7 +15,7 @@ export default function AdminLogin() {
const json = await res.json(); const json = await res.json();
if (json.success) { if (json.success) {
sessionStorage.setItem("admin_key", key); sessionStorage.setItem("admin_key", key);
navigate("/admin/stamps"); navigate("/admin/dashboard");
} else { } else {
setError("密钥不正确"); setError("密钥不正确");
} }
@@ -27,28 +27,117 @@ export default function AdminLogin() {
}; };
return ( return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center relative overflow-hidden grain-overlay">
<div className="w-80 bg-white rounded-lg shadow-sm p-6 border border-gray-200"> <div className="absolute inset-0 bg-[var(--bg-dark-deep)]" />
<h1 className="text-lg font-semibold text-gray-800 mb-4 text-center"></h1> <div
<div className="space-y-3"> className="absolute inset-0"
<input style={{
type="password" backgroundImage: `
value={key} radial-gradient(ellipse 60% 50% at 50% 40%, rgba(212, 165, 116, 0.08) 0%, transparent 100%),
onChange={(e) => setKey(e.target.value)} radial-gradient(circle at 15% 80%, rgba(199, 91, 57, 0.06) 0%, transparent 50%),
onKeyDown={(e) => e.key === "Enter" && handleLogin()} radial-gradient(circle at 85% 20%, rgba(45, 106, 79, 0.04) 0%, transparent 40%)
placeholder="输入管理密钥" `,
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm }}
focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500" />
/> <div
{error && <p className="text-sm text-red-500">{error}</p>} className="absolute inset-0 opacity-[0.03]"
<button style={{
onClick={handleLogin} backgroundImage: `
disabled={loading || !key} linear-gradient(rgba(212, 165, 116, 0.5) 1px, transparent 1px),
className="w-full py-2 bg-blue-600 text-white text-sm rounded-md linear-gradient(90deg, rgba(212, 165, 116, 0.5) 1px, transparent 1px)
hover:bg-blue-700 disabled:opacity-50 transition-colors" `,
backgroundSize: "60px 60px",
}}
/>
<div className="relative z-10 w-full max-w-sm px-6">
{/* Brand */}
<div className="text-center mb-10 animate-fade-in">
<div className="inline-flex items-center gap-3 mb-6">
<span className="block w-10 h-px bg-[var(--gold)]/50" />
<span
className="text-[var(--gold)] text-[11px] tracking-[0.4em] uppercase"
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 500 }}
>
CityWalk · Atelier
</span>
<span className="block w-10 h-px bg-[var(--gold)]/50" />
</div>
<h1
className="text-[var(--text-inverted)] text-4xl leading-none mb-3"
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 700, letterSpacing: "-0.02em" }}
> >
{loading ? "验证中..." : "登录"}
</button> </h1>
<p className="text-[var(--gold-light)]/50 text-[11px] tracking-[0.28em] uppercase">
Stamp · Admin Console
</p>
</div>
{/* Card */}
<div
className="relative animate-fade-in-up"
style={{ animationDelay: "0.15s" }}
>
{/* Corner flourishes */}
<span className="absolute -top-1.5 -left-1.5 w-6 h-6 border-t border-l border-[var(--gold)]/40" />
<span className="absolute -top-1.5 -right-1.5 w-6 h-6 border-t border-r border-[var(--gold)]/40" />
<span className="absolute -bottom-1.5 -left-1.5 w-6 h-6 border-b border-l border-[var(--gold)]/40" />
<span className="absolute -bottom-1.5 -right-1.5 w-6 h-6 border-b border-r border-[var(--gold)]/40" />
<div
className="rounded-xl bg-[var(--bg-cream)] p-7"
style={{ boxShadow: "0 32px 80px rgba(0,0,0,0.4)" }}
>
<div className="flex items-center gap-2 mb-5">
<span className="block w-4 h-px bg-[var(--gold)]" />
<span
className="text-[var(--gold)] text-[10px] tracking-[0.32em] uppercase"
style={{ fontFamily: "'Playfair Display', serif" }}
>
Access
</span>
</div>
<label className="block text-[13px] text-[var(--text-secondary)] mb-2"></label>
<input
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && key && handleLogin()}
placeholder="••••••••"
className="w-full px-4 py-3 bg-white border border-[var(--border-default)] rounded-lg text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)]/50 focus:outline-none focus:border-[var(--gold)] focus:ring-2 focus:ring-[var(--gold)]/15 transition-all"
autoFocus
/>
{error && (
<div className="mt-3 flex items-center gap-2 text-[13px] text-[var(--terracotta)]">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
{error}
</div>
)}
<button
onClick={handleLogin}
disabled={loading || !key}
className="mt-6 w-full py-3 rounded-lg text-white text-sm font-medium transition-all disabled:opacity-40 hover:brightness-110"
style={{
backgroundColor: "var(--terracotta)",
boxShadow: "0 8px 24px rgba(199,91,57,0.35)",
}}
>
{loading ? "验证中..." : "进入后台"}
</button>
<div className="mt-6 pt-5 border-t border-[var(--border-muted)]">
<p className="text-[10px] tracking-[0.25em] uppercase text-[var(--text-muted)] text-center">
访
</p>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,8 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom"; import Modal from "./Modal";
import { adminFetch } from "./adminApi"; import { adminFetch } from "./adminApi";
import { useToast } from "./Toast";
import { Field, ErrorRow, FormFooter, HintRow, fieldCls } from "./FormPrimitives";
type Article = { type Article = {
id: string; id: string;
@@ -13,11 +15,16 @@ type Article = {
enabled: boolean; enabled: boolean;
}; };
export default function ArticleForm() { type Props = {
const { id } = useParams(); open: boolean;
const navigate = useNavigate(); id: string | null;
const isEdit = !!id; onClose: () => void;
onSaved: () => void;
};
export default function ArticleForm({ open, id, onClose, onSaved }: Props) {
const toast = useToast();
const [currentId, setCurrentId] = useState<string | null>(null);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [subtitle, setSubtitle] = useState(""); const [subtitle, setSubtitle] = useState("");
const [body, setBody] = useState(""); const [body, setBody] = useState("");
@@ -28,8 +35,17 @@ export default function ArticleForm() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const isEdit = !!currentId;
useEffect(() => { useEffect(() => {
if (!id) return; if (!open) return;
setCurrentId(id);
setError("");
if (!id) {
setTitle(""); setSubtitle(""); setBody(""); setCaption("");
setCoverImage(""); setSortOrder(0); setEnabled(true);
return;
}
adminFetch<Article[]>("/articles").then((articles) => { adminFetch<Article[]>("/articles").then((articles) => {
const article = articles.find((a) => a.id === id); const article = articles.find((a) => a.id === id);
if (article) { if (article) {
@@ -42,32 +58,33 @@ export default function ArticleForm() {
setEnabled(article.enabled); setEnabled(article.enabled);
} }
}); });
}, [id]); }, [open, id]);
const handleUpload = async (file: File) => { const handleUpload = async (file: File) => {
if (!id) { if (!currentId) {
setError("请先保存文章后再上传封面"); setError("请先保存文章后再上传封面");
return; return;
} }
setError("");
const formData = new FormData(); const formData = new FormData();
formData.append("image", file); formData.append("image", file);
const data = await adminFetch<{ path: string }>(`/articles/${id}/upload`, { try {
method: "POST", const data = await adminFetch<{ path: string }>(`/articles/${currentId}/upload`, {
body: formData, method: "POST",
}); body: formData,
setCoverImage(data.path); });
setCoverImage(data.path);
toast.show("封面已上传");
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "上传失败");
}
}; };
const handleSave = async () => { const handleSave = async () => {
setError(""); setError("");
if (!title.trim()) { if (!title.trim()) return setError("请输入标题");
setError("请输入标题"); if (!body.trim()) return setError("请输入正文");
return;
}
if (!body.trim()) {
setError("请输入正文");
return;
}
setSaving(true); setSaving(true);
try { try {
const payload = { const payload = {
@@ -79,19 +96,22 @@ export default function ArticleForm() {
enabled, enabled,
}; };
if (isEdit) { if (isEdit) {
await adminFetch(`/articles/${id}`, { await adminFetch(`/articles/${currentId}`, {
method: "PUT", method: "PUT",
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
toast.show("已保存");
onSaved();
onClose();
} else { } else {
const article = await adminFetch<Article>("/articles", { const article = await adminFetch<Article>("/articles", {
method: "POST", method: "POST",
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
navigate(`/admin/articles/${article.id}/edit`, { replace: true }); setCurrentId(article.id);
return; toast.show("已创建,现在可以上传封面");
onSaved();
} }
navigate("/admin/articles");
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "保存失败"); setError(e instanceof Error ? e.message : "保存失败");
} finally { } finally {
@@ -100,122 +120,104 @@ export default function ArticleForm() {
}; };
return ( return (
<div className="max-w-2xl"> <Modal
<h2 className="text-lg font-semibold text-gray-800 mb-4"> open={open}
{isEdit ? "编辑文章" : "添加文章"} onClose={onClose}
</h2> size="lg"
eyebrow={isEdit ? "Edit Article" : "New Article"}
<div className="bg-white rounded-lg border border-gray-200 p-5 space-y-4"> title={isEdit ? "编辑文章" : "添加文章"}
<div> subtitle={isEdit ? "调整内容与上传封面" : "先保存文章内容,再上传封面图"}
<label className="block text-sm font-medium text-gray-700 mb-1"></label> >
<div className="px-7 py-6 space-y-5">
<Field label="标题" required>
<input <input
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="如:朝天宫" placeholder="如:朝天宫"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm className={fieldCls}
focus:outline-none focus:ring-1 focus:ring-blue-500"
/> />
</div> </Field>
<div> <Field label="副标题">
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input <input
value={subtitle} value={subtitle}
onChange={(e) => setSubtitle(e.target.value)} onChange={(e) => setSubtitle(e.target.value)}
placeholder="如:千年冶山,文脉绵延" placeholder="如:千年冶山,文脉绵延"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm className={fieldCls}
focus:outline-none focus:ring-1 focus:ring-blue-500"
/> />
</div> </Field>
<div> <Field label="正文" required hint="段落之间用空行分隔">
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-xs text-gray-400 font-normal ml-2"></span>
</label>
<textarea <textarea
value={body} value={body}
onChange={(e) => setBody(e.target.value)} onChange={(e) => setBody(e.target.value)}
rows={18} rows={14}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm leading-relaxed placeholder="在这里撰写文章正文…"
focus:outline-none focus:ring-1 focus:ring-blue-500 font-mono" className={fieldCls + " font-mono text-[13px] leading-relaxed resize-y"}
/> />
</div> </Field>
<div> <Field label="图片说明">
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input <input
value={caption} value={caption}
onChange={(e) => setCaption(e.target.value)} onChange={(e) => setCaption(e.target.value)}
placeholder="如1910 年的朝天宫大成殿旧影" placeholder="如1910 年的朝天宫大成殿旧影"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm className={fieldCls}
focus:outline-none focus:ring-1 focus:ring-blue-500"
/> />
</div> </Field>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-[auto_1fr] gap-5 items-end">
<div> <Field label="排序">
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input <input
type="number" type="number"
value={sortOrder} value={sortOrder}
onChange={(e) => setSortOrder(Number(e.target.value))} onChange={(e) => setSortOrder(Number(e.target.value))}
className="w-24 px-3 py-2 border border-gray-300 rounded-md text-sm className={fieldCls + " w-28"}
focus:outline-none focus:ring-1 focus:ring-blue-500"
/> />
</div> </Field>
<div className="flex items-center pt-6"> <label className="flex items-center gap-2.5 cursor-pointer select-none pb-2.5">
<label className="flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
</label>
</div>
</div>
{isEdit && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
{coverImage && (
<div className="w-64 aspect-[4/3] rounded-md bg-gray-50 border border-gray-200 overflow-hidden shadow-sm mb-2">
<img src={coverImage} alt="封面" className="w-full h-full object-cover" />
</div>
)}
<input <input
type="file" type="checkbox"
accept="image/*" checked={enabled}
onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0])} onChange={(e) => setEnabled(e.target.checked)}
className="text-xs text-gray-500" className="w-4 h-4 accent-[var(--jade)]"
/> />
</div> <span className="text-sm text-[var(--text-secondary)]">访访</span>
)} </label>
{!isEdit && (
<p className="text-xs text-gray-400"></p>
)}
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-3 pt-2">
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md
hover:bg-blue-700 disabled:opacity-50"
>
{saving ? "保存中..." : "保存"}
</button>
<button
onClick={() => navigate("/admin/articles")}
className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded-md hover:bg-gray-50"
>
</button>
</div> </div>
{isEdit ? (
<Field label="封面图片">
<div className="rounded-xl border border-[var(--border-muted)] bg-white p-4">
{coverImage && (
<div className="w-full max-w-sm aspect-[4/3] rounded-lg bg-[var(--bg-paper)] overflow-hidden border border-[var(--border-muted)] mb-3">
<img src={coverImage} alt="封面" className="w-full h-full object-cover" />
</div>
)}
<label className="cursor-pointer inline-flex items-center gap-2 text-[13px] text-[var(--text-secondary)] hover:text-[var(--terracotta)] transition-colors">
<input
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0])}
className="hidden"
/>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" />
</svg>
<span className="underline underline-offset-2 decoration-dotted">
{coverImage ? "更换封面" : "选择封面图片"}
</span>
</label>
</div>
</Field>
) : (
<HintRow text="保存文章后,即可上传封面图片" />
)}
{error && <ErrorRow text={error} />}
</div> </div>
</div>
<FormFooter onCancel={onClose} onSave={handleSave} saving={saving} primaryLabel={isEdit ? "保存" : "创建"} />
</Modal>
); );
} }

View File

@@ -1,6 +1,21 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { adminFetch } from "./adminApi"; import { adminFetch } from "./adminApi";
import { useToast } from "./Toast";
import { copyItemLink } from "./utils";
import ArticleForm from "./ArticleForm";
import QRCodeModal from "./QRCodeModal";
import PageHeader, {
PrimaryButton,
StatusChip,
ActionButton,
EmptyState,
LoadingBlock,
IconEdit,
IconCopy,
IconQR,
IconDelete,
} from "./PageHeader";
import { TableCard, TableHeadRow } from "./StampList";
type Article = { type Article = {
id: string; id: string;
@@ -12,11 +27,14 @@ type Article = {
}; };
export default function ArticleList() { export default function ArticleList() {
const toast = useToast();
const [articles, setArticles] = useState<Article[]>([]); const [articles, setArticles] = useState<Article[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [formState, setFormState] = useState<{ open: boolean; id: string | null }>({ open: false, id: null });
const [qrState, setQrState] = useState<{ open: boolean; id: string | null }>({ open: false, id: null });
const fetchArticles = async () => { const fetchArticles = async () => {
setLoading(true);
try { try {
const data = await adminFetch<Article[]>("/articles"); const data = await adminFetch<Article[]>("/articles");
setArticles(data); setArticles(data);
@@ -25,12 +43,19 @@ export default function ArticleList() {
} }
}; };
useEffect(() => { fetchArticles(); }, []); useEffect(() => {
fetchArticles();
}, []);
const handleDelete = async (id: string, title: string) => { const handleDelete = async (id: string, title: string) => {
if (!confirm(`确定删除文章「${title}」?`)) return; if (!confirm(`确定删除文章「${title}」?`)) return;
await adminFetch(`/articles/${id}`, { method: "DELETE" }); try {
fetchArticles(); await adminFetch(`/articles/${id}`, { method: "DELETE" });
toast.show("已删除");
fetchArticles();
} catch (e) {
toast.show(e instanceof Error ? e.message : "删除失败", "error");
}
}; };
const handleToggle = async (id: string, enabled: boolean) => { const handleToggle = async (id: string, enabled: boolean) => {
@@ -41,78 +66,124 @@ export default function ArticleList() {
fetchArticles(); fetchArticles();
}; };
if (loading) return <p className="text-gray-500">...</p>; const handleCopyLink = async (id: string) => {
try {
await copyItemLink("article", id);
toast.show("链接已复制");
} catch {
toast.show("复制失败", "error");
}
};
return ( return (
<div> <>
<div className="flex items-center justify-between mb-4"> <PageHeader
<h2 className="text-lg font-semibold text-gray-800"></h2> eyebrow="03 · Articles"
<Link title="文章管理"
to="/admin/articles/new" caption="静态文章与对应点位的 NFC 链接"
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700" action={<PrimaryButton onClick={() => setFormState({ open: true, id: null })}></PrimaryButton>}
> />
</Link>
</div>
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden"> {loading ? (
<table className="w-full text-sm"> <LoadingBlock />
<thead className="bg-gray-50 border-b border-gray-200"> ) : (
<tr> <TableCard>
<th className="text-left px-4 py-3 font-medium text-gray-600"></th> {articles.length === 0 ? (
<th className="text-left px-4 py-3 font-medium text-gray-600"></th> <EmptyState
<th className="text-left px-4 py-3 font-medium text-gray-600"></th> message="尚未创建文章"
<th className="text-center px-4 py-3 font-medium text-gray-600"></th> action={
<th className="text-center px-4 py-3 font-medium text-gray-600"></th> <PrimaryButton onClick={() => setFormState({ open: true, id: null })}>
<th className="text-right px-4 py-3 font-medium text-gray-600"></th>
</tr> </PrimaryButton>
</thead> }
<tbody> />
{articles.map((article) => ( ) : (
<tr key={article.id} className="border-b border-gray-100 hover:bg-gray-50"> <table className="w-full">
<td className="px-4 py-3"> <thead>
<div className="w-16 h-10 rounded bg-gray-50 border border-gray-200 overflow-hidden flex items-center justify-center shadow-sm"> <TableHeadRow cols={["封面", "标题 · 副标题", "排序", "状态", "操作"]} />
{article.coverImage && ( </thead>
<img src={article.coverImage} alt="" className="w-full h-full object-cover" /> <tbody>
)} {articles.map((article, i) => (
</div> <tr
</td> key={article.id}
<td className="px-4 py-3 text-gray-800 font-medium">{article.title}</td> className="border-t border-[var(--border-muted)] hover:bg-white/60 transition-colors animate-admin-row"
<td className="px-4 py-3 text-gray-500 max-w-[260px] truncate">{article.subtitle || "—"}</td> style={{ animationDelay: `${Math.min(i, 12) * 0.03}s` }}
<td className="px-4 py-3 text-center text-gray-500">{article.sortOrder}</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => handleToggle(article.id, article.enabled)}
className={`px-2 py-0.5 rounded text-xs ${
article.enabled ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"
}`}
> >
{article.enabled ? "启用" : "禁用"} <td className="px-5 py-4 w-[110px]">
</button> <div className="w-[72px] h-12 rounded-md bg-[var(--bg-paper)] border border-[var(--border-muted)] overflow-hidden shadow-sm">
</td> {article.coverImage ? (
<td className="px-4 py-3 text-right space-x-2"> <img src={article.coverImage} alt="" className="w-full h-full object-cover" />
<Link to={`/admin/articles/${article.id}/edit`} className="text-blue-600 hover:underline"> ) : (
<div className="w-full h-full flex items-center justify-center">
</Link> <span className="text-[9px] tracking-[0.2em] uppercase text-[var(--text-muted)]/60">
<Link to={`/admin/articles/${article.id}/qrcode`} className="text-blue-600 hover:underline">
</span>
</Link> </div>
<button onClick={() => handleDelete(article.id, article.title)} className="text-red-500 hover:underline"> )}
</div>
</button> </td>
</td> <td className="px-5 py-4">
</tr> <p className="text-[15px] font-medium text-[var(--text-primary)]">{article.title}</p>
))} {article.subtitle && (
{articles.length === 0 && ( <p className="text-xs text-[var(--text-muted)] mt-0.5 max-w-[360px] truncate">
<tr> {article.subtitle}
<td colSpan={6} className="px-4 py-8 text-center text-gray-400"> </p>
)}
</td> </td>
</tr> <td className="px-5 py-4 text-center w-[80px]">
)} <span
</tbody> className="text-[13px] text-[var(--text-secondary)]"
</table> style={{ fontFamily: "'Playfair Display', serif" }}
</div> >
</div> {article.sortOrder}
</span>
</td>
<td className="px-5 py-4 text-center w-[110px]">
<StatusChip
enabled={article.enabled}
onClick={() => handleToggle(article.id, article.enabled)}
/>
</td>
<td className="px-5 py-4 w-[180px]">
<div className="flex items-center justify-end gap-1">
<ActionButton title="编辑" onClick={() => setFormState({ open: true, id: article.id })}>
{IconEdit}
</ActionButton>
<ActionButton title="复制链接" onClick={() => handleCopyLink(article.id)}>
{IconCopy}
</ActionButton>
<ActionButton title="二维码" onClick={() => setQrState({ open: true, id: article.id })}>
{IconQR}
</ActionButton>
<ActionButton
title="删除"
variant="danger"
onClick={() => handleDelete(article.id, article.title)}
>
{IconDelete}
</ActionButton>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</TableCard>
)}
<ArticleForm
open={formState.open}
id={formState.id}
onClose={() => setFormState({ open: false, id: null })}
onSaved={fetchArticles}
/>
<QRCodeModal
open={qrState.open}
type="article"
id={qrState.id}
onClose={() => setQrState({ open: false, id: null })}
/>
</>
); );
} }

View File

@@ -1,120 +0,0 @@
import { useState, useEffect, useRef } from "react";
import { useParams, Link } from "react-router-dom";
import { adminFetch } from "./adminApi";
type QRData = {
qrDataUrl: string;
articleUrl: string;
articleTitle: string;
};
export default function ArticleQRCode() {
const { id } = useParams();
const [data, setData] = useState<QRData | null>(null);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!id) return;
adminFetch<QRData>(`/articles/${id}/qrcode`)
.then(setData)
.finally(() => setLoading(false));
}, [id]);
useEffect(() => {
if (!data || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d")!;
const img = new Image();
img.onload = () => {
const padding = 20;
const textHeight = 40;
canvas.width = img.width + padding * 2;
canvas.height = img.height + padding * 2 + textHeight;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, padding, padding);
ctx.fillStyle = "#666666";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.fillText(data.articleUrl, canvas.width / 2, img.height + padding * 2 + 12);
};
img.src = data.qrDataUrl;
}, [data]);
const handleDownload = () => {
if (!canvasRef.current || !data) return;
const link = document.createElement("a");
link.download = `${data.articleTitle}-二维码.png`;
link.href = canvasRef.current.toDataURL("image/png");
link.click();
};
const handleCopy = async () => {
if (!data) return;
try {
await navigator.clipboard.writeText(data.articleUrl);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
} catch {
const ta = document.createElement("textarea");
ta.value = data.articleUrl;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
}
};
if (loading) return <p className="text-gray-500">...</p>;
if (!data) return <p className="text-gray-500"></p>;
return (
<div className="max-w-md">
<div className="flex items-center gap-2 mb-4">
<Link to="/admin/articles" className="text-gray-400 hover:text-gray-600">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
</Link>
<h2 className="text-lg font-semibold text-gray-800">{data.articleTitle} </h2>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-6 text-center space-y-4">
<div className="inline-block">
<img src={data.qrDataUrl} alt="二维码" className="w-64 h-64" />
</div>
<p className="text-xs text-gray-500 break-all select-all">{data.articleUrl}</p>
<canvas ref={canvasRef} className="hidden" />
<div className="flex flex-wrap items-center justify-center gap-3">
<button
onClick={handleCopy}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 min-w-[160px]"
>
{copied ? "已复制 ✓" : "复制链接(写入 NFC"}
</button>
<button
onClick={handleDownload}
className="px-4 py-2 border border-gray-300 text-gray-700 text-sm rounded-md hover:bg-gray-50"
>
</button>
</div>
<p className="text-xs text-gray-400 pt-1">
NFC
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,181 @@
import { useEffect, useState, type ReactNode } from "react";
import { adminFetch } from "./adminApi";
import PageHeader, { LoadingBlock } from "./PageHeader";
type MetricBlock = { total: number; today: number; thisWeek: number; thisMonth: number };
type DashboardData = {
users: MetricBlock;
collections: MetricBlock;
redemptions: MetricBlock;
};
type CardSpec = {
key: keyof DashboardData;
eyebrow: string;
label: string;
caption: string;
accent: string;
icon: ReactNode;
};
const CARDS: CardSpec[] = [
{
key: "users",
eyebrow: "Users",
label: "注册用户",
caption: "累计参与城市漫步的旅人",
accent: "var(--jade)",
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" />
</svg>
),
},
{
key: "collections",
eyebrow: "Collected",
label: "图章收集",
caption: "触发一次 NFC 即记一次",
accent: "var(--gold)",
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<circle cx="12" cy="12" r="9" />
<path d="M12 3a9 9 0 010 18M12 3v18M3 12h18" />
</svg>
),
},
{
key: "redemptions",
eyebrow: "Redeemed",
label: "兑换次数",
caption: "访客将图章兑换为奖品",
accent: "var(--terracotta)",
icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path d="M20 12v10H4V12M2 7h20v5H2zM12 22V7M12 7H7.5a2.5 2.5 0 010-5C11 2 12 7 12 7zM12 7h4.5a2.5 2.5 0 000-5C13 2 12 7 12 7z" />
</svg>
),
},
];
export default function Dashboard() {
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
adminFetch<DashboardData>("/dashboard")
.then(setData)
.finally(() => setLoading(false));
}, []);
return (
<>
<PageHeader
eyebrow="01 · Dashboard"
title="数据看板"
caption={`截至 ${new Date().toLocaleString("zh-CN", { dateStyle: "long", timeStyle: "short" })}Asia/Shanghai`}
/>
{loading || !data ? (
<LoadingBlock />
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
{CARDS.map((card, i) => (
<MetricCard key={card.key} card={card} metric={data[card.key]} index={i} />
))}
</div>
)}
</>
);
}
function MetricCard({ card, metric, index }: { card: CardSpec; metric: MetricBlock; index: number }) {
return (
<div
className="relative rounded-2xl bg-white/85 border border-[var(--border-muted)] overflow-hidden animate-admin-row"
style={{
animationDelay: `${index * 0.08}s`,
boxShadow: "0 1px 2px rgba(16,16,30,0.04), 0 12px 32px rgba(16,16,30,0.05)",
}}
>
<span
className="absolute top-0 left-0 right-0 h-0.5"
style={{ backgroundColor: card.accent }}
/>
{/* Header: eyebrow + icon */}
<div className="px-7 pt-6 pb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="block w-5 h-px" style={{ backgroundColor: card.accent, opacity: 0.6 }} />
<span
className="text-[10px] tracking-[0.32em] uppercase"
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 500, color: card.accent }}
>
{card.eyebrow}
</span>
</div>
<span style={{ color: card.accent, opacity: 0.6 }}>{card.icon}</span>
</div>
{/* Total */}
<div className="px-7 pb-5">
<p
className="text-[64px] leading-none text-[var(--text-primary)]"
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 600, letterSpacing: "-0.03em" }}
>
{metric.total}
</p>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-sm font-medium text-[var(--text-primary)]">{card.label}</span>
<span className="text-[10px] tracking-[0.2em] uppercase text-[var(--text-muted)]">Total</span>
</div>
<p className="mt-1 text-[11px] text-[var(--text-muted)] leading-relaxed">{card.caption}</p>
</div>
{/* Time-slice metrics */}
<div className="grid grid-cols-3 divide-x divide-[var(--border-muted)] border-t border-[var(--border-muted)] bg-[var(--bg-paper)]/40">
<SliceCell label="Today" value={metric.today} labelCn="今日" accent={card.accent} />
<SliceCell label="Week" value={metric.thisWeek} labelCn="本周" accent={card.accent} />
<SliceCell label="Month" value={metric.thisMonth} labelCn="本月" accent={card.accent} />
</div>
</div>
);
}
function SliceCell({
label,
labelCn,
value,
accent,
}: {
label: string;
labelCn: string;
value: number;
accent: string;
}) {
const isZero = value === 0;
return (
<div className="px-4 py-4 text-center">
<div
className="text-[9px] tracking-[0.3em] uppercase mb-1"
style={{ fontFamily: "'Playfair Display', serif", color: accent, opacity: 0.7 }}
>
{label}
</div>
<div
className="text-xl leading-none"
style={{
fontFamily: "'Playfair Display', serif",
fontWeight: 600,
color: isZero ? "var(--text-muted)" : "var(--text-primary)",
letterSpacing: "-0.01em",
}}
>
{isZero ? "0" : `+${value}`}
</div>
<div className="text-[10px] text-[var(--text-muted)] mt-1">{labelCn}</div>
</div>
);
}

View File

@@ -0,0 +1,94 @@
import type { ReactNode } from "react";
export const fieldCls =
"w-full px-3.5 py-2.5 bg-white border border-[var(--border-default)] rounded-lg text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)]/60 focus:outline-none focus:border-[var(--gold)] focus:ring-2 focus:ring-[var(--gold)]/15 transition-all";
export function Field({
label,
required,
hint,
children,
}: {
label: string;
required?: boolean;
hint?: string;
children: ReactNode;
}) {
return (
<div>
<label className="flex items-baseline justify-between mb-1.5">
<span className="text-[13px] font-medium text-[var(--text-secondary)]">
{label}
{required && <span className="text-[var(--terracotta)] ml-0.5">*</span>}
</span>
{hint && <span className="text-[10px] tracking-[0.2em] uppercase text-[var(--text-muted)]">{hint}</span>}
</label>
{children}
</div>
);
}
export function ErrorRow({ text }: { text: string }) {
return (
<div
className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg text-sm"
style={{ backgroundColor: "rgba(199, 91, 57, 0.08)", color: "var(--terracotta)" }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
{text}
</div>
);
}
export function HintRow({ text }: { text: string }) {
return (
<div className="flex items-center gap-2.5 px-3.5 py-2.5 rounded-lg bg-[var(--bg-paper)] border border-[var(--border-muted)]">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--gold)" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" />
</svg>
<span className="text-xs text-[var(--text-secondary)]">{text}</span>
</div>
);
}
export function FormFooter({
onCancel,
onSave,
saving,
primaryLabel = "保存",
disabled,
}: {
onCancel: () => void;
onSave: () => void;
saving: boolean;
primaryLabel?: string;
disabled?: boolean;
}) {
return (
<div className="px-7 py-5 bg-white/40 border-t border-[var(--border-muted)] flex justify-end gap-3">
<button
type="button"
onClick={onCancel}
className="px-5 py-2.5 rounded-lg text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--bg-paper)] transition-colors"
>
</button>
<button
type="button"
onClick={onSave}
disabled={saving || disabled}
className="px-6 py-2.5 rounded-lg text-white text-sm font-medium transition-all disabled:opacity-40 hover:brightness-110"
style={{
backgroundColor: "var(--terracotta)",
boxShadow: "0 4px 14px rgba(199,91,57,0.3)",
}}
>
{saving ? "保存中..." : primaryLabel}
</button>
</div>
);
}

View File

@@ -0,0 +1,100 @@
import { useEffect, type ReactNode } from "react";
type ModalProps = {
open: boolean;
onClose: () => void;
children: ReactNode;
size?: "sm" | "md" | "lg";
title?: ReactNode;
eyebrow?: string;
subtitle?: ReactNode;
dismissable?: boolean;
};
const sizeMap = { sm: "max-w-md", md: "max-w-2xl", lg: "max-w-4xl" };
export default function Modal({
open,
onClose,
children,
size = "md",
title,
eyebrow,
subtitle,
dismissable = true,
}: ModalProps) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && dismissable) onClose();
};
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [open, onClose, dismissable]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto px-4 py-8 sm:py-12 animate-overlay-fade"
style={{ backgroundColor: "rgba(16, 16, 30, 0.6)", backdropFilter: "blur(6px)" }}
onClick={(e) => {
if (!dismissable) return;
if (e.target === e.currentTarget) onClose();
}}
>
<div className={`relative w-full ${sizeMap[size]} my-auto animate-scale-in`}>
<div
className="bg-[var(--bg-cream)] rounded-2xl overflow-hidden border border-[var(--border-muted)] relative"
style={{ boxShadow: "0 32px 80px rgba(16, 16, 30, 0.5)" }}
>
{/* Decorative corner flourishes */}
<span className="absolute top-3 left-3 w-5 h-5 border-t border-l border-[var(--gold)]/30 pointer-events-none" />
<span className="absolute top-3 right-3 w-5 h-5 border-t border-r border-[var(--gold)]/30 pointer-events-none" />
{(title || eyebrow) && (
<div className="relative px-7 pt-7 pb-5 border-b border-[var(--border-muted)]">
{eyebrow && (
<div className="flex items-center gap-2 mb-2">
<span className="block w-5 h-px bg-[var(--gold)]" />
<span
className="text-[var(--gold)] text-[10px] tracking-[0.32em] uppercase"
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 500 }}
>
{eyebrow}
</span>
</div>
)}
{title && (
<h3
className="text-[22px] text-[var(--text-primary)] leading-tight pr-8"
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 600 }}
>
{title}
</h3>
)}
{subtitle && <p className="text-xs text-[var(--text-muted)] mt-1.5">{subtitle}</p>}
<button
type="button"
onClick={onClose}
className="absolute top-6 right-5 w-8 h-8 flex items-center justify-center rounded-full text-[var(--text-muted)] hover:bg-[var(--bg-paper)] hover:text-[var(--text-primary)] transition-colors"
aria-label="关闭"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
)}
<div>{children}</div>
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,8 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom"; import Modal from "./Modal";
import { adminFetch } from "./adminApi"; import { adminFetch } from "./adminApi";
import { useToast } from "./Toast";
import { Field, ErrorRow, FormFooter, HintRow, fieldCls } from "./FormPrimitives";
type Music = { type Music = {
id: string; id: string;
@@ -11,11 +13,16 @@ type Music = {
enabled: boolean; enabled: boolean;
}; };
export default function MusicForm() { type Props = {
const { id } = useParams(); open: boolean;
const navigate = useNavigate(); id: string | null;
const isEdit = !!id; onClose: () => void;
onSaved: () => void;
};
export default function MusicForm({ open, id, onClose, onSaved }: Props) {
const toast = useToast();
const [currentId, setCurrentId] = useState<string | null>(null);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [subtitle, setSubtitle] = useState(""); const [subtitle, setSubtitle] = useState("");
const [audioFile, setAudioFile] = useState(""); const [audioFile, setAudioFile] = useState("");
@@ -25,8 +32,17 @@ export default function MusicForm() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const isEdit = !!currentId;
useEffect(() => { useEffect(() => {
if (!id) return; if (!open) return;
setCurrentId(id);
setError("");
if (!id) {
setTitle(""); setSubtitle(""); setAudioFile("");
setSortOrder(0); setEnabled(true);
return;
}
adminFetch<Music[]>("/music").then((list) => { adminFetch<Music[]>("/music").then((list) => {
const item = list.find((m) => m.id === id); const item = list.find((m) => m.id === id);
if (item) { if (item) {
@@ -37,10 +53,10 @@ export default function MusicForm() {
setEnabled(item.enabled); setEnabled(item.enabled);
} }
}); });
}, [id]); }, [open, id]);
const handleUpload = async (file: File) => { const handleUpload = async (file: File) => {
if (!id) { if (!currentId) {
setError("请先保存后再上传音频"); setError("请先保存后再上传音频");
return; return;
} }
@@ -49,11 +65,13 @@ export default function MusicForm() {
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append("audio", file); formData.append("audio", file);
const data = await adminFetch<{ path: string }>(`/music/${id}/upload`, { const data = await adminFetch<{ path: string }>(`/music/${currentId}/upload`, {
method: "POST", method: "POST",
body: formData, body: formData,
}); });
setAudioFile(data.path); setAudioFile(data.path);
toast.show("音频已上传");
onSaved();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "上传失败"); setError(e instanceof Error ? e.message : "上传失败");
} finally { } finally {
@@ -63,10 +81,7 @@ export default function MusicForm() {
const handleSave = async () => { const handleSave = async () => {
setError(""); setError("");
if (!title.trim()) { if (!title.trim()) return setError("请输入标题");
setError("请输入标题");
return;
}
setSaving(true); setSaving(true);
try { try {
const payload = { const payload = {
@@ -76,19 +91,22 @@ export default function MusicForm() {
enabled, enabled,
}; };
if (isEdit) { if (isEdit) {
await adminFetch(`/music/${id}`, { await adminFetch(`/music/${currentId}`, {
method: "PUT", method: "PUT",
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
toast.show("已保存");
onSaved();
onClose();
} else { } else {
const music = await adminFetch<Music>("/music", { const music = await adminFetch<Music>("/music", {
method: "POST", method: "POST",
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
navigate(`/admin/music/${music.id}/edit`, { replace: true }); setCurrentId(music.id);
return; toast.show("已创建,现在可以上传音频");
onSaved();
} }
navigate("/admin/music");
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "保存失败"); setError(e instanceof Error ? e.message : "保存失败");
} finally { } finally {
@@ -97,102 +115,88 @@ export default function MusicForm() {
}; };
return ( return (
<div className="max-w-xl"> <Modal
<h2 className="text-lg font-semibold text-gray-800 mb-4"> open={open}
{isEdit ? "编辑音乐" : "添加音乐"} onClose={onClose}
</h2> size="md"
eyebrow={isEdit ? "Edit Music" : "New Music"}
<div className="bg-white rounded-lg border border-gray-200 p-5 space-y-4"> title={isEdit ? "编辑音乐" : "添加音乐"}
<div> subtitle={isEdit ? "调整信息与上传音频" : "先保存基础信息,再上传音频文件"}
<label className="block text-sm font-medium text-gray-700 mb-1"></label> >
<div className="px-7 py-6 space-y-5">
<Field label="标题" required>
<input <input
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="如:朝天宫之歌" placeholder="如:朝天宫之歌"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm className={fieldCls}
focus:outline-none focus:ring-1 focus:ring-blue-500"
/> />
</div> </Field>
<div> <Field label="副标题">
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input <input
value={subtitle} value={subtitle}
onChange={(e) => setSubtitle(e.target.value)} onChange={(e) => setSubtitle(e.target.value)}
placeholder="选填,如:金陵千年韵" placeholder="选填,如:金陵千年韵"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm className={fieldCls}
focus:outline-none focus:ring-1 focus:ring-blue-500"
/> />
</div> </Field>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-[auto_1fr] gap-5 items-end">
<div> <Field label="排序">
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input <input
type="number" type="number"
value={sortOrder} value={sortOrder}
onChange={(e) => setSortOrder(Number(e.target.value))} onChange={(e) => setSortOrder(Number(e.target.value))}
className="w-24 px-3 py-2 border border-gray-300 rounded-md text-sm className={fieldCls + " w-28"}
focus:outline-none focus:ring-1 focus:ring-blue-500"
/> />
</div> </Field>
<div className="flex items-center pt-6"> <label className="flex items-center gap-2.5 cursor-pointer select-none pb-2.5">
<label className="flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
</label>
</div>
</div>
{isEdit && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-xs text-gray-400 font-normal ml-2">
MP3 / M4A / WAV 20 MB
</span>
</label>
{audioFile && (
<audio src={audioFile} controls preload="metadata" className="w-full mb-2" />
)}
<input <input
type="file" type="checkbox"
accept="audio/*,.mp3,.m4a,.wav,.ogg" checked={enabled}
onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0])} onChange={(e) => setEnabled(e.target.checked)}
disabled={uploading} className="w-4 h-4 accent-[var(--jade)]"
className="text-xs text-gray-500"
/> />
{uploading && <p className="text-xs text-gray-500 mt-1"></p>} <span className="text-sm text-[var(--text-secondary)]">访访</span>
</div> </label>
)}
{!isEdit && (
<p className="text-xs text-gray-400"></p>
)}
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-3 pt-2">
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md
hover:bg-blue-700 disabled:opacity-50"
>
{saving ? "保存中..." : "保存"}
</button>
<button
onClick={() => navigate("/admin/music")}
className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded-md hover:bg-gray-50"
>
</button>
</div> </div>
{isEdit ? (
<Field label="音频文件" hint="MP3 / M4A / WAV≤ 20 MB">
<div className="rounded-xl border border-[var(--border-muted)] bg-white p-4 space-y-3">
{audioFile ? (
<audio src={audioFile} controls preload="metadata" className="w-full" />
) : (
<div className="text-xs text-[var(--text-muted)] py-4 text-center border border-dashed border-[var(--border-muted)] rounded-lg">
</div>
)}
<label className="cursor-pointer inline-flex items-center gap-2 text-[13px] text-[var(--text-secondary)] hover:text-[var(--terracotta)] transition-colors">
<input
type="file"
accept="audio/*,.mp3,.m4a,.wav,.ogg"
onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0])}
disabled={uploading}
className="hidden"
/>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" />
</svg>
<span className="underline underline-offset-2 decoration-dotted">
{uploading ? "上传中…" : audioFile ? "更换音频" : "选择音频文件"}
</span>
</label>
</div>
</Field>
) : (
<HintRow text="保存基础信息后,即可上传音频文件" />
)}
{error && <ErrorRow text={error} />}
</div> </div>
</div>
<FormFooter onCancel={onClose} onSave={handleSave} saving={saving} primaryLabel={isEdit ? "保存" : "创建"} />
</Modal>
); );
} }

View File

@@ -1,6 +1,21 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { adminFetch } from "./adminApi"; import { adminFetch } from "./adminApi";
import { useToast } from "./Toast";
import { copyItemLink } from "./utils";
import MusicForm from "./MusicForm";
import QRCodeModal from "./QRCodeModal";
import PageHeader, {
PrimaryButton,
StatusChip,
ActionButton,
EmptyState,
LoadingBlock,
IconEdit,
IconCopy,
IconQR,
IconDelete,
} from "./PageHeader";
import { TableCard, TableHeadRow } from "./StampList";
type Music = { type Music = {
id: string; id: string;
@@ -12,11 +27,14 @@ type Music = {
}; };
export default function MusicList() { export default function MusicList() {
const toast = useToast();
const [music, setMusic] = useState<Music[]>([]); const [music, setMusic] = useState<Music[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [formState, setFormState] = useState<{ open: boolean; id: string | null }>({ open: false, id: null });
const [qrState, setQrState] = useState<{ open: boolean; id: string | null }>({ open: false, id: null });
const fetchMusic = async () => { const fetchMusic = async () => {
setLoading(true);
try { try {
const data = await adminFetch<Music[]>("/music"); const data = await adminFetch<Music[]>("/music");
setMusic(data); setMusic(data);
@@ -25,12 +43,19 @@ export default function MusicList() {
} }
}; };
useEffect(() => { fetchMusic(); }, []); useEffect(() => {
fetchMusic();
}, []);
const handleDelete = async (id: string, title: string) => { const handleDelete = async (id: string, title: string) => {
if (!confirm(`确定删除音乐「${title}」?`)) return; if (!confirm(`确定删除音乐「${title}」?`)) return;
await adminFetch(`/music/${id}`, { method: "DELETE" }); try {
fetchMusic(); await adminFetch(`/music/${id}`, { method: "DELETE" });
toast.show("已删除");
fetchMusic();
} catch (e) {
toast.show(e instanceof Error ? e.message : "删除失败", "error");
}
}; };
const handleToggle = async (id: string, enabled: boolean) => { const handleToggle = async (id: string, enabled: boolean) => {
@@ -41,78 +66,134 @@ export default function MusicList() {
fetchMusic(); fetchMusic();
}; };
if (loading) return <p className="text-gray-500">...</p>; const handleCopyLink = async (id: string) => {
try {
await copyItemLink("music", id);
toast.show("链接已复制");
} catch {
toast.show("复制失败", "error");
}
};
return ( return (
<div> <>
<div className="flex items-center justify-between mb-4"> <PageHeader
<h2 className="text-lg font-semibold text-gray-800"></h2> eyebrow="04 · Music"
<Link title="音乐管理"
to="/admin/music/new" caption="音频作品与对应点位的 NFC 链接"
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700" action={<PrimaryButton onClick={() => setFormState({ open: true, id: null })}></PrimaryButton>}
> />
</Link>
</div>
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden"> {loading ? (
<table className="w-full text-sm"> <LoadingBlock />
<thead className="bg-gray-50 border-b border-gray-200"> ) : (
<tr> <TableCard>
<th className="text-left px-4 py-3 font-medium text-gray-600"></th> {music.length === 0 ? (
<th className="text-left px-4 py-3 font-medium text-gray-600"></th> <EmptyState
<th className="text-left px-4 py-3 font-medium text-gray-600"></th> message="尚未上传音乐"
<th className="text-center px-4 py-3 font-medium text-gray-600"></th> action={
<th className="text-center px-4 py-3 font-medium text-gray-600"></th> <PrimaryButton onClick={() => setFormState({ open: true, id: null })}>
<th className="text-right px-4 py-3 font-medium text-gray-600"></th>
</tr> </PrimaryButton>
</thead> }
<tbody> />
{music.map((item) => ( ) : (
<tr key={item.id} className="border-b border-gray-100 hover:bg-gray-50"> <table className="w-full">
<td className="px-4 py-3 text-gray-800 font-medium">{item.title}</td> <thead>
<td className="px-4 py-3 text-gray-500 max-w-[220px] truncate">{item.subtitle || ""}</td> <TableHeadRow cols={["标题 · 副标题", "音频", "排序", "状态", "操作"]} />
<td className="px-4 py-3 text-gray-500 text-xs"> </thead>
{item.audioFile ? ( <tbody>
<audio src={item.audioFile} controls preload="none" className="h-8 max-w-[220px]" /> {music.map((item, i) => (
) : ( <tr
<span className="text-gray-300"></span> key={item.id}
)} className="border-t border-[var(--border-muted)] hover:bg-white/60 transition-colors animate-admin-row"
</td> style={{ animationDelay: `${Math.min(i, 12) * 0.03}s` }}
<td className="px-4 py-3 text-center text-gray-500">{item.sortOrder}</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => handleToggle(item.id, item.enabled)}
className={`px-2 py-0.5 rounded text-xs ${
item.enabled ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"
}`}
> >
{item.enabled ? "启用" : "禁用"} <td className="px-5 py-4">
</button> <div className="flex items-center gap-3">
</td> <div
<td className="px-4 py-3 text-right space-x-2 whitespace-nowrap"> className="w-10 h-10 rounded-full flex items-center justify-center shrink-0 border border-[var(--border-muted)]"
<Link to={`/admin/music/${item.id}/edit`} className="text-blue-600 hover:underline"> style={{ backgroundColor: "rgba(212,165,116,0.08)" }}
>
</Link> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--gold)" strokeWidth="1.8">
<Link to={`/admin/music/${item.id}/qrcode`} className="text-blue-600 hover:underline"> <path d="M9 18V5l12-2v13M9 9l12-2" />
<circle cx="6" cy="18" r="3" />
</Link> <circle cx="18" cy="16" r="3" />
<button onClick={() => handleDelete(item.id, item.title)} className="text-red-500 hover:underline"> </svg>
</div>
</button> <div className="min-w-0">
</td> <p className="text-[15px] font-medium text-[var(--text-primary)]">{item.title}</p>
</tr> {item.subtitle && (
))} <p className="text-xs text-[var(--text-muted)] mt-0.5 max-w-[260px] truncate">
{music.length === 0 && ( {item.subtitle}
<tr> </p>
<td colSpan={6} className="px-4 py-8 text-center text-gray-400"> )}
</div>
</td> </div>
</tr> </td>
)} <td className="px-5 py-4 w-[260px]">
</tbody> {item.audioFile ? (
</table> <audio
</div> src={item.audioFile}
</div> controls
preload="none"
className="h-8 max-w-[240px]"
/>
) : (
<span className="text-xs text-[var(--text-muted)]/60"></span>
)}
</td>
<td className="px-5 py-4 text-center w-[80px]">
<span
className="text-[13px] text-[var(--text-secondary)]"
style={{ fontFamily: "'Playfair Display', serif" }}
>
{item.sortOrder}
</span>
</td>
<td className="px-5 py-4 text-center w-[110px]">
<StatusChip enabled={item.enabled} onClick={() => handleToggle(item.id, item.enabled)} />
</td>
<td className="px-5 py-4 w-[180px]">
<div className="flex items-center justify-end gap-1">
<ActionButton title="编辑" onClick={() => setFormState({ open: true, id: item.id })}>
{IconEdit}
</ActionButton>
<ActionButton title="复制链接" onClick={() => handleCopyLink(item.id)}>
{IconCopy}
</ActionButton>
<ActionButton title="二维码" onClick={() => setQrState({ open: true, id: item.id })}>
{IconQR}
</ActionButton>
<ActionButton
title="删除"
variant="danger"
onClick={() => handleDelete(item.id, item.title)}
>
{IconDelete}
</ActionButton>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</TableCard>
)}
<MusicForm
open={formState.open}
id={formState.id}
onClose={() => setFormState({ open: false, id: null })}
onSaved={fetchMusic}
/>
<QRCodeModal
open={qrState.open}
type="music"
id={qrState.id}
onClose={() => setQrState({ open: false, id: null })}
/>
</>
); );
} }

View File

@@ -1,120 +0,0 @@
import { useState, useEffect, useRef } from "react";
import { useParams, Link } from "react-router-dom";
import { adminFetch } from "./adminApi";
type QRData = {
qrDataUrl: string;
musicUrl: string;
musicTitle: string;
};
export default function MusicQRCode() {
const { id } = useParams();
const [data, setData] = useState<QRData | null>(null);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!id) return;
adminFetch<QRData>(`/music/${id}/qrcode`)
.then(setData)
.finally(() => setLoading(false));
}, [id]);
useEffect(() => {
if (!data || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d")!;
const img = new Image();
img.onload = () => {
const padding = 20;
const textHeight = 40;
canvas.width = img.width + padding * 2;
canvas.height = img.height + padding * 2 + textHeight;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, padding, padding);
ctx.fillStyle = "#666666";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.fillText(data.musicUrl, canvas.width / 2, img.height + padding * 2 + 12);
};
img.src = data.qrDataUrl;
}, [data]);
const handleDownload = () => {
if (!canvasRef.current || !data) return;
const link = document.createElement("a");
link.download = `${data.musicTitle}-二维码.png`;
link.href = canvasRef.current.toDataURL("image/png");
link.click();
};
const handleCopy = async () => {
if (!data) return;
try {
await navigator.clipboard.writeText(data.musicUrl);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
} catch {
const ta = document.createElement("textarea");
ta.value = data.musicUrl;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
}
};
if (loading) return <p className="text-gray-500">...</p>;
if (!data) return <p className="text-gray-500"></p>;
return (
<div className="max-w-md">
<div className="flex items-center gap-2 mb-4">
<Link to="/admin/music" className="text-gray-400 hover:text-gray-600">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
</Link>
<h2 className="text-lg font-semibold text-gray-800">{data.musicTitle} </h2>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-6 text-center space-y-4">
<div className="inline-block">
<img src={data.qrDataUrl} alt="二维码" className="w-64 h-64" />
</div>
<p className="text-xs text-gray-500 break-all select-all">{data.musicUrl}</p>
<canvas ref={canvasRef} className="hidden" />
<div className="flex flex-wrap items-center justify-center gap-3">
<button
onClick={handleCopy}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 min-w-[160px]"
>
{copied ? "已复制 ✓" : "复制链接(写入 NFC"}
</button>
<button
onClick={handleDownload}
className="px-4 py-2 border border-gray-300 text-gray-700 text-sm rounded-md hover:bg-gray-50"
>
</button>
</div>
<p className="text-xs text-gray-400 pt-1">
NFC
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,161 @@
import type { ReactNode } from "react";
type Props = {
eyebrow: string;
title: string;
caption?: string;
action?: ReactNode;
};
export default function PageHeader({ eyebrow, title, caption, action }: Props) {
return (
<div className="mb-8 flex items-end justify-between gap-6 flex-wrap">
<div>
<div className="flex items-center gap-3 mb-2.5">
<span className="block w-8 h-px bg-[var(--gold)]" />
<span
className="text-[var(--gold)] text-[10px] tracking-[0.35em] uppercase"
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 500 }}
>
{eyebrow}
</span>
</div>
<h1
className="text-[32px] text-[var(--text-primary)] leading-none"
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 600, letterSpacing: "-0.01em" }}
>
{title}
</h1>
{caption && <p className="mt-2 text-[13px] text-[var(--text-muted)]">{caption}</p>}
</div>
{action && <div>{action}</div>}
</div>
);
}
export function PrimaryButton({ onClick, children }: { onClick: () => void; children: ReactNode }) {
return (
<button
onClick={onClick}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-white text-sm font-medium transition-all hover:brightness-110"
style={{
backgroundColor: "var(--terracotta)",
boxShadow: "0 6px 18px rgba(199,91,57,0.28)",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
<path d="M12 5v14M5 12h14" />
</svg>
{children}
</button>
);
}
export function StatusChip({
enabled,
onClick,
}: {
enabled: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium transition-colors"
style={
enabled
? { backgroundColor: "rgba(45,106,79,0.1)", color: "var(--jade)" }
: { backgroundColor: "rgba(138,132,148,0.12)", color: "var(--text-muted)" }
}
>
<span
className="inline-block w-1.5 h-1.5 rounded-full"
style={{
backgroundColor: enabled ? "var(--jade)" : "var(--text-muted)",
boxShadow: enabled ? "0 0 6px rgba(45,106,79,0.5)" : "none",
}}
/>
{enabled ? "启用" : "禁用"}
</button>
);
}
export function ActionButton({
title,
onClick,
variant = "default",
children,
}: {
title: string;
onClick: () => void;
variant?: "default" | "danger";
children: ReactNode;
}) {
const danger = variant === "danger";
return (
<button
title={title}
onClick={onClick}
className={`w-8 h-8 flex items-center justify-center rounded-full transition-all ${
danger
? "text-[var(--text-muted)] hover:bg-[rgba(199,91,57,0.1)] hover:text-[var(--terracotta)]"
: "text-[var(--text-muted)] hover:bg-[rgba(212,165,116,0.12)] hover:text-[var(--text-primary)]"
}`}
>
{children}
</button>
);
}
export function EmptyState({ message, action }: { message: string; action?: ReactNode }) {
return (
<div className="py-16 flex flex-col items-center gap-4 text-center">
<div className="relative">
<span className="absolute inset-0 rounded-full border border-dashed border-[var(--gold)]/30 animate-[rotate-slow_30s_linear_infinite]" />
<div className="w-16 h-16 rounded-full flex items-center justify-center bg-[var(--bg-paper)]">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--gold)" strokeWidth="1.6">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
</div>
</div>
<p className="text-sm text-[var(--text-muted)]">{message}</p>
{action}
</div>
);
}
export function LoadingBlock() {
return (
<div className="py-20 flex justify-center">
<div className="w-8 h-8 border-2 border-[var(--gold)] border-t-transparent rounded-full animate-spin" />
</div>
);
}
/* ————— Row-action icons ————— */
export const IconEdit = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
export const IconCopy = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
);
export const IconQR = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
<path d="M14 14h3v3h-3zM17 17h4M17 21h4M21 14v3" />
</svg>
);
export const IconDelete = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6M10 11v6M14 11v6" />
</svg>
);

View File

@@ -0,0 +1,143 @@
import { useEffect, useRef, useState } from "react";
import Modal from "./Modal";
import { adminFetch } from "./adminApi";
import { copyText, type LinkType } from "./utils";
import { useToast } from "./Toast";
type QRResponse = {
qrDataUrl: string;
[urlKey: string]: string;
};
const CFG: Record<LinkType, { path: (id: string) => string; urlKey: string; nameKey: string; label: string }> = {
stamp: { path: (id) => `/stamps/${id}/qrcode`, urlKey: "collectUrl", nameKey: "stampName", label: "Stamp" },
article: { path: (id) => `/articles/${id}/qrcode`, urlKey: "articleUrl", nameKey: "articleTitle", label: "Article" },
music: { path: (id) => `/music/${id}/qrcode`, urlKey: "musicUrl", nameKey: "musicTitle", label: "Music" },
};
type Props = {
open: boolean;
type: LinkType;
id: string | null;
onClose: () => void;
};
export default function QRCodeModal({ open, type, id, onClose }: Props) {
const cfg = CFG[type];
const [data, setData] = useState<{ qrDataUrl: string; url: string; name: string } | null>(null);
const [loading, setLoading] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const toast = useToast();
useEffect(() => {
if (!open || !id) {
setData(null);
return;
}
setLoading(true);
adminFetch<QRResponse>(cfg.path(id))
.then((res) =>
setData({
qrDataUrl: res.qrDataUrl,
url: res[cfg.urlKey],
name: res[cfg.nameKey],
}),
)
.finally(() => setLoading(false));
}, [open, id, cfg]);
useEffect(() => {
if (!data || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const img = new Image();
img.onload = () => {
const pad = 24;
const textH = 40;
canvas.width = img.width + pad * 2;
canvas.height = img.height + pad * 2 + textH;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, pad, pad);
ctx.fillStyle = "#4a4553";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.fillText(data.url, canvas.width / 2, img.height + pad * 2 + 14);
};
img.src = data.qrDataUrl;
}, [data]);
const handleCopy = async () => {
if (!data) return;
await copyText(data.url);
toast.show("链接已复制");
};
const handleDownload = () => {
if (!canvasRef.current || !data) return;
const link = document.createElement("a");
link.download = `${data.name}-二维码.png`;
link.href = canvasRef.current.toDataURL("image/png");
link.click();
};
return (
<Modal open={open} onClose={onClose} size="sm" eyebrow={cfg.label} title={data?.name ?? "点位链接"}>
<div className="px-7 py-7">
{loading || !data ? (
<div className="py-16 flex justify-center">
<div className="w-8 h-8 border-2 border-[var(--gold)] border-t-transparent rounded-full animate-spin" />
</div>
) : (
<>
<div className="relative mx-auto w-56 h-56">
<span className="absolute -top-1 -left-1 w-4 h-4 border-t-2 border-l-2 border-[var(--gold)]/50" />
<span className="absolute -top-1 -right-1 w-4 h-4 border-t-2 border-r-2 border-[var(--gold)]/50" />
<span className="absolute -bottom-1 -left-1 w-4 h-4 border-b-2 border-l-2 border-[var(--gold)]/50" />
<span className="absolute -bottom-1 -right-1 w-4 h-4 border-b-2 border-r-2 border-[var(--gold)]/50" />
<div
className="w-full h-full rounded-xl bg-white p-3"
style={{ boxShadow: "0 12px 40px rgba(16,16,30,0.12)" }}
>
<img src={data.qrDataUrl} alt="二维码" className="w-full h-full" />
</div>
</div>
<div className="mt-7 text-center">
<p className="text-[10px] tracking-[0.3em] uppercase text-[var(--text-muted)] mb-2">URL</p>
<p className="text-xs text-[var(--text-secondary)] break-all select-all font-mono leading-relaxed px-2">
{data.url}
</p>
</div>
<canvas ref={canvasRef} className="hidden" />
<div className="mt-7 flex gap-3">
<button
onClick={handleCopy}
className="flex-1 py-3 rounded-xl text-sm font-medium text-white transition-all hover:brightness-110"
style={{
backgroundColor: "var(--terracotta)",
boxShadow: "0 6px 18px rgba(199,91,57,0.3)",
}}
>
</button>
<button
onClick={handleDownload}
className="flex-1 py-3 rounded-xl text-sm font-medium border border-[var(--border-default)] text-[var(--text-secondary)] bg-white hover:bg-[var(--bg-paper)] transition-colors"
>
</button>
</div>
<p className="mt-5 text-[11px] text-[var(--text-muted)] text-center leading-relaxed">
NFC
</p>
</>
)}
</div>
</Modal>
);
}

View File

@@ -1,13 +1,14 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { adminFetch } from "./adminApi"; import { adminFetch } from "./adminApi";
import PageHeader, { LoadingBlock } from "./PageHeader";
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 = {
@@ -16,6 +17,12 @@ type Stats = {
redemptionCount: number; redemptionCount: number;
}; };
const STAT_CARDS: { key: keyof Stats; label: string; eyebrow: string; accent: string }[] = [
{ key: "userCount", label: "注册用户", eyebrow: "Users", accent: "var(--jade)" },
{ key: "collectionCount", label: "当前收集数", eyebrow: "Collected", accent: "var(--gold)" },
{ key: "redemptionCount", label: "累计兑换", eyebrow: "Redeemed", accent: "var(--terracotta)" },
];
export default function RedemptionLog() { export default function RedemptionLog() {
const [records, setRecords] = useState<RedemptionRecord[]>([]); const [records, setRecords] = useState<RedemptionRecord[]>([]);
const [stats, setStats] = useState<Stats | null>(null); const [stats, setStats] = useState<Stats | null>(null);
@@ -33,62 +40,120 @@ export default function RedemptionLog() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
if (loading) return <p className="text-gray-500">...</p>;
return ( return (
<div> <>
<h2 className="text-lg font-semibold text-gray-800 mb-4"></h2> <PageHeader
eyebrow="06 · Log"
title="兑换记录"
caption="账户、图章收集与兑换的完整轨迹"
/>
{/* Stats */} {loading ? (
{stats && ( <LoadingBlock />
<div className="grid grid-cols-3 gap-4 mb-6"> ) : (
{[ <>
{ label: "注册用户", value: stats.userCount }, {/* Stats */}
{ label: "当前收集数", value: stats.collectionCount }, {stats && (
{ label: "累计兑换", value: stats.redemptionCount }, <div className="grid grid-cols-3 gap-5 mb-8">
].map((s) => ( {STAT_CARDS.map((card, i) => (
<div key={s.label} className="bg-white rounded-lg border border-gray-200 p-4 text-center"> <div
<p className="text-2xl font-semibold text-gray-800">{s.value}</p> key={card.key}
<p className="text-xs text-gray-500 mt-1">{s.label}</p> className="relative rounded-2xl bg-white/80 border border-[var(--border-muted)] p-6 overflow-hidden animate-admin-row"
style={{
animationDelay: `${i * 0.08}s`,
boxShadow: "0 1px 2px rgba(16,16,30,0.04), 0 8px 24px rgba(16,16,30,0.04)",
}}
>
<span
className="absolute top-0 left-0 right-0 h-0.5"
style={{ backgroundColor: card.accent }}
/>
<div className="flex items-center gap-2 mb-3">
<span className="block w-4 h-px" style={{ backgroundColor: card.accent, opacity: 0.7 }} />
<span
className="text-[10px] tracking-[0.3em] uppercase"
style={{
fontFamily: "'Playfair Display', serif",
fontWeight: 500,
color: card.accent,
}}
>
{card.eyebrow}
</span>
</div>
<p
className="text-[44px] leading-none text-[var(--text-primary)]"
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 600, letterSpacing: "-0.02em" }}
>
{stats[card.key]}
</p>
<p className="text-sm text-[var(--text-secondary)] mt-2">{card.label}</p>
</div>
))}
</div> </div>
))} )}
</div>
)}
{/* Records table */} {/* Records */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden"> <div className="flex items-center gap-3 mb-4">
<table className="w-full text-sm"> <span className="block w-5 h-px bg-[var(--gold)]/40" />
<thead className="bg-gray-50 border-b border-gray-200"> <span
<tr> className="text-[10px] tracking-[0.3em] uppercase text-[var(--text-muted)]"
<th className="text-left px-4 py-3 font-medium text-gray-600"></th> style={{ fontFamily: "'Playfair Display', serif" }}
<th className="text-left px-4 py-3 font-medium text-gray-600"></th> >
<th className="text-left px-4 py-3 font-medium text-gray-600"></th> Ledger
<th className="text-center px-4 py-3 font-medium text-gray-600"></th> </span>
<th className="text-right px-4 py-3 font-medium text-gray-600"></th> </div>
</tr>
</thead> <TableCard>
<tbody> {records.length === 0 ? (
{records.map((r) => ( <div className="py-16 flex flex-col items-center gap-3">
<tr key={r.id} className="border-b border-gray-100"> <div className="w-14 h-14 rounded-full flex items-center justify-center bg-[var(--bg-paper)]">
<td className="px-4 py-3 text-gray-800">{r.user.username}</td> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--gold)" strokeWidth="1.6">
<td className="px-4 py-3 text-gray-500">{r.user.phone}</td> <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<td className="px-4 py-3 text-gray-700">{r.rule.name}</td> <path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" />
<td className="px-4 py-3 text-center text-gray-500">{r.stampCount}</td> </svg>
<td className="px-4 py-3 text-right text-gray-500"> </div>
{new Date(r.redeemedAt).toLocaleString("zh-CN")} <p className="text-sm text-[var(--text-muted)]"></p>
</td> </div>
</tr> ) : (
))} <table className="w-full">
{records.length === 0 && ( <thead>
<tr> <TableHeadRow cols={["用户", "手机号", "图章", "奖品", "时间"]} />
<td colSpan={5} className="px-4 py-8 text-center text-gray-400"> </thead>
<tbody>
</td> {records.map((r, i) => (
</tr> <tr
key={r.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">
<span className="text-[15px] font-medium text-[var(--text-primary)]">
{r.user.username}
</span>
</td>
<td className="px-5 py-4">
<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.stampName}</span>
</td>
<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">
{new Date(r.redeemedAt).toLocaleString("zh-CN")}
</span>
</td>
</tr>
))}
</tbody>
</table>
)} )}
</tbody> </TableCard>
</table> </>
</div> )}
</div> </>
); );
} }

View File

@@ -1,138 +0,0 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { adminFetch } from "./adminApi";
type Rule = {
id: string;
name: string;
description: string | null;
threshold: number;
enabled: boolean;
sortOrder: number;
};
export default function RuleForm() {
const { id } = useParams();
const navigate = useNavigate();
const isEdit = !!id;
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("");
useEffect(() => {
if (!id) 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);
}
});
}, [id]);
const handleSave = async () => {
setError("");
if (!name.trim()) {
setError("请输入奖品名称");
return;
}
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) });
}
navigate("/admin/rules");
} catch (e) {
setError(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
};
return (
<div className="max-w-xl">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
{isEdit ? "编辑兑换规则" : "添加兑换规则"}
</h2>
<div className="bg-white rounded-lg border border-gray-200 p-5 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm
focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm
focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="number"
min={1}
value={threshold}
onChange={(e) => setThreshold(Number(e.target.value))}
className="w-24 px-3 py-2 border border-gray-300 rounded-md text-sm
focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="number"
value={sortOrder}
onChange={(e) => setSortOrder(Number(e.target.value))}
className="w-24 px-3 py-2 border border-gray-300 rounded-md text-sm
focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-3 pt-2">
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md
hover:bg-blue-700 disabled:opacity-50"
>
{saving ? "保存中..." : "保存"}
</button>
<button
onClick={() => navigate("/admin/rules")}
className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded-md hover:bg-gray-50"
>
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,107 +0,0 @@
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { adminFetch } from "./adminApi";
type Rule = {
id: string;
name: string;
description: string | null;
threshold: number;
enabled: boolean;
sortOrder: number;
};
export default function RuleList() {
const [rules, setRules] = useState<Rule[]>([]);
const [loading, setLoading] = useState(true);
const fetchRules = async () => {
setLoading(true);
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;
await adminFetch(`/rules/${id}`, { method: "DELETE" });
fetchRules();
};
const handleToggle = async (id: string, enabled: boolean) => {
await adminFetch(`/rules/${id}`, {
method: "PUT",
body: JSON.stringify({ enabled: !enabled }),
});
fetchRules();
};
if (loading) return <p className="text-gray-500">...</p>;
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-800"></h2>
<Link
to="/admin/rules/new"
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
</Link>
</div>
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-gray-600"></th>
<th className="text-left px-4 py-3 font-medium text-gray-600"></th>
<th className="text-center px-4 py-3 font-medium text-gray-600"></th>
<th className="text-center px-4 py-3 font-medium text-gray-600"></th>
<th className="text-right px-4 py-3 font-medium text-gray-600"></th>
</tr>
</thead>
<tbody>
{rules.map((rule) => (
<tr key={rule.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-4 py-3 text-gray-800">{rule.name}</td>
<td className="px-4 py-3 text-gray-500 max-w-[250px] truncate">{rule.description || "—"}</td>
<td className="px-4 py-3 text-center font-medium text-gray-700">{rule.threshold}</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => handleToggle(rule.id, rule.enabled)}
className={`px-2 py-0.5 rounded text-xs ${
rule.enabled ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"
}`}
>
{rule.enabled ? "启用" : "禁用"}
</button>
</td>
<td className="px-4 py-3 text-right space-x-2">
<Link to={`/admin/rules/${rule.id}/edit`} className="text-blue-600 hover:underline">
</Link>
<button onClick={() => handleDelete(rule.id, rule.name)} className="text-red-500 hover:underline">
</button>
</td>
</tr>
))}
{rules.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -1,6 +1,16 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom"; import Modal from "./Modal";
import { adminFetch } from "./adminApi"; 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 = { type Stamp = {
id: string; id: string;
@@ -10,13 +20,19 @@ type Stamp = {
imageGrey: string; imageGrey: string;
sortOrder: number; sortOrder: number;
enabled: boolean; enabled: boolean;
prize: Prize | null;
}; };
export default function StampForm() { type Props = {
const { id } = useParams(); open: boolean;
const navigate = useNavigate(); id: string | null;
const isEdit = !!id; onClose: () => void;
onSaved: () => void;
};
export default function StampForm({ open, id, onClose, onSaved }: Props) {
const toast = useToast();
const [currentId, setCurrentId] = useState<string | null>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const [sortOrder, setSortOrder] = useState(0); const [sortOrder, setSortOrder] = useState(0);
@@ -25,8 +41,29 @@ export default function StampForm() {
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;
useEffect(() => { useEffect(() => {
if (!id) return; if (!open) return;
setCurrentId(id);
setError("");
if (!id) {
setName("");
setNote("");
setSortOrder(0);
setImageColor("");
setImageGrey("");
setPrizeName("");
setPrizeDescription("");
setPrizeStock(0);
setPrizeEnabled(true);
return;
}
adminFetch<Stamp[]>("/stamps").then((stamps) => { adminFetch<Stamp[]>("/stamps").then((stamps) => {
const stamp = stamps.find((s) => s.id === id); const stamp = stamps.find((s) => s.id === id);
if (stamp) { if (stamp) {
@@ -35,24 +72,42 @@ export default function StampForm() {
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);
}
} }
}); });
}, [id]); }, [open, id]);
const handleUpload = async (file: File, field: "imageColor" | "imageGrey") => { const handleUpload = async (file: File, field: "imageColor" | "imageGrey") => {
if (!id) { if (!currentId) {
setError("请先保存图章后再上传图片"); setError("请先保存图章后再上传图片");
return; return;
} }
setError("");
const formData = new FormData(); const formData = new FormData();
formData.append("image", file); formData.append("image", file);
formData.append("field", field); formData.append("field", field);
const data = await adminFetch<{ path: string }>(`/stamps/${id}/upload`, { try {
method: "POST", const data = await adminFetch<{ path: string }>(`/stamps/${currentId}/upload`, {
body: formData, method: "POST",
}); body: formData,
if (field === "imageColor") setImageColor(data.path); });
else setImageGrey(data.path); if (field === "imageColor") setImageColor(data.path);
else setImageGrey(data.path);
toast.show("图片已上传");
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : "上传失败");
}
}; };
const handleSave = async () => { const handleSave = async () => {
@@ -61,22 +116,41 @@ export default function StampForm() {
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 };
if (isEdit) { if (isEdit) {
await adminFetch(`/stamps/${id}`, { await adminFetch(`/stamps/${currentId}`, {
method: "PUT", method: "PUT",
body: JSON.stringify({ name: name.trim(), note: note.trim() || undefined, sortOrder }), 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();
} else { } else {
const stamp = await adminFetch<Stamp>("/stamps", { const stamp = await adminFetch<Stamp>("/stamps", {
method: "POST", method: "POST",
body: JSON.stringify({ name: name.trim(), note: note.trim() || undefined, sortOrder }), body: JSON.stringify(payload),
}); });
navigate(`/admin/stamps/${stamp.id}/edit`, { replace: true }); setCurrentId(stamp.id);
return; toast.show("已创建,现在可以上传图片与配置奖品");
onSaved();
} }
navigate("/admin/stamps");
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "保存失败"); setError(e instanceof Error ? e.message : "保存失败");
} finally { } finally {
@@ -85,100 +159,170 @@ export default function StampForm() {
}; };
return ( return (
<div className="max-w-xl"> <Modal
<h2 className="text-lg font-semibold text-gray-800 mb-4"> open={open}
{isEdit ? "编辑图章" : "添加图章"} onClose={onClose}
</h2> size="md"
eyebrow={isEdit ? "Edit Stamp" : "New Stamp"}
<div className="bg-white rounded-lg border border-gray-200 p-5 space-y-4"> title={isEdit ? "编辑图章" : "添加图章"}
<div> subtitle={isEdit ? "调整信息、上传图片并配置关联奖品" : "先保存基础信息,再上传图片与配置奖品"}
<label className="block text-sm font-medium text-gray-700 mb-1"></label> >
<div className="px-7 py-6 space-y-5">
<Field label="名称" required>
<input <input
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm placeholder="如:朝天宫"
focus:outline-none focus:ring-1 focus:ring-blue-500" className={fieldCls}
/> />
</div> </Field>
<div> <Field label="品牌说明" hint="选填,展示在收集弹窗与集章册详情中">
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<textarea <textarea
value={note} value={note}
onChange={(e) => setNote(e.target.value)} onChange={(e) => setNote(e.target.value)}
rows={2} rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm placeholder="例:品牌定位、特色亮点一句话"
focus:outline-none focus:ring-1 focus:ring-blue-500" className={fieldCls + " resize-none"}
/> />
</div> </Field>
<div> <Field label="排序" hint="数字小的在前">
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input <input
type="number" type="number"
value={sortOrder} value={sortOrder}
onChange={(e) => setSortOrder(Number(e.target.value))} onChange={(e) => setSortOrder(Number(e.target.value))}
className="w-24 px-3 py-2 border border-gray-300 rounded-md text-sm className={fieldCls + " w-28"}
focus:outline-none focus:ring-1 focus:ring-blue-500"
/> />
</div> </Field>
{/* Image uploads - only available after saving */} {isEdit ? (
{isEdit && ( <>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-5">
<div> <ImageSlot
<label className="block text-sm font-medium text-gray-700 mb-1"></label> label="彩色图章"
{imageColor && ( kind="color"
<div className="w-20 h-20 rounded-full bg-white border border-gray-200 overflow-hidden flex items-center justify-center shadow-sm mb-2"> image={imageColor}
<img src={imageColor} alt="彩色" className="w-[92%] h-[92%] object-contain" /> onUpload={(f) => handleUpload(f, "imageColor")}
</div> />
)} <ImageSlot
<input label="灰色图章"
type="file" kind="grey"
accept="image/*" image={imageGrey}
onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0], "imageColor")} onUpload={(f) => handleUpload(f, "imageGrey")}
className="text-xs text-gray-500"
/> />
</div> </div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label> <div className="pt-4 mt-2 border-t border-dashed border-[var(--border-muted)]">
{imageGrey && ( <div className="flex items-baseline justify-between mb-3">
<div className="w-20 h-20 rounded-full bg-white border border-gray-200 overflow-hidden flex items-center justify-center shadow-sm mb-2"> <span className="text-[13px] font-medium text-[var(--text-secondary)]"></span>
<img src={imageGrey} alt="灰色" className="w-[92%] h-[92%] object-contain" /> <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="如:品牌 8 折券 / 定制书签"
className={fieldCls}
/>
</Field>
<Field label="奖品说明" hint="展示在收集弹窗与兑换页的规则文案">
<textarea
value={prizeDescription}
onChange={(e) => setPrizeDescription(e.target.value)}
rows={3}
placeholder="例:进店消费 19.9 元,可赠送人气爆款谷物锅巴 1 份。"
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>
<input
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0], "imageGrey")}
className="text-xs text-gray-500"
/>
</div> </div>
</div> </>
) : (
<HintRow text="保存基础信息后,即可上传图章图片并配置关联奖品" />
)} )}
{!isEdit && ( {error && <ErrorRow text={error} />}
<p className="text-xs text-gray-400"></p> </div>
)}
{error && <p className="text-sm text-red-500">{error}</p>} <FormFooter onCancel={onClose} onSave={handleSave} saving={saving} primaryLabel={isEdit ? "保存" : "创建"} />
</Modal>
);
}
<div className="flex gap-3 pt-2"> function ImageSlot({
<button label,
onClick={handleSave} kind,
disabled={saving} image,
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md onUpload,
hover:bg-blue-700 disabled:opacity-50" }: {
> label: string;
{saving ? "保存中..." : "保存"} kind: "color" | "grey";
</button> image: string;
<button onUpload: (f: File) => void;
onClick={() => navigate("/admin/stamps")} }) {
className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded-md hover:bg-gray-50" return (
> <div>
<label className="flex items-baseline justify-between mb-1.5">
</button> <span className="text-[13px] font-medium text-[var(--text-secondary)]">{label}</span>
<span
className="text-[9px] tracking-[0.3em] uppercase text-[var(--gold)]"
style={{ fontFamily: "'Playfair Display', serif" }}
>
{kind === "color" ? "Color" : "Grey"}
</span>
</label>
<div className="rounded-xl border border-[var(--border-muted)] bg-white p-4 flex flex-col items-center gap-3">
<div className="w-20 h-20 rounded-full bg-[var(--bg-paper)] overflow-hidden flex items-center justify-center border border-[var(--border-muted)]">
{image ? (
<img src={image} alt="" className="w-[92%] h-[92%] object-contain" />
) : (
<span className="text-[10px] tracking-[0.2em] uppercase text-[var(--text-muted)]/60">
No image
</span>
)}
</div> </div>
<label className="cursor-pointer text-[12px] text-[var(--text-secondary)] hover:text-[var(--terracotta)] transition-colors">
<input
type="file"
accept="image/*"
onChange={(e) => e.target.files?.[0] && onUpload(e.target.files[0])}
className="hidden"
/>
<span className="underline underline-offset-2 decoration-dotted">
{image ? "更换图片" : "选择图片"}
</span>
</label>
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +1,20 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { adminFetch } from "./adminApi"; import { adminFetch } from "./adminApi";
import { useToast } from "./Toast";
import { copyItemLink } from "./utils";
import StampForm from "./StampForm";
import QRCodeModal from "./QRCodeModal";
import PageHeader, {
PrimaryButton,
StatusChip,
ActionButton,
EmptyState,
LoadingBlock,
IconEdit,
IconCopy,
IconQR,
IconDelete,
} from "./PageHeader";
type Stamp = { type Stamp = {
id: string; id: string;
@@ -10,14 +24,24 @@ 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() {
const toast = useToast();
const [stamps, setStamps] = useState<Stamp[]>([]); const [stamps, setStamps] = useState<Stamp[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [formState, setFormState] = useState<{ open: boolean; id: string | null }>({ open: false, id: null });
const [qrState, setQrState] = useState<{ open: boolean; id: string | null }>({ open: false, id: null });
const fetchStamps = async () => { const fetchStamps = async () => {
setLoading(true);
try { try {
const data = await adminFetch<Stamp[]>("/stamps"); const data = await adminFetch<Stamp[]>("/stamps");
setStamps(data); setStamps(data);
@@ -26,12 +50,19 @@ export default function StampList() {
} }
}; };
useEffect(() => { fetchStamps(); }, []); useEffect(() => {
fetchStamps();
}, []);
const handleDelete = async (id: string, name: string) => { const handleDelete = async (id: string, name: string) => {
if (!confirm(`确定删除图章「${name}」?`)) return; if (!confirm(`确定删除图章「${name}」?`)) return;
await adminFetch(`/stamps/${id}`, { method: "DELETE" }); try {
fetchStamps(); await adminFetch(`/stamps/${id}`, { method: "DELETE" });
toast.show("已删除");
fetchStamps();
} catch (e) {
toast.show(e instanceof Error ? e.message : "删除失败", "error");
}
}; };
const handleToggle = async (id: string, enabled: boolean) => { const handleToggle = async (id: string, enabled: boolean) => {
@@ -42,78 +73,178 @@ export default function StampList() {
fetchStamps(); fetchStamps();
}; };
if (loading) return <p className="text-gray-500">...</p>; const handleCopyLink = async (id: string) => {
try {
await copyItemLink("stamp", id);
toast.show("链接已复制");
} catch {
toast.show("复制失败", "error");
}
};
return ( return (
<div> <>
<div className="flex items-center justify-between mb-4"> <PageHeader
<h2 className="text-lg font-semibold text-gray-800"></h2> eyebrow="02 · Stamps"
<Link title="图章管理"
to="/admin/stamps/new" caption="收集图章、生成点位 NFC 链接与备用二维码"
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700" action={
> <PrimaryButton onClick={() => setFormState({ open: true, id: null })}></PrimaryButton>
}
</Link> />
</div>
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden"> {loading ? (
<table className="w-full text-sm"> <LoadingBlock />
<thead className="bg-gray-50 border-b border-gray-200"> ) : (
<tr> <TableCard>
<th className="text-left px-4 py-3 font-medium text-gray-600"></th> {stamps.length === 0 ? (
<th className="text-left px-4 py-3 font-medium text-gray-600"></th> <EmptyState
<th className="text-left px-4 py-3 font-medium text-gray-600"></th> message="尚未创建图章"
<th className="text-center px-4 py-3 font-medium text-gray-600"></th> action={
<th className="text-center px-4 py-3 font-medium text-gray-600"></th> <PrimaryButton onClick={() => setFormState({ open: true, id: null })}>
<th className="text-right px-4 py-3 font-medium text-gray-600"></th>
</tr> </PrimaryButton>
</thead> }
<tbody> />
{stamps.map((stamp) => ( ) : (
<tr key={stamp.id} className="border-b border-gray-100 hover:bg-gray-50"> <table className="w-full">
<td className="px-4 py-3"> <thead>
<div className="w-10 h-10 rounded-full bg-white border border-gray-200 overflow-hidden flex items-center justify-center shadow-sm"> <TableHeadRow cols={["图章", "名称 · 备注", "奖品 · 库存", "排序", "状态", "操作"]} />
{stamp.imageColor && ( </thead>
<img src={stamp.imageColor} alt="" className="w-[92%] h-[92%] object-contain" /> <tbody>
)} {stamps.map((stamp, i) => (
</div> <tr
</td> key={stamp.id}
<td className="px-4 py-3 text-gray-800">{stamp.name}</td> className="border-t border-[var(--border-muted)] hover:bg-white/60 transition-colors animate-admin-row"
<td className="px-4 py-3 text-gray-500 max-w-[200px] truncate">{stamp.note || "—"}</td> style={{ animationDelay: `${Math.min(i, 12) * 0.03}s` }}
<td className="px-4 py-3 text-center text-gray-500">{stamp.sortOrder}</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => handleToggle(stamp.id, stamp.enabled)}
className={`px-2 py-0.5 rounded text-xs ${
stamp.enabled ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"
}`}
> >
{stamp.enabled ? "启用" : "禁用"} <td className="px-5 py-4 w-[90px]">
</button> <div className="w-12 h-12 rounded-full bg-white border border-[var(--border-muted)] overflow-hidden flex items-center justify-center shadow-sm">
</td> {stamp.imageColor ? (
<td className="px-4 py-3 text-right space-x-2"> <img src={stamp.imageColor} alt="" className="w-[92%] h-[92%] object-contain" />
<Link to={`/admin/stamps/${stamp.id}/edit`} className="text-blue-600 hover:underline"> ) : (
<span className="text-[9px] tracking-[0.2em] uppercase text-[var(--text-muted)]/60">
</Link> No image
<Link to={`/admin/stamps/${stamp.id}/qrcode`} className="text-blue-600 hover:underline"> </span>
)}
</Link> </div>
<button onClick={() => handleDelete(stamp.id, stamp.name)} className="text-red-500 hover:underline"> </td>
<td className="px-5 py-4">
</button> <p className="text-[15px] font-medium text-[var(--text-primary)]">{stamp.name}</p>
</td> {stamp.note && (
</tr> <p className="text-xs text-[var(--text-muted)] mt-0.5 max-w-[360px] truncate">
))} {stamp.note}
{stamps.length === 0 && ( </p>
<tr> )}
<td colSpan={6} className="px-4 py-8 text-center text-gray-400"> </td>
<td className="px-5 py-4 w-[220px]">
</td> {stamp.prize ? (
</tr> <>
)} <p className="text-sm text-[var(--text-primary)] truncate max-w-[200px]">
</tbody> {stamp.prize.name}
</table> </p>
</div> <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)]"
style={{ fontFamily: "'Playfair Display', serif" }}
>
{stamp.sortOrder}
</span>
</td>
<td className="px-5 py-4 text-center w-[110px]">
<StatusChip enabled={stamp.enabled} onClick={() => handleToggle(stamp.id, stamp.enabled)} />
</td>
<td className="px-5 py-4 w-[180px]">
<div className="flex items-center justify-end gap-1">
<ActionButton title="编辑" onClick={() => setFormState({ open: true, id: stamp.id })}>
{IconEdit}
</ActionButton>
<ActionButton title="复制链接" onClick={() => handleCopyLink(stamp.id)}>
{IconCopy}
</ActionButton>
<ActionButton title="二维码" onClick={() => setQrState({ open: true, id: stamp.id })}>
{IconQR}
</ActionButton>
<ActionButton
title="删除"
variant="danger"
onClick={() => handleDelete(stamp.id, stamp.name)}
>
{IconDelete}
</ActionButton>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</TableCard>
)}
<StampForm
open={formState.open}
id={formState.id}
onClose={() => setFormState({ open: false, id: null })}
onSaved={fetchStamps}
/>
<QRCodeModal
open={qrState.open}
type="stamp"
id={qrState.id}
onClose={() => setQrState({ open: false, id: null })}
/>
</>
);
}
/* ————— Shared table pieces (also used by other list pages) ————— */
export function TableCard({ children }: { children: React.ReactNode }) {
return (
<div
className="rounded-2xl bg-white/70 border border-[var(--border-muted)] overflow-hidden"
style={{ boxShadow: "0 1px 2px rgba(16,16,30,0.04), 0 8px 24px rgba(16,16,30,0.04)" }}
>
{children}
</div> </div>
); );
} }
export function TableHeadRow({ cols }: { cols: string[] }) {
return (
<tr className="bg-[var(--bg-paper)]/60">
{cols.map((c, i) => (
<th
key={c}
className={`px-5 py-3.5 text-[10px] tracking-[0.25em] uppercase text-[var(--text-muted)] font-medium ${
i === cols.length - 1 ? "text-right" : i === 0 ? "text-left" : "text-left"
}`}
style={{ fontFamily: "'Playfair Display', serif" }}
>
{i === cols.length - 1 ? <span className="inline-block pr-2">{c}</span> : c}
</th>
))}
</tr>
);
}

View File

@@ -1,128 +0,0 @@
import { useState, useEffect, useRef } from "react";
import { useParams, Link } from "react-router-dom";
import { adminFetch } from "./adminApi";
type QRData = {
qrDataUrl: string;
collectUrl: string;
stampName: string;
};
export default function StampQRCode() {
const { id } = useParams();
const [data, setData] = useState<QRData | null>(null);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!id) return;
adminFetch<QRData>(`/stamps/${id}/qrcode`)
.then(setData)
.finally(() => setLoading(false));
}, [id]);
// Render composite image (QR code + URL text) to canvas
useEffect(() => {
if (!data || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d")!;
const img = new Image();
img.onload = () => {
const padding = 20;
const textHeight = 40;
canvas.width = img.width + padding * 2;
canvas.height = img.height + padding * 2 + textHeight;
// White background
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// QR code
ctx.drawImage(img, padding, padding);
// URL text below
ctx.fillStyle = "#666666";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.fillText(data.collectUrl, canvas.width / 2, img.height + padding * 2 + 12);
};
img.src = data.qrDataUrl;
}, [data]);
const handleDownload = () => {
if (!canvasRef.current || !data) return;
const link = document.createElement("a");
link.download = `${data.stampName}-二维码.png`;
link.href = canvasRef.current.toDataURL("image/png");
link.click();
};
const handleCopy = async () => {
if (!data) return;
try {
await navigator.clipboard.writeText(data.collectUrl);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
} catch {
// Fallback for older browsers / insecure context
const ta = document.createElement("textarea");
ta.value = data.collectUrl;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
}
};
if (loading) return <p className="text-gray-500">...</p>;
if (!data) return <p className="text-gray-500"></p>;
return (
<div className="max-w-md">
<div className="flex items-center gap-2 mb-4">
<Link to="/admin/stamps" className="text-gray-400 hover:text-gray-600">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
</Link>
<h2 className="text-lg font-semibold text-gray-800">{data.stampName} </h2>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-6 text-center space-y-4">
{/* QR code display */}
<div className="inline-block">
<img src={data.qrDataUrl} alt="二维码" className="w-64 h-64" />
</div>
{/* URL display */}
<p className="text-xs text-gray-500 break-all select-all">{data.collectUrl}</p>
{/* Hidden canvas for composite download */}
<canvas ref={canvasRef} className="hidden" />
<div className="flex flex-wrap items-center justify-center gap-3">
<button
onClick={handleCopy}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 min-w-[160px]"
>
{copied ? "已复制 ✓" : "复制链接(写入 NFC"}
</button>
<button
onClick={handleDownload}
className="px-4 py-2 border border-gray-300 text-gray-700 text-sm rounded-md hover:bg-gray-50"
>
</button>
</div>
<p className="text-xs text-gray-400 pt-1">
NFC
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
type ToastKind = "success" | "error";
type ToastItem = { id: number; message: string; kind: ToastKind };
type ToastContextValue = { show: (message: string, kind?: ToastKind) => void };
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const show = useCallback((message: string, kind: ToastKind = "success") => {
const id = Date.now() + Math.random();
setToasts((list) => [...list, { id, message, kind }]);
setTimeout(() => setToasts((list) => list.filter((t) => t.id !== id)), 2400);
}, []);
return (
<ToastContext.Provider value={{ show }}>
{children}
<div className="fixed top-5 left-1/2 -translate-x-1/2 z-[70] pointer-events-none flex flex-col items-center gap-2">
{toasts.map((t) => (
<div
key={t.id}
className="animate-toast-in pointer-events-auto flex items-center gap-2 pl-3 pr-4 py-2 rounded-full text-[13px] font-medium"
style={{
backgroundColor: t.kind === "success" ? "var(--text-primary)" : "var(--terracotta)",
color: "#fff",
boxShadow: "0 12px 32px rgba(16,16,30,0.35)",
}}
>
<span
className="inline-block w-1.5 h-1.5 rounded-full"
style={{
backgroundColor: t.kind === "success" ? "var(--gold)" : "#fff",
boxShadow: t.kind === "success" ? "0 0 8px rgba(212,165,116,0.6)" : "none",
}}
/>
{t.message}
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within ToastProvider");
return ctx;
}

View File

@@ -0,0 +1,151 @@
import { useEffect, useMemo, useState } from "react";
import { adminFetch } from "./adminApi";
import PageHeader, { LoadingBlock } from "./PageHeader";
import { TableCard, TableHeadRow } from "./StampList";
type User = {
id: string;
username: string;
phone: string;
createdAt: string;
collectionCount: number;
redemptionCount: number;
};
export default function UsersList() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState("");
useEffect(() => {
adminFetch<User[]>("/users")
.then(setUsers)
.finally(() => setLoading(false));
}, []);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return users;
return users.filter(
(u) => u.username.toLowerCase().includes(q) || u.phone.includes(q),
);
}, [users, query]);
return (
<>
<PageHeader
eyebrow="06 · Users"
title="用户管理"
caption={`${users.length} 位注册访客(只读展示)`}
action={
<div className="relative">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-[var(--text-muted)] pointer-events-none"
>
<circle cx="11" cy="11" r="7" />
<path d="M21 21l-4.35-4.35" />
</svg>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索用户名或手机号"
className="pl-9 pr-4 py-2.5 bg-white border border-[var(--border-default)] rounded-lg text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)]/60 focus:outline-none focus:border-[var(--gold)] focus:ring-2 focus:ring-[var(--gold)]/15 transition-all w-64"
/>
</div>
}
/>
{loading ? (
<LoadingBlock />
) : (
<TableCard>
{filtered.length === 0 ? (
<div className="py-16 flex flex-col items-center gap-3">
<div className="w-14 h-14 rounded-full flex items-center justify-center bg-[var(--bg-paper)]">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--gold)" strokeWidth="1.6">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
</svg>
</div>
<p className="text-sm text-[var(--text-muted)]">
{query ? `没有匹配「${query}」的用户` : "尚无注册用户"}
</p>
</div>
) : (
<table className="w-full">
<thead>
<TableHeadRow cols={["用户名", "手机号", "已收集", "已兑换", "注册时间"]} />
</thead>
<tbody>
{filtered.map((u, i) => (
<tr
key={u.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">
<div className="flex items-center gap-3">
<div
className="w-9 h-9 rounded-full flex items-center justify-center shrink-0 border border-[var(--border-muted)]"
style={{ backgroundColor: "rgba(45,106,79,0.08)" }}
>
<span
className="text-[13px] text-[var(--jade)]"
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 600 }}
>
{u.username.slice(0, 1).toUpperCase()}
</span>
</div>
<span className="text-[15px] font-medium text-[var(--text-primary)]">
{u.username}
</span>
</div>
</td>
<td className="px-5 py-4">
<span className="text-sm text-[var(--text-secondary)] font-mono">{u.phone}</span>
</td>
<td className="px-5 py-4 text-center w-[140px]">
<CountBadge value={u.collectionCount} accent="var(--gold)" suffix="枚" />
</td>
<td className="px-5 py-4 text-center w-[140px]">
<CountBadge value={u.redemptionCount} accent="var(--terracotta)" suffix="次" />
</td>
<td className="px-5 py-4 text-right w-[200px]">
<span className="text-xs text-[var(--text-muted)] font-mono">
{new Date(u.createdAt).toLocaleString("zh-CN")}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</TableCard>
)}
</>
);
}
function CountBadge({ value, accent, suffix }: { value: number; accent: string; suffix: string }) {
const isZero = value === 0;
return (
<span
className="inline-flex items-baseline gap-1"
style={{ color: isZero ? "var(--text-muted)" : accent }}
>
<span
className="text-lg leading-none"
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 600 }}
>
{value}
</span>
<span className="text-[10px] tracking-[0.2em] uppercase opacity-70">{suffix}</span>
</span>
);
}

View File

@@ -0,0 +1,36 @@
import { adminFetch } from "./adminApi";
export type LinkType = "stamp" | "article" | "music";
const LINK_CFG: Record<LinkType, { path: (id: string) => string; urlKey: string }> = {
stamp: { path: (id) => `/stamps/${id}/qrcode`, urlKey: "collectUrl" },
article: { path: (id) => `/articles/${id}/qrcode`, urlKey: "articleUrl" },
music: { path: (id) => `/music/${id}/qrcode`, urlKey: "musicUrl" },
};
export async function fetchItemUrl(type: LinkType, id: string): Promise<string> {
const cfg = LINK_CFG[type];
const data = await adminFetch<Record<string, string>>(cfg.path(id));
return data[cfg.urlKey];
}
export async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text);
} catch {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
}
export async function copyItemLink(type: LinkType, id: string): Promise<string> {
const url = await fetchItemUrl(type, id);
await copyText(url);
return url;
}

View File

@@ -1,26 +1,35 @@
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 = "uncollected" | "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.collected) return "uncollected";
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,45 +41,67 @@ 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 "uncollected":
return "前往点位收集";
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();
}} }}
> >
<div className="w-full max-w-lg bg-[var(--bg-cream)] rounded-t-2xl p-6 pb-10 animate-slide-up safe-bottom relative overflow-hidden"> <div className="w-full max-w-lg bg-[var(--bg-cream)] rounded-t-2xl p-6 pb-10 animate-slide-up safe-bottom relative overflow-hidden">
<div className="flex items-center justify-between mb-5"> <div className="flex items-center justify-between mb-5">
<h3 className="text-lg font-semibold text-[var(--text-primary)]"></h3> <h3 className="text-lg font-semibold text-[var(--text-primary)]">
{mode === "uncollected" ? "品牌权益" : "兑换奖品"}
</h3>
<button onClick={onClose} className="text-[var(--text-muted)] p-1"> <button onClick={onClose} className="text-[var(--text-muted)] p-1">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 6L6 18M6 6l12 12" /> <path d="M18 6L6 18M6 6l12 12" />
@@ -78,57 +109,99 @@ 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={{
<p className="text-sm text-[var(--terracotta)] mb-3">{error}</p> boxShadow: stamp.collected
)} ? "0 2px 6px rgba(212,165,116,0.25), inset 0 0 0 1px rgba(212,165,116,0.15)"
: "0 1px 3px rgba(0,0,0,0.05)",
<div className="space-y-3"> }}
{rules.map((rule) => { >
const canRedeem = collectedCount >= rule.threshold; <img
return ( src={stamp.collected ? stamp.imageColor : stamp.imageGrey}
<div alt={stamp.name}
key={rule.id} className="w-[92%] h-[92%] object-contain"
className="flex items-center justify-between p-4 rounded-xl border" style={{ opacity: stamp.collected ? 1 : 0.6 }}
style={{ />
borderColor: canRedeem ? "var(--jade)" : "var(--border-muted)", </div>
backgroundColor: canRedeem ? "rgba(45, 106, 79, 0.05)" : "white", <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>
<div className="flex-1 min-w-0 mr-3"> {mode === "uncollected" ? (
<p className="text-sm font-medium text-[var(--text-primary)] truncate"> <p className="text-xs text-[var(--text-muted)] mt-0.5"></p>
{rule.name} ) : stamp.collectedAt ? (
</p> <p className="text-xs text-[var(--text-muted)] mt-0.5">
{rule.description && ( {new Date(stamp.collectedAt).toLocaleDateString("zh-CN")}
<p className="text-xs text-[var(--text-muted)] mt-0.5 truncate">{rule.description}</p> </p>
)} ) : null}
<p className="text-xs text-[var(--text-muted)] mt-1"> </div>
{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>
{/* Brand description (optional) */}
{stamp.note && (
<div className="rounded-xl border border-[var(--border-muted)] bg-white p-3.5 mb-3">
<p className="text-[10px] tracking-[0.2em] text-[var(--gold)] uppercase mb-1">Brand</p>
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">{stamp.note}</p>
</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-2">Reward</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-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 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 === "uncollected" && (
<p className="text-xs text-[var(--text-muted)] text-center mb-4 leading-relaxed">
线 NFC
</p>
)}
{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 +211,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 +234,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-2">Reward</p>
<p className="text-sm font-semibold text-[var(--text-primary)]">{confirmRule.name}</p> <p className="text-sm font-medium text-[var(--text-primary)] mb-1">{prize.name}</p>
{confirmRule.description && ( {prize.description && (
<p className="text-xs text-[var(--text-muted)] mt-0.5">{confirmRule.description}</p> <p className="text-sm text-[var(--text-primary)] leading-relaxed">{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 +272,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>

View File

@@ -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

View File

@@ -16,6 +16,7 @@ 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}
redeemed={stamp.redeemed}
onClick={() => onStampClick?.(stamp)} onClick={() => onStampClick?.(stamp)}
/> />
))} ))}

View File

@@ -1,26 +1,28 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import type { PrizeInfo } from "@stamp/shared";
type StampPopupProps = { type StampPopupProps = {
name: string; name: string;
imageColor: string; imageColor: string;
note?: string | null; note?: string | null;
prize?: PrizeInfo | null;
status: "preview" | "collected" | "already"; status: "preview" | "collected" | "already";
onCollect?: () => void; onCollect?: () => void;
onClose: () => void; onClose: () => void;
}; };
export default function StampPopup({ name, imageColor, note, status, onCollect, onClose }: StampPopupProps) { export default function StampPopup({ name, imageColor, note, prize, status, onCollect, onClose }: StampPopupProps) {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center animate-overlay-fade" className="fixed inset-0 z-50 flex items-center justify-center animate-overlay-fade px-5"
style={{ backgroundColor: "var(--overlay)" }} style={{ backgroundColor: "var(--overlay)" }}
onClick={(e) => e.target === e.currentTarget && onClose()} onClick={(e) => e.target === e.currentTarget && onClose()}
> >
<div className="w-72 bg-[var(--bg-cream)] rounded-2xl p-6 text-center shadow-[var(--shadow-lg)]"> <div className="w-full max-w-xs bg-[var(--bg-cream)] rounded-2xl p-6 text-center shadow-[var(--shadow-lg)] max-h-[90vh] overflow-y-auto">
{/* Stamp image */} {/* Stamp image */}
<div className="w-40 h-40 mx-auto mb-4"> <div className="w-36 h-36 mx-auto mb-4">
<div <div
className="w-full h-full rounded-full bg-white overflow-hidden flex items-center justify-center border border-[var(--border-muted)] animate-stamp-press" className="w-full h-full rounded-full bg-white overflow-hidden flex items-center justify-center border border-[var(--border-muted)] animate-stamp-press"
style={{ style={{
@@ -44,13 +46,34 @@ export default function StampPopup({ name, imageColor, note, status, onCollect,
{/* Stamp name */} {/* Stamp name */}
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-1">{name}</h3> <h3 className="text-xl font-semibold text-[var(--text-primary)] mb-1">{name}</h3>
{note && <p className="text-xs text-[var(--text-muted)] mb-4">{note}</p>} {note && <p className="text-xs text-[var(--text-muted)] mb-3 leading-relaxed">{note}</p>}
{/* Prize rule (preview only) */}
{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">
<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 && (
<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>
)}
{/* Status message & action */} {/* Status message & action */}
{status === "preview" && ( {status === "preview" && (
<button <button
onClick={onCollect} onClick={onCollect}
className="w-full py-3 rounded-xl text-sm font-medium text-white mt-2" className="w-full py-3 rounded-xl text-sm font-medium text-white mt-4"
style={{ backgroundColor: "var(--terracotta)" }} style={{ backgroundColor: "var(--terracotta)" }}
> >

View File

@@ -111,6 +111,16 @@
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
@keyframes toast-in {
from { opacity: 0; transform: translate(-50%, -12px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
@keyframes admin-row-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* ===== Component Classes (in @layer components) ===== */ /* ===== Component Classes (in @layer components) ===== */
@layer components { @layer components {
.safe-bottom { .safe-bottom {
@@ -128,6 +138,8 @@
.animate-scale-in { opacity: 0; animation: scale-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) both; } .animate-scale-in { opacity: 0; animation: scale-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) both; }
.animate-music-ripple { animation: music-ripple 2.4s cubic-bezier(0.22, 1, 0.36, 1) infinite; } .animate-music-ripple { animation: music-ripple 2.4s cubic-bezier(0.22, 1, 0.36, 1) infinite; }
.animate-music-spin { animation: music-disc-spin 18s linear infinite; } .animate-music-spin { animation: music-disc-spin 18s linear infinite; }
.animate-toast-in { animation: toast-in 0.3s cubic-bezier(0.22, 1, 0.36, 1) both; }
.animate-admin-row { animation: admin-row-in 0.35s cubic-bezier(0.22, 1, 0.36, 1) both; }
.music-progress { .music-progress {
-webkit-appearance: none; -webkit-appearance: none;

View File

@@ -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,20 @@ 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); setSelectedStampId(stamp.id);
}; };
if (loading || authLoading) { if (loading || authLoading) {
@@ -108,40 +106,16 @@ export default function AlbumPage() {
style={{ width: stamps.length ? `${(collectedCount / stamps.length) * 100}%` : "0%" }} style={{ width: stamps.length ? `${(collectedCount / stamps.length) * 100}%` : "0%" }}
/> />
</div> </div>
<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 +123,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 +137,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)}
/> />
)} )}

View File

@@ -1,5 +1,7 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import type { ReactNode } from "react";
import { useSearchParams, useNavigate } from "react-router-dom"; import { useSearchParams, useNavigate } from "react-router-dom";
import type { PrizeInfo } from "@stamp/shared";
import { apiFetch } from "../lib/api"; import { apiFetch } from "../lib/api";
import { useAuth } from "../lib/auth"; import { useAuth } from "../lib/auth";
import FloatingButton from "../components/FloatingButton"; import FloatingButton from "../components/FloatingButton";
@@ -14,14 +16,49 @@ type StampDetail = {
note: string | null; note: string | null;
imageColor: string; imageColor: string;
imageGrey: string; imageGrey: string;
prize: PrizeInfo | null;
}; };
type CollectState = "idle" | "loading" | "show_stamp" | "needs_register" | "collecting" | "collected" | "already_collected"; type CollectState = "idle" | "loading" | "show_stamp" | "needs_register" | "collecting" | "collected" | "already_collected";
const STEPS = [ const RULES: { num: string; title: string; desc: ReactNode }[] = [
{ num: "01", title: "前往点位", desc: "跟随路线,探索散落在城市中的文化地标" }, {
{ num: "02", title: "触碰集章", desc: "手机轻触点位 NFC 芯片,图章即刻收入囊中" }, num: "01",
{ num: "03", title: "兑换好礼", desc: "集满图章,兑换城市限定纪念品" }, title: "去朝天宫读城",
desc: "活动期间,用户可在朝天宫街道辖区范围内自由探索,拍摄美食美景,记录你眼中的城南烟火气——红墙下的光影、打钉巷里热腾腾的锅贴、南台巷排队的咖啡店,街角一只晒太阳的猫……",
},
{
num: "02",
title: "线上打卡",
desc: (
<>
{" "}
<span className="text-[var(--terracotta)] font-medium">#</span>
{" "}
<span className="text-[var(--terracotta)] font-medium">#</span>
{" "}线
</>
),
},
{
num: "03",
title: "线下打卡",
desc: '前往任意一家门口贴有"读城窗口"标志的小店,进店后找到活动立牌,触碰 NFC 热点,在对应网页中完成线下打卡。',
},
{
num: "04",
title: "解锁权益",
desc: "用户完成线上打卡、线下打卡后,即可在小店解锁活动权益。活动期间每个账号在同一小店仅享受 1 次权益,权益现场核销,不重复享受,名额有限,先到先得。",
},
{
num: "05",
title: "活动时间",
desc: (
<span className="text-[var(--terracotta)] font-medium">
2026 4 21 2026 5 21
</span>
),
},
]; ];
export default function LandingPage() { export default function LandingPage() {
@@ -100,149 +137,66 @@ export default function LandingPage() {
const showRegister = collectState === "needs_register"; const showRegister = collectState === "needs_register";
return ( return (
<div className="grain-overlay"> <div className="min-h-svh bg-[var(--bg-cream)]">
{/* ═══════════ HERO ═══════════ */} {/* ═══════════ POSTER ═══════════ */}
<section className="relative min-h-svh flex flex-col items-center justify-center overflow-hidden"> <section className="relative w-full">
<div className="absolute inset-0 bg-[var(--bg-dark-deep)]" /> <img
<div src="/poster.jpg"
className="absolute inset-0" alt="读城·行走朝天宫"
style={{ className="block w-full h-auto select-none"
backgroundImage: ` draggable={false}
radial-gradient(ellipse 60% 50% at 50% 40%, rgba(212, 165, 116, 0.08) 0%, transparent 100%),
radial-gradient(circle at 15% 80%, rgba(199, 91, 57, 0.06) 0%, transparent 50%),
radial-gradient(circle at 85% 20%, rgba(45, 106, 79, 0.04) 0%, transparent 40%)
`,
}}
/> />
<div
className="absolute inset-0 opacity-[0.03]"
style={{
backgroundImage: `
linear-gradient(rgba(212, 165, 116, 0.5) 1px, transparent 1px),
linear-gradient(90deg, rgba(212, 165, 116, 0.5) 1px, transparent 1px)
`,
backgroundSize: "60px 60px",
}}
/>
<div className="relative z-10 text-center px-8 flex flex-col items-center">
<div className="animate-fade-in mb-8" style={{ animationDelay: "0.2s" }}>
<div className="inline-flex items-center gap-3">
<span className="block w-8 h-px bg-[var(--gold)]/40" />
<span className="text-[var(--gold)] text-[11px] tracking-[0.4em] uppercase"
style={{ fontFamily: "'Playfair Display', serif" }}>
CityWalk
</span>
<span className="block w-8 h-px bg-[var(--gold)]/40" />
</div>
</div>
<h1 className="animate-fade-in-up text-[var(--text-inverted)] leading-none mb-6"
style={{
animationDelay: "0.4s",
fontSize: "clamp(3rem, 12vw, 4.5rem)",
fontFamily: "'Playfair Display', serif",
fontWeight: 700,
letterSpacing: "-0.02em",
}}>
</h1>
<p className="animate-fade-in-up text-[var(--gold-light)]/70 text-sm leading-relaxed max-w-[260px]"
style={{ animationDelay: "0.6s", letterSpacing: "0.08em" }}>
<br />
</p>
<div className="animate-scale-in mt-14" style={{ animationDelay: "0.9s" }}>
<div className="stamp-seal w-[100px] h-[100px] animate-float">
<div className="w-[100px] h-[100px] rounded-full flex items-center justify-center"
style={{
background: "radial-gradient(circle, rgba(212, 165, 116, 0.12) 0%, rgba(212, 165, 116, 0.02) 70%)",
border: "1.5px solid rgba(212, 165, 116, 0.2)",
}}>
<div className="text-center">
<div className="text-[var(--gold)] text-[10px] tracking-[0.2em] uppercase opacity-60">Stamp</div>
<div className="text-[var(--gold)] text-2xl mt-0.5 opacity-80"
style={{ fontFamily: "'Playfair Display', serif" }}>9</div>
<div className="text-[var(--gold)] text-[9px] tracking-[0.15em] uppercase opacity-50">Collect</div>
</div>
</div>
</div>
</div>
<div className="animate-fade-in mt-16" style={{ animationDelay: "1.4s" }}>
<div className="flex flex-col items-center gap-2">
<span className="text-[var(--gold)]/30 text-[10px] tracking-[0.3em] uppercase"></span>
<div className="w-px h-8 bg-gradient-to-b from-[var(--gold)]/30 to-transparent" />
</div>
</div>
</div>
</section> </section>
{/* ═══════════ ABOUT ═══════════ */} {/* ═══════════ RULES ═══════════ */}
<section className="relative bg-[var(--bg-dark)] py-20 px-6 overflow-hidden"> <section className="relative paper-texture px-6 py-14 pb-32">
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-[var(--gold)]/20 to-transparent" /> <div className="relative z-10 max-w-sm mx-auto">
<div className="max-w-sm mx-auto">
<div className="flex items-center gap-3 mb-8 animate-fade-in-up">
<span className="block w-6 h-px bg-[var(--gold)]/40" />
<span className="text-[var(--gold)]/50 text-[10px] tracking-[0.3em] uppercase">About</span>
</div>
<h2 className="text-[var(--text-inverted)] text-2xl leading-snug mb-6 animate-fade-in-up"
style={{ fontFamily: "'Playfair Display', serif", animationDelay: "0.1s" }}>
<br /><span className="text-[var(--gold)]"></span>
</h2>
<p className="text-[var(--text-inverted)]/50 text-sm leading-[1.9] animate-fade-in-up"
style={{ animationDelay: "0.2s" }}>
穿
</p>
<div className="ornament-line mt-10" />
<div className="mt-10 grid grid-cols-3 gap-4 stagger-children">
{[
{ num: "9", label: "城市坐标" },
{ num: "4", label: "限定好礼" },
{ num: "∞", label: "重复挑战" },
].map((item) => (
<div key={item.label} className="text-center">
<div className="text-[var(--gold)] text-3xl mb-1.5"
style={{ fontFamily: "'Playfair Display', serif" }}>{item.num}</div>
<div className="text-[var(--text-inverted)]/35 text-[11px] tracking-wider">{item.label}</div>
</div>
))}
</div>
</div>
</section>
{/* ═══════════ HOW IT WORKS ═══════════ */}
<section className="relative paper-texture py-20 px-6 pb-32">
<div className="relative z-10 max-w-sm mx-auto pt-4">
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
<span className="block w-6 h-px bg-[var(--text-primary)]/20" /> <span className="block w-6 h-px bg-[var(--text-primary)]/20" />
<span className="text-[var(--text-muted)] text-[10px] tracking-[0.3em] uppercase">How it works</span> <span className="text-[var(--text-muted)] text-[10px] tracking-[0.3em] uppercase">
Rules
</span>
</div> </div>
<h2 className="text-[var(--text-primary)] text-2xl leading-snug mb-12" <h2
style={{ fontFamily: "'Playfair Display', serif" }}> className="text-[var(--text-primary)] text-2xl leading-snug mb-10"
style={{ fontFamily: "'Playfair Display', serif" }}
>
</h2> </h2>
<div className="space-y-0 stagger-children">
{STEPS.map((step, i) => ( <ol className="space-y-0 stagger-children">
<div key={step.num} className="relative flex gap-5"> {RULES.map((rule, i) => (
<li key={rule.num} className="relative flex gap-5">
<div className="flex flex-col items-center shrink-0"> <div className="flex flex-col items-center shrink-0">
<div className="w-11 h-11 rounded-full border-2 flex items-center justify-center" <div
style={{ borderColor: "var(--gold)", background: "rgba(212, 165, 116, 0.06)" }}> className="w-11 h-11 rounded-full border-2 flex items-center justify-center"
<span className="text-[var(--gold)] text-xs" style={{
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 600 }}> borderColor: "var(--gold)",
{step.num} background: "rgba(212, 165, 116, 0.06)",
}}
>
<span
className="text-[var(--gold)] text-xs"
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 600 }}
>
{rule.num}
</span> </span>
</div> </div>
{i < STEPS.length - 1 && <div className="w-px flex-1 min-h-[40px] bg-[var(--gold)]/20" />} {i < RULES.length - 1 && (
<div className="w-px flex-1 min-h-[40px] bg-[var(--gold)]/20" />
)}
</div> </div>
<div className="pb-10 pt-1.5"> <div className="pb-8 pt-1.5">
<h3 className="text-base font-semibold text-[var(--text-primary)] mb-1">{step.title}</h3> <h3 className="text-base font-semibold text-[var(--text-primary)] mb-1.5">
<p className="text-sm text-[var(--text-secondary)] leading-relaxed">{step.desc}</p> {rule.title}
</h3>
<p className="text-sm text-[var(--text-secondary)] leading-[1.9]">
{rule.desc}
</p>
</div> </div>
</div> </li>
))} ))}
</div> </ol>
</div> </div>
</section> </section>
@@ -254,6 +208,7 @@ export default function LandingPage() {
name={stamp.name} name={stamp.name}
imageColor={stamp.imageColor} imageColor={stamp.imageColor}
note={stamp.note} note={stamp.note}
prize={stamp.prize}
status="preview" status="preview"
onCollect={handleCollect} onCollect={handleCollect}
onClose={handleClose} onClose={handleClose}
@@ -264,6 +219,7 @@ export default function LandingPage() {
name={stamp.name} name={stamp.name}
imageColor={stamp.imageColor} imageColor={stamp.imageColor}
note={stamp.note} note={stamp.note}
prize={stamp.prize}
status="collected" status="collected"
onClose={handleClose} onClose={handleClose}
/> />
@@ -273,6 +229,7 @@ export default function LandingPage() {
name={stamp.name} name={stamp.name}
imageColor={stamp.imageColor} imageColor={stamp.imageColor}
note={stamp.note} note={stamp.note}
prize={stamp.prize}
status="already" status="already"
onClose={handleClose} onClose={handleClose}
/> />

View File

@@ -0,0 +1,22 @@
import { useParams } from "react-router-dom";
export default function VideoPage() {
const { id } = useParams();
const src = id ? `/uploads/videos/${id}.mp4` : "";
return (
<div className="min-h-svh flex items-center justify-center bg-black px-4 py-4">
{id ? (
<video
src={src}
controls
playsInline
preload="metadata"
className="block w-full max-w-[960px] h-auto bg-black"
/>
) : (
<p className="text-white/60 text-sm"></p>
)}
</div>
);
}

View File

@@ -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;

View File

@@ -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])
} }

BIN
核销指导.pdf Normal file

Binary file not shown.