Compare commits
9 Commits
613684384b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f87d16021e | |||
| 3b3878ea5c | |||
| f84815611d | |||
| f2c71ff91a | |||
| bcb167b67d | |||
| 2c179cd19a | |||
| 394b643304 | |||
| 52169ac71d | |||
| b4a0e23c7e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,6 +5,7 @@ dist/
|
||||
.env
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
packages/server/uploads/videos/
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
CityWalk 图章收集系统 — 移动端 H5 + 管理后台。游客在城市点位扫描二维码收集图章,集满可兑换奖品,兑换后图章清空,支持重复收集。
|
||||
CityWalk 图章收集系统 — 移动端 H5 + 管理后台。游客在城市点位扫描二维码收集图章,每枚图章绑定一个特定奖品(带库存),已收集的图章可兑换对应奖品。兑换后图章保持彩色点亮并标记为"已兑换",同一图章不可二次收集或二次兑换。
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -17,7 +17,7 @@ pnpm dev:web # Vite dev server on :5173
|
||||
pnpm db:generate # Generate Prisma client after schema changes
|
||||
pnpm db:migrate # Create and apply migrations (prisma migrate dev)
|
||||
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
|
||||
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)
|
||||
/admin → AdminLogin
|
||||
/admin/stamps → Stamp CRUD + QR code generation
|
||||
/admin/rules → Redemption rule CRUD
|
||||
/admin/redemptions → Redemption history + stats
|
||||
```
|
||||
|
||||
@@ -58,7 +57,7 @@ QR codes encode `/collect/{stampId}` → redirects to `/?stamp={stampId}` → La
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -32,5 +32,5 @@ packages/
|
||||
server/ Express API(认证、图章、兑换、管理)
|
||||
web/ React SPA(移动端 H5 + PC 管理后台)
|
||||
prisma/
|
||||
schema.prisma 数据模型(User, Stamp, Collection, RedemptionRule, Redemption)
|
||||
schema.prisma 数据模型(User, Stamp, Prize, Collection, Redemption)
|
||||
```
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"db:push": "prisma db push",
|
||||
"db:seed": "pnpm --filter @stamp/server seed",
|
||||
"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": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"start": "node dist/index.js",
|
||||
"seed": "tsx src/seed.ts",
|
||||
"seed-articles": "tsx src/seed-articles.ts",
|
||||
"seed-music": "tsx src/seed-music.ts"
|
||||
"seed-music": "tsx src/seed-music.ts",
|
||||
"update-brand-rules": "tsx src/scripts/update-brand-rules.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stamp/shared": "workspace:*",
|
||||
|
||||
@@ -26,7 +26,10 @@ router.use(requireAdmin);
|
||||
// ===== Stamps CRUD =====
|
||||
|
||||
router.get("/stamps", async (_req, res) => {
|
||||
const stamps = await prisma.stamp.findMany({ orderBy: { sortOrder: "asc" } });
|
||||
const stamps = await prisma.stamp.findMany({
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: { prize: true },
|
||||
});
|
||||
res.json({ success: true, data: stamps });
|
||||
});
|
||||
|
||||
@@ -121,69 +124,58 @@ router.get("/stamps/:id/qrcode", async (req, res) => {
|
||||
res.json({ success: true, data: { qrDataUrl, collectUrl, stampName: stamp.name } });
|
||||
});
|
||||
|
||||
// ===== Redemption Rules CRUD =====
|
||||
// ===== Prize (per-stamp) =====
|
||||
|
||||
router.get("/rules", async (_req, res) => {
|
||||
const rules = await prisma.redemptionRule.findMany({ orderBy: { sortOrder: "asc" } });
|
||||
res.json({ success: true, data: rules });
|
||||
});
|
||||
|
||||
const ruleSchema = z.object({
|
||||
const prizeSchema = z.object({
|
||||
name: z.string().min(1, "奖品名称不能为空"),
|
||||
description: z.string().optional(),
|
||||
threshold: z.number().int().min(1, "兑换门槛至少为 1"),
|
||||
stock: z.number().int().min(0, "库存不能为负数"),
|
||||
enabled: z.boolean().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
});
|
||||
|
||||
router.post("/rules", async (req, res) => {
|
||||
const parsed = ruleSchema.safeParse(req.body);
|
||||
router.put("/stamps/:id/prize", async (req, res) => {
|
||||
const parsed = prizeSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } });
|
||||
return;
|
||||
}
|
||||
const rule = await prisma.redemptionRule.create({
|
||||
data: {
|
||||
const stamp = await prisma.stamp.findUnique({ where: { id: req.params.id } });
|
||||
if (!stamp) {
|
||||
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "图章不存在" } });
|
||||
return;
|
||||
}
|
||||
const data = {
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description,
|
||||
threshold: parsed.data.threshold,
|
||||
description: parsed.data.description ?? null,
|
||||
stock: parsed.data.stock,
|
||||
enabled: parsed.data.enabled ?? true,
|
||||
sortOrder: parsed.data.sortOrder ?? 0,
|
||||
},
|
||||
};
|
||||
const prize = await prisma.prize.upsert({
|
||||
where: { stampId: stamp.id },
|
||||
create: { stampId: stamp.id, ...data },
|
||||
update: data,
|
||||
});
|
||||
res.json({ success: true, data: rule });
|
||||
});
|
||||
|
||||
router.put("/rules/:id", async (req, res) => {
|
||||
const parsed = ruleSchema.partial().safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ success: false, error: { code: "VALIDATION", message: parsed.error.issues[0].message } });
|
||||
return;
|
||||
}
|
||||
const rule = await prisma.redemptionRule.update({
|
||||
where: { id: req.params.id },
|
||||
data: parsed.data,
|
||||
}).catch(() => null);
|
||||
if (!rule) {
|
||||
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "规则不存在" } });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true, data: rule });
|
||||
});
|
||||
|
||||
router.delete("/rules/:id", async (req, res) => {
|
||||
await prisma.redemptionRule.delete({ where: { id: req.params.id } }).catch(() => null);
|
||||
res.json({ success: true, data: null });
|
||||
res.json({ success: true, data: prize });
|
||||
});
|
||||
|
||||
// ===== Redemption Records & Stats =====
|
||||
|
||||
router.get("/redemptions", async (_req, res) => {
|
||||
const records = await prisma.redemption.findMany({
|
||||
include: { user: { select: { username: true, phone: true } }, rule: { select: { name: true } } },
|
||||
include: {
|
||||
user: { select: { username: true, phone: true } },
|
||||
stamp: { select: { name: true } },
|
||||
},
|
||||
orderBy: { redeemedAt: "desc" },
|
||||
});
|
||||
res.json({ success: true, data: records });
|
||||
const data = records.map((r) => ({
|
||||
id: r.id,
|
||||
redeemedAt: r.redeemedAt,
|
||||
user: r.user,
|
||||
stampName: r.stamp.name,
|
||||
prizeName: r.prizeName,
|
||||
}));
|
||||
res.json({ success: true, data });
|
||||
});
|
||||
|
||||
router.get("/stats", async (_req, res) => {
|
||||
@@ -195,6 +187,71 @@ router.get("/stats", async (_req, res) => {
|
||||
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 =====
|
||||
|
||||
router.get("/articles", async (_req, res) => {
|
||||
|
||||
@@ -5,17 +5,8 @@ import { requireAuth } from "../middleware/auth.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/rules", async (_req, res) => {
|
||||
const rules = await prisma.redemptionRule.findMany({
|
||||
where: { enabled: true },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { id: true, name: true, description: true, threshold: true },
|
||||
});
|
||||
res.json({ success: true, data: rules });
|
||||
});
|
||||
|
||||
const redeemSchema = z.object({
|
||||
ruleId: z.string().uuid("规则 ID 格式不正确"),
|
||||
stampId: z.string().uuid("图章 ID 格式不正确"),
|
||||
});
|
||||
|
||||
router.post("/redeem", requireAuth, async (req, res) => {
|
||||
@@ -25,64 +16,94 @@ router.post("/redeem", requireAuth, async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const rule = await prisma.redemptionRule.findUnique({ where: { id: parsed.data.ruleId, enabled: true } });
|
||||
if (!rule) {
|
||||
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "兑换规则不存在" } });
|
||||
return;
|
||||
}
|
||||
|
||||
const 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 { stampId } = parsed.data;
|
||||
const userId = req.userId!;
|
||||
|
||||
try {
|
||||
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 },
|
||||
const collection = await tx.collection.findUnique({
|
||||
where: { userId_stampId: { userId, stampId } },
|
||||
});
|
||||
await tx.collection.deleteMany({
|
||||
where: { id: { in: toDelete.map((c) => c.id) } },
|
||||
if (!collection) {
|
||||
throw new RedeemError("NOT_COLLECTED", "你还没有收集这枚图章", 400);
|
||||
}
|
||||
|
||||
const already = await tx.redemption.findUnique({
|
||||
where: { userId_stampId: { userId, stampId } },
|
||||
});
|
||||
const record = await tx.redemption.create({
|
||||
data: { userId: req.userId!, ruleId: rule.id, stampCount: rule.threshold },
|
||||
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 } } },
|
||||
});
|
||||
return record;
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: redemption.id,
|
||||
ruleName: rule.name,
|
||||
stampCount: redemption.stampCount,
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/history", requireAuth, async (req, res) => {
|
||||
const records = await prisma.redemption.findMany({
|
||||
where: { userId: req.userId! },
|
||||
include: { rule: { select: { name: true } } },
|
||||
include: { stamp: { select: { name: true } } },
|
||||
orderBy: { redeemedAt: "desc" },
|
||||
});
|
||||
|
||||
const data = records.map((r) => ({
|
||||
id: r.id,
|
||||
ruleName: r.rule.name,
|
||||
stampCount: r.stampCount,
|
||||
stampId: r.stampId,
|
||||
stampName: r.stamp.name,
|
||||
prizeName: r.prizeName,
|
||||
redeemedAt: r.redeemedAt.toISOString(),
|
||||
}));
|
||||
|
||||
res.json({ success: true, data });
|
||||
});
|
||||
|
||||
class RedeemError extends Error {
|
||||
constructor(
|
||||
public code: string,
|
||||
message: string,
|
||||
public status: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -8,20 +8,25 @@ router.get("/", optionalAuth, async (req, res) => {
|
||||
const stamps = await prisma.stamp.findMany({
|
||||
where: { enabled: true },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: { prize: true },
|
||||
});
|
||||
|
||||
let collections: Set<string> = new Set();
|
||||
let collectionMap: Map<string, Date> = new Map();
|
||||
const collectionMap = new Map<string, Date>();
|
||||
const redeemedSet = new Set<string>();
|
||||
|
||||
if (req.userId) {
|
||||
const userCollections = await prisma.collection.findMany({
|
||||
const [userCollections, userRedemptions] = await Promise.all([
|
||||
prisma.collection.findMany({
|
||||
where: { userId: req.userId },
|
||||
select: { stampId: true, collectedAt: true },
|
||||
});
|
||||
userCollections.forEach((c) => {
|
||||
collections.add(c.stampId);
|
||||
collectionMap.set(c.stampId, c.collectedAt);
|
||||
});
|
||||
}),
|
||||
prisma.redemption.findMany({
|
||||
where: { userId: req.userId },
|
||||
select: { stampId: true },
|
||||
}),
|
||||
]);
|
||||
userCollections.forEach((c) => collectionMap.set(c.stampId, c.collectedAt));
|
||||
userRedemptions.forEach((r) => redeemedSet.add(r.stampId));
|
||||
}
|
||||
|
||||
const data = stamps.map((s) => ({
|
||||
@@ -31,15 +36,28 @@ router.get("/", optionalAuth, async (req, res) => {
|
||||
imageColor: s.imageColor,
|
||||
imageGrey: s.imageGrey,
|
||||
sortOrder: s.sortOrder,
|
||||
collected: collections.has(s.id),
|
||||
collected: collectionMap.has(s.id),
|
||||
collectedAt: collectionMap.get(s.id)?.toISOString() ?? null,
|
||||
redeemed: redeemedSet.has(s.id),
|
||||
prize: s.prize
|
||||
? {
|
||||
id: s.prize.id,
|
||||
name: s.prize.name,
|
||||
description: s.prize.description,
|
||||
stock: s.prize.stock,
|
||||
enabled: s.prize.enabled,
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
|
||||
res.json({ success: true, data });
|
||||
});
|
||||
|
||||
router.get("/:id", async (req, res) => {
|
||||
const stamp = await prisma.stamp.findUnique({ where: { id: req.params.id } });
|
||||
const stamp = await prisma.stamp.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: { prize: true },
|
||||
});
|
||||
if (!stamp) {
|
||||
res.status(404).json({ success: false, error: { code: "NOT_FOUND", message: "图章不存在" } });
|
||||
return;
|
||||
@@ -53,6 +71,15 @@ router.get("/:id", async (req, res) => {
|
||||
imageColor: stamp.imageColor,
|
||||
imageGrey: stamp.imageGrey,
|
||||
sortOrder: stamp.sortOrder,
|
||||
prize: stamp.prize
|
||||
? {
|
||||
id: stamp.prize.id,
|
||||
name: stamp.prize.name,
|
||||
description: stamp.prize.description,
|
||||
stock: stamp.prize.stock,
|
||||
enabled: stamp.prize.enabled,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
73
packages/server/src/scripts/rename-prize-names.ts
Normal file
73
packages/server/src/scripts/rename-prize-names.ts
Normal 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());
|
||||
125
packages/server/src/scripts/update-brand-rules.ts
Normal file
125
packages/server/src/scripts/update-brand-rules.ts
Normal 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());
|
||||
@@ -22,7 +22,7 @@ const stampData = [
|
||||
async function seed() {
|
||||
console.log("Seeding database...");
|
||||
|
||||
// Clear existing stamps (cascades to collections)
|
||||
// Clear existing stamps (cascades to collections + prize)
|
||||
await prisma.stamp.deleteMany();
|
||||
|
||||
const stamps = await Promise.all(
|
||||
@@ -34,34 +34,20 @@ async function seed() {
|
||||
imageColor: `/uploads/stamps/stamp-${pos}-color.jpg`,
|
||||
imageGrey: `/uploads/stamps/stamp-${pos}-grey.jpg`,
|
||||
sortOrder: idx + 1,
|
||||
prize: {
|
||||
create: {
|
||||
name: `${s.name} · 专属奖品`,
|
||||
description: `在「${s.name}」可兑换的专属奖品`,
|
||||
stock: 100,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
console.log(`Created ${stamps.length} stamps`);
|
||||
|
||||
// Create redemption rules if none exist
|
||||
const existingRules = await prisma.redemptionRule.count();
|
||||
if (existingRules === 0) {
|
||||
const rules = await Promise.all([
|
||||
prisma.redemptionRule.create({
|
||||
data: { name: "纪念书签", description: "精美城市纪念书签一枚", threshold: 4, sortOrder: 1 },
|
||||
}),
|
||||
prisma.redemptionRule.create({
|
||||
data: { name: "手绘明信片套装", description: "一套 5 张手绘城市明信片", threshold: 8, sortOrder: 2 },
|
||||
}),
|
||||
prisma.redemptionRule.create({
|
||||
data: { name: "限定徽章礼盒", description: "城市限定版金属徽章礼盒", threshold: 12, sortOrder: 3 },
|
||||
}),
|
||||
prisma.redemptionRule.create({
|
||||
data: { name: "城市记忆礼包", description: "包含纪念 T 恤、帆布袋和城市画册", threshold: 16, sortOrder: 4 },
|
||||
}),
|
||||
]);
|
||||
console.log(`Created ${rules.length} redemption rules`);
|
||||
} else {
|
||||
console.log(`Kept existing ${existingRules} redemption rules`);
|
||||
}
|
||||
console.log(`Created ${stamps.length} stamps with prizes`);
|
||||
|
||||
console.log("\nStamp IDs for testing:");
|
||||
stamps.forEach((s) => {
|
||||
|
||||
@@ -4,6 +4,14 @@ export type ApiResponse<T = unknown> = {
|
||||
error?: { code: string; message: string };
|
||||
};
|
||||
|
||||
export type PrizeInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
stock: number;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type StampWithStatus = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -13,19 +21,15 @@ export type StampWithStatus = {
|
||||
sortOrder: number;
|
||||
collected: boolean;
|
||||
collectedAt: string | null;
|
||||
};
|
||||
|
||||
export type RedemptionRuleInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
threshold: number;
|
||||
redeemed: boolean;
|
||||
prize: PrizeInfo | null;
|
||||
};
|
||||
|
||||
export type RedemptionRecord = {
|
||||
id: string;
|
||||
ruleName: string;
|
||||
stampCount: number;
|
||||
stampId: string;
|
||||
stampName: string;
|
||||
prizeName: string;
|
||||
redeemedAt: string;
|
||||
};
|
||||
|
||||
|
||||
BIN
packages/web/public/poster.jpg
Normal file
BIN
packages/web/public/poster.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
@@ -4,20 +4,15 @@ import LandingPage from "./pages/LandingPage";
|
||||
import AlbumPage from "./pages/AlbumPage";
|
||||
import ArticlePage from "./pages/ArticlePage";
|
||||
import MusicPage from "./pages/MusicPage";
|
||||
import VideoPage from "./pages/VideoPage";
|
||||
import AdminLogin from "./admin/AdminLogin";
|
||||
import AdminGuard from "./admin/AdminGuard";
|
||||
import AdminLayout from "./admin/AdminLayout";
|
||||
import Dashboard from "./admin/Dashboard";
|
||||
import StampList from "./admin/StampList";
|
||||
import StampForm from "./admin/StampForm";
|
||||
import StampQRCode from "./admin/StampQRCode";
|
||||
import ArticleList from "./admin/ArticleList";
|
||||
import ArticleForm from "./admin/ArticleForm";
|
||||
import ArticleQRCode from "./admin/ArticleQRCode";
|
||||
import MusicList from "./admin/MusicList";
|
||||
import MusicForm from "./admin/MusicForm";
|
||||
import MusicQRCode from "./admin/MusicQRCode";
|
||||
import RuleList from "./admin/RuleList";
|
||||
import RuleForm from "./admin/RuleForm";
|
||||
import UsersList from "./admin/UsersList";
|
||||
import RedemptionLog from "./admin/RedemptionLog";
|
||||
|
||||
function CollectRedirect() {
|
||||
@@ -35,26 +30,17 @@ export default function App() {
|
||||
<Route path="/collect/:stampId" element={<CollectRedirect />} />
|
||||
<Route path="/article/:id" element={<ArticlePage />} />
|
||||
<Route path="/music/:id" element={<MusicPage />} />
|
||||
<Route path="/video/:id" element={<VideoPage />} />
|
||||
|
||||
{/* Admin panel */}
|
||||
<Route path="/admin" element={<AdminLogin />} />
|
||||
<Route element={<AdminGuard />}>
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route path="/admin/dashboard" element={<Dashboard />} />
|
||||
<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/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/new" element={<MusicForm />} />
|
||||
<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/users" element={<UsersList />} />
|
||||
<Route path="/admin/redemptions" element={<RedemptionLog />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { ToastProvider } from "./Toast";
|
||||
|
||||
const navItems = [
|
||||
{ path: "/admin/stamps", label: "图章管理" },
|
||||
{ path: "/admin/articles", label: "文章管理" },
|
||||
{ path: "/admin/music", label: "音乐管理" },
|
||||
{ path: "/admin/rules", label: "兑换规则" },
|
||||
{ path: "/admin/redemptions", label: "兑换记录" },
|
||||
{ path: "/admin/dashboard", label: "数据看板", eyebrow: "01", tag: "Dashboard" },
|
||||
{ path: "/admin/stamps", label: "图章管理", eyebrow: "02", tag: "Stamps" },
|
||||
{ path: "/admin/articles", label: "文章管理", eyebrow: "03", tag: "Articles" },
|
||||
{ path: "/admin/music", label: "音乐管理", eyebrow: "04", tag: "Music" },
|
||||
{ path: "/admin/users", label: "用户管理", eyebrow: "05", tag: "Users" },
|
||||
{ path: "/admin/redemptions", label: "兑换记录", eyebrow: "06", tag: "Log" },
|
||||
];
|
||||
|
||||
export default function AdminLayout() {
|
||||
@@ -17,40 +19,131 @@ export default function AdminLayout() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-56 bg-white border-r border-gray-200 flex flex-col shrink-0">
|
||||
<div className="px-5 py-4 border-b border-gray-200">
|
||||
<h1 className="text-base font-semibold text-gray-800">图章管理后台</h1>
|
||||
<ToastProvider>
|
||||
<div className="min-h-screen flex bg-[var(--bg-cream)] grain-overlay">
|
||||
{/* ═══════════ Sidebar ═══════════ */}
|
||||
<aside className="w-64 shrink-0 relative flex flex-col text-[var(--text-inverted)]">
|
||||
<div className="absolute inset-0 bg-[var(--bg-dark-deep)]" />
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
radial-gradient(ellipse 80% 40% at 50% 0%, rgba(212, 165, 116, 0.08) 0%, transparent 70%),
|
||||
radial-gradient(circle at 20% 90%, rgba(199, 91, 57, 0.06) 0%, transparent 60%)
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.025] pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(212, 165, 116, 0.6) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(212, 165, 116, 0.6) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: "40px 40px",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Brand */}
|
||||
<div className="relative px-7 pt-8 pb-7">
|
||||
<div className="flex items-center gap-2.5 mb-3">
|
||||
<span className="block w-5 h-px bg-[var(--gold)]/60" />
|
||||
<span
|
||||
className="text-[var(--gold)] text-[10px] tracking-[0.4em] uppercase"
|
||||
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 500 }}
|
||||
>
|
||||
Atelier
|
||||
</span>
|
||||
</div>
|
||||
<nav className="flex-1 py-3">
|
||||
<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 }) =>
|
||||
`block px-5 py-2.5 text-sm transition-colors ${
|
||||
`group relative block px-3 py-3 my-0.5 rounded-lg transition-all duration-300 ${
|
||||
isActive
|
||||
? "text-blue-600 bg-blue-50 font-medium border-r-2 border-blue-600"
|
||||
: "text-gray-600 hover:bg-gray-50"
|
||||
? "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>
|
||||
<div className="px-5 py-3 border-t border-gray-200">
|
||||
<button onClick={handleLogout} className="text-sm text-gray-500 hover:text-gray-700">
|
||||
退出管理
|
||||
|
||||
{/* 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 content */}
|
||||
<main className="flex-1 p-6 overflow-auto">
|
||||
{/* ═══════════ Main ═══════════ */}
|
||||
<main className="flex-1 overflow-auto paper-texture">
|
||||
<div className="min-h-full px-10 py-10">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function AdminLogin() {
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
sessionStorage.setItem("admin_key", key);
|
||||
navigate("/admin/stamps");
|
||||
navigate("/admin/dashboard");
|
||||
} else {
|
||||
setError("密钥不正确");
|
||||
}
|
||||
@@ -27,28 +27,117 @@ export default function AdminLogin() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="w-80 bg-white rounded-lg shadow-sm p-6 border border-gray-200">
|
||||
<h1 className="text-lg font-semibold text-gray-800 mb-4 text-center">管理后台</h1>
|
||||
<div className="space-y-3">
|
||||
<div className="min-h-screen flex items-center justify-center relative overflow-hidden grain-overlay">
|
||||
<div className="absolute inset-0 bg-[var(--bg-dark-deep)]" />
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
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 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" }}
|
||||
>
|
||||
图章后台
|
||||
</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" && handleLogin()}
|
||||
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"
|
||||
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 && <p className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
{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="w-full py-2 bg-blue-600 text-white text-sm rounded-md
|
||||
hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
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 ? "验证中..." : "登录"}
|
||||
{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>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import Modal from "./Modal";
|
||||
import { adminFetch } from "./adminApi";
|
||||
import { useToast } from "./Toast";
|
||||
import { Field, ErrorRow, FormFooter, HintRow, fieldCls } from "./FormPrimitives";
|
||||
|
||||
type Article = {
|
||||
id: string;
|
||||
@@ -13,11 +15,16 @@ type Article = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export default function ArticleForm() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const isEdit = !!id;
|
||||
type Props = {
|
||||
open: boolean;
|
||||
id: string | null;
|
||||
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 [subtitle, setSubtitle] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
@@ -28,8 +35,17 @@ export default function ArticleForm() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const isEdit = !!currentId;
|
||||
|
||||
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) => {
|
||||
const article = articles.find((a) => a.id === id);
|
||||
if (article) {
|
||||
@@ -42,32 +58,33 @@ export default function ArticleForm() {
|
||||
setEnabled(article.enabled);
|
||||
}
|
||||
});
|
||||
}, [id]);
|
||||
}, [open, id]);
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
if (!id) {
|
||||
if (!currentId) {
|
||||
setError("请先保存文章后再上传封面");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
const formData = new FormData();
|
||||
formData.append("image", file);
|
||||
const data = await adminFetch<{ path: string }>(`/articles/${id}/upload`, {
|
||||
try {
|
||||
const data = await adminFetch<{ path: string }>(`/articles/${currentId}/upload`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
setCoverImage(data.path);
|
||||
toast.show("封面已上传");
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "上传失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setError("");
|
||||
if (!title.trim()) {
|
||||
setError("请输入标题");
|
||||
return;
|
||||
}
|
||||
if (!body.trim()) {
|
||||
setError("请输入正文");
|
||||
return;
|
||||
}
|
||||
if (!title.trim()) return setError("请输入标题");
|
||||
if (!body.trim()) return setError("请输入正文");
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
@@ -79,19 +96,22 @@ export default function ArticleForm() {
|
||||
enabled,
|
||||
};
|
||||
if (isEdit) {
|
||||
await adminFetch(`/articles/${id}`, {
|
||||
await adminFetch(`/articles/${currentId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
toast.show("已保存");
|
||||
onSaved();
|
||||
onClose();
|
||||
} else {
|
||||
const article = await adminFetch<Article>("/articles", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
navigate(`/admin/articles/${article.id}/edit`, { replace: true });
|
||||
return;
|
||||
setCurrentId(article.id);
|
||||
toast.show("已创建,现在可以上传封面");
|
||||
onSaved();
|
||||
}
|
||||
navigate("/admin/articles");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
@@ -100,122 +120,104 @@ export default function ArticleForm() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<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>
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
eyebrow={isEdit ? "Edit Article" : "New Article"}
|
||||
title={isEdit ? "编辑文章" : "添加文章"}
|
||||
subtitle={isEdit ? "调整内容与上传封面" : "先保存文章内容,再上传封面图"}
|
||||
>
|
||||
<div className="px-7 py-6 space-y-5">
|
||||
<Field label="标题" required>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
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"
|
||||
className={fieldCls}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">副标题</label>
|
||||
<Field label="副标题">
|
||||
<input
|
||||
value={subtitle}
|
||||
onChange={(e) => setSubtitle(e.target.value)}
|
||||
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"
|
||||
className={fieldCls}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<Field label="正文" required hint="段落之间用空行分隔">
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
rows={18}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm leading-relaxed
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500 font-mono"
|
||||
rows={14}
|
||||
placeholder="在这里撰写文章正文…"
|
||||
className={fieldCls + " font-mono text-[13px] leading-relaxed resize-y"}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">图片说明</label>
|
||||
<Field label="图片说明">
|
||||
<input
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
placeholder="如:1910 年的朝天宫大成殿旧影"
|
||||
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"
|
||||
className={fieldCls}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">排序</label>
|
||||
<div className="grid grid-cols-[auto_1fr] gap-5 items-end">
|
||||
<Field 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"
|
||||
className={fieldCls + " w-28"}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center pt-6">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
</Field>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer select-none pb-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
className="w-4 h-4 accent-[var(--jade)]"
|
||||
/>
|
||||
启用
|
||||
<span className="text-sm text-[var(--text-secondary)]">启用(访客可访问)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEdit && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">封面图片</label>
|
||||
{isEdit ? (
|
||||
<Field label="封面图片">
|
||||
<div className="rounded-xl border border-[var(--border-muted)] bg-white p-4">
|
||||
{coverImage && (
|
||||
<div className="w-64 aspect-[4/3] rounded-md bg-gray-50 border border-gray-200 overflow-hidden shadow-sm mb-2">
|
||||
<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="text-xs text-gray-500"
|
||||
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="保存文章后,即可上传封面图片" />
|
||||
)}
|
||||
|
||||
{!isEdit && (
|
||||
<p className="text-xs text-gray-400">保存后可上传封面图片</p>
|
||||
)}
|
||||
{error && <ErrorRow text={error} />}
|
||||
</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/articles")}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded-md hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormFooter onCancel={onClose} onSave={handleSave} saving={saving} primaryLabel={isEdit ? "保存" : "创建"} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
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 = {
|
||||
id: string;
|
||||
@@ -12,11 +27,14 @@ type Article = {
|
||||
};
|
||||
|
||||
export default function ArticleList() {
|
||||
const toast = useToast();
|
||||
const [articles, setArticles] = useState<Article[]>([]);
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await adminFetch<Article[]>("/articles");
|
||||
setArticles(data);
|
||||
@@ -25,12 +43,19 @@ export default function ArticleList() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchArticles(); }, []);
|
||||
useEffect(() => {
|
||||
fetchArticles();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: string, title: string) => {
|
||||
if (!confirm(`确定删除文章「${title}」?`)) return;
|
||||
try {
|
||||
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) => {
|
||||
@@ -41,78 +66,124 @@ export default function ArticleList() {
|
||||
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 (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800">文章管理</h2>
|
||||
<Link
|
||||
to="/admin/articles/new"
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
|
||||
>
|
||||
添加文章
|
||||
</Link>
|
||||
</div>
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="03 · Articles"
|
||||
title="文章管理"
|
||||
caption="静态文章与对应点位的 NFC 链接"
|
||||
action={<PrimaryButton onClick={() => setFormState({ open: true, id: null })}>添加文章</PrimaryButton>}
|
||||
/>
|
||||
|
||||
<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-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>
|
||||
{loading ? (
|
||||
<LoadingBlock />
|
||||
) : (
|
||||
<TableCard>
|
||||
{articles.length === 0 ? (
|
||||
<EmptyState
|
||||
message="尚未创建文章"
|
||||
action={
|
||||
<PrimaryButton onClick={() => setFormState({ open: true, id: null })}>
|
||||
添加第一篇文章
|
||||
</PrimaryButton>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<TableHeadRow cols={["封面", "标题 · 副标题", "排序", "状态", "操作"]} />
|
||||
</thead>
|
||||
<tbody>
|
||||
{articles.map((article) => (
|
||||
<tr key={article.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="w-16 h-10 rounded bg-gray-50 border border-gray-200 overflow-hidden flex items-center justify-center shadow-sm">
|
||||
{article.coverImage && (
|
||||
{articles.map((article, i) => (
|
||||
<tr
|
||||
key={article.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 w-[110px]">
|
||||
<div className="w-[72px] h-12 rounded-md bg-[var(--bg-paper)] border border-[var(--border-muted)] overflow-hidden shadow-sm">
|
||||
{article.coverImage ? (
|
||||
<img src={article.coverImage} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<span className="text-[9px] tracking-[0.2em] uppercase text-[var(--text-muted)]/60">
|
||||
—
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-800 font-medium">{article.title}</td>
|
||||
<td className="px-4 py-3 text-gray-500 max-w-[260px] truncate">{article.subtitle || "—"}</td>
|
||||
<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 ? "启用" : "禁用"}
|
||||
</button>
|
||||
<td className="px-5 py-4">
|
||||
<p className="text-[15px] font-medium text-[var(--text-primary)]">{article.title}</p>
|
||||
{article.subtitle && (
|
||||
<p className="text-xs text-[var(--text-muted)] mt-0.5 max-w-[360px] truncate">
|
||||
{article.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right space-x-2">
|
||||
<Link to={`/admin/articles/${article.id}/edit`} className="text-blue-600 hover:underline">
|
||||
编辑
|
||||
</Link>
|
||||
<Link to={`/admin/articles/${article.id}/qrcode`} className="text-blue-600 hover:underline">
|
||||
二维码
|
||||
</Link>
|
||||
<button onClick={() => handleDelete(article.id, article.title)} className="text-red-500 hover:underline">
|
||||
删除
|
||||
</button>
|
||||
<td className="px-5 py-4 text-center w-[80px]">
|
||||
<span
|
||||
className="text-[13px] text-[var(--text-secondary)]"
|
||||
style={{ fontFamily: "'Playfair Display', serif" }}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
{articles.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
|
||||
暂无文章,点击右上角添加
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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 })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
181
packages/web/src/admin/Dashboard.tsx
Normal file
181
packages/web/src/admin/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
packages/web/src/admin/FormPrimitives.tsx
Normal file
94
packages/web/src/admin/FormPrimitives.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
packages/web/src/admin/Modal.tsx
Normal file
100
packages/web/src/admin/Modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import Modal from "./Modal";
|
||||
import { adminFetch } from "./adminApi";
|
||||
import { useToast } from "./Toast";
|
||||
import { Field, ErrorRow, FormFooter, HintRow, fieldCls } from "./FormPrimitives";
|
||||
|
||||
type Music = {
|
||||
id: string;
|
||||
@@ -11,11 +13,16 @@ type Music = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export default function MusicForm() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const isEdit = !!id;
|
||||
type Props = {
|
||||
open: boolean;
|
||||
id: string | null;
|
||||
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 [subtitle, setSubtitle] = useState("");
|
||||
const [audioFile, setAudioFile] = useState("");
|
||||
@@ -25,8 +32,17 @@ export default function MusicForm() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const isEdit = !!currentId;
|
||||
|
||||
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) => {
|
||||
const item = list.find((m) => m.id === id);
|
||||
if (item) {
|
||||
@@ -37,10 +53,10 @@ export default function MusicForm() {
|
||||
setEnabled(item.enabled);
|
||||
}
|
||||
});
|
||||
}, [id]);
|
||||
}, [open, id]);
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
if (!id) {
|
||||
if (!currentId) {
|
||||
setError("请先保存后再上传音频");
|
||||
return;
|
||||
}
|
||||
@@ -49,11 +65,13 @@ export default function MusicForm() {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("audio", file);
|
||||
const data = await adminFetch<{ path: string }>(`/music/${id}/upload`, {
|
||||
const data = await adminFetch<{ path: string }>(`/music/${currentId}/upload`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
setAudioFile(data.path);
|
||||
toast.show("音频已上传");
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "上传失败");
|
||||
} finally {
|
||||
@@ -63,10 +81,7 @@ export default function MusicForm() {
|
||||
|
||||
const handleSave = async () => {
|
||||
setError("");
|
||||
if (!title.trim()) {
|
||||
setError("请输入标题");
|
||||
return;
|
||||
}
|
||||
if (!title.trim()) return setError("请输入标题");
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
@@ -76,19 +91,22 @@ export default function MusicForm() {
|
||||
enabled,
|
||||
};
|
||||
if (isEdit) {
|
||||
await adminFetch(`/music/${id}`, {
|
||||
await adminFetch(`/music/${currentId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
toast.show("已保存");
|
||||
onSaved();
|
||||
onClose();
|
||||
} else {
|
||||
const music = await adminFetch<Music>("/music", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
navigate(`/admin/music/${music.id}/edit`, { replace: true });
|
||||
return;
|
||||
setCurrentId(music.id);
|
||||
toast.show("已创建,现在可以上传音频");
|
||||
onSaved();
|
||||
}
|
||||
navigate("/admin/music");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
@@ -97,102 +115,88 @@ export default function MusicForm() {
|
||||
};
|
||||
|
||||
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>
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
eyebrow={isEdit ? "Edit Music" : "New Music"}
|
||||
title={isEdit ? "编辑音乐" : "添加音乐"}
|
||||
subtitle={isEdit ? "调整信息与上传音频" : "先保存基础信息,再上传音频文件"}
|
||||
>
|
||||
<div className="px-7 py-6 space-y-5">
|
||||
<Field label="标题" required>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
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"
|
||||
className={fieldCls}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">副标题</label>
|
||||
<Field label="副标题">
|
||||
<input
|
||||
value={subtitle}
|
||||
onChange={(e) => setSubtitle(e.target.value)}
|
||||
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"
|
||||
className={fieldCls}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">排序</label>
|
||||
<div className="grid grid-cols-[auto_1fr] gap-5 items-end">
|
||||
<Field 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"
|
||||
className={fieldCls + " w-28"}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center pt-6">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
</Field>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer select-none pb-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
className="w-4 h-4 accent-[var(--jade)]"
|
||||
/>
|
||||
启用
|
||||
<span className="text-sm text-[var(--text-secondary)]">启用(访客可访问)</span>
|
||||
</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" />
|
||||
{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="text-xs text-gray-500"
|
||||
className="hidden"
|
||||
/>
|
||||
{uploading && <p className="text-xs text-gray-500 mt-1">上传中…</p>}
|
||||
<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="保存基础信息后,即可上传音频文件" />
|
||||
)}
|
||||
|
||||
{!isEdit && (
|
||||
<p className="text-xs text-gray-400">保存后可上传音频文件</p>
|
||||
)}
|
||||
{error && <ErrorRow text={error} />}
|
||||
</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/music")}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded-md hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormFooter onCancel={onClose} onSave={handleSave} saving={saving} primaryLabel={isEdit ? "保存" : "创建"} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
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 = {
|
||||
id: string;
|
||||
@@ -12,11 +27,14 @@ type Music = {
|
||||
};
|
||||
|
||||
export default function MusicList() {
|
||||
const toast = useToast();
|
||||
const [music, setMusic] = useState<Music[]>([]);
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await adminFetch<Music[]>("/music");
|
||||
setMusic(data);
|
||||
@@ -25,12 +43,19 @@ export default function MusicList() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchMusic(); }, []);
|
||||
useEffect(() => {
|
||||
fetchMusic();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: string, title: string) => {
|
||||
if (!confirm(`确定删除音乐「${title}」?`)) return;
|
||||
try {
|
||||
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) => {
|
||||
@@ -41,78 +66,134 @@ export default function MusicList() {
|
||||
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 (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800">音乐管理</h2>
|
||||
<Link
|
||||
to="/admin/music/new"
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
|
||||
>
|
||||
添加音乐
|
||||
</Link>
|
||||
</div>
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="04 · Music"
|
||||
title="音乐管理"
|
||||
caption="音频作品与对应点位的 NFC 链接"
|
||||
action={<PrimaryButton onClick={() => setFormState({ open: true, id: null })}>添加音乐</PrimaryButton>}
|
||||
/>
|
||||
|
||||
<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-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>
|
||||
{loading ? (
|
||||
<LoadingBlock />
|
||||
) : (
|
||||
<TableCard>
|
||||
{music.length === 0 ? (
|
||||
<EmptyState
|
||||
message="尚未上传音乐"
|
||||
action={
|
||||
<PrimaryButton onClick={() => setFormState({ open: true, id: null })}>
|
||||
添加第一首音乐
|
||||
</PrimaryButton>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<TableHeadRow cols={["标题 · 副标题", "音频", "排序", "状态", "操作"]} />
|
||||
</thead>
|
||||
<tbody>
|
||||
{music.map((item) => (
|
||||
<tr key={item.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-gray-800 font-medium">{item.title}</td>
|
||||
<td className="px-4 py-3 text-gray-500 max-w-[220px] truncate">{item.subtitle || "—"}</td>
|
||||
<td className="px-4 py-3 text-gray-500 text-xs">
|
||||
{music.map((item, i) => (
|
||||
<tr
|
||||
key={item.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-10 h-10 rounded-full flex items-center justify-center shrink-0 border border-[var(--border-muted)]"
|
||||
style={{ backgroundColor: "rgba(212,165,116,0.08)" }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--gold)" strokeWidth="1.8">
|
||||
<path d="M9 18V5l12-2v13M9 9l12-2" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[15px] font-medium text-[var(--text-primary)]">{item.title}</p>
|
||||
{item.subtitle && (
|
||||
<p className="text-xs text-[var(--text-muted)] mt-0.5 max-w-[260px] truncate">
|
||||
{item.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 w-[260px]">
|
||||
{item.audioFile ? (
|
||||
<audio src={item.audioFile} controls preload="none" className="h-8 max-w-[220px]" />
|
||||
<audio
|
||||
src={item.audioFile}
|
||||
controls
|
||||
preload="none"
|
||||
className="h-8 max-w-[240px]"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-gray-300">未上传</span>
|
||||
<span className="text-xs text-[var(--text-muted)]/60">未上传</span>
|
||||
)}
|
||||
</td>
|
||||
<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"
|
||||
}`}
|
||||
<td className="px-5 py-4 text-center w-[80px]">
|
||||
<span
|
||||
className="text-[13px] text-[var(--text-secondary)]"
|
||||
style={{ fontFamily: "'Playfair Display', serif" }}
|
||||
>
|
||||
{item.enabled ? "启用" : "禁用"}
|
||||
</button>
|
||||
{item.sortOrder}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right space-x-2 whitespace-nowrap">
|
||||
<Link to={`/admin/music/${item.id}/edit`} className="text-blue-600 hover:underline">
|
||||
编辑
|
||||
</Link>
|
||||
<Link to={`/admin/music/${item.id}/qrcode`} className="text-blue-600 hover:underline">
|
||||
二维码
|
||||
</Link>
|
||||
<button onClick={() => handleDelete(item.id, item.title)} className="text-red-500 hover:underline">
|
||||
删除
|
||||
</button>
|
||||
<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>
|
||||
))}
|
||||
{music.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
|
||||
暂无音乐,点击右上角添加
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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 })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
161
packages/web/src/admin/PageHeader.tsx
Normal file
161
packages/web/src/admin/PageHeader.tsx
Normal 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>
|
||||
);
|
||||
143
packages/web/src/admin/QRCodeModal.tsx
Normal file
143
packages/web/src/admin/QRCodeModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { adminFetch } from "./adminApi";
|
||||
import PageHeader, { LoadingBlock } from "./PageHeader";
|
||||
import { TableCard, TableHeadRow } from "./StampList";
|
||||
|
||||
type RedemptionRecord = {
|
||||
id: string;
|
||||
userId: string;
|
||||
stampCount: number;
|
||||
redeemedAt: string;
|
||||
user: { username: string; phone: string };
|
||||
rule: { name: string };
|
||||
stampName: string;
|
||||
prizeName: string;
|
||||
};
|
||||
|
||||
type Stats = {
|
||||
@@ -16,6 +17,12 @@ type Stats = {
|
||||
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() {
|
||||
const [records, setRecords] = useState<RedemptionRecord[]>([]);
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
@@ -33,62 +40,120 @@ export default function RedemptionLog() {
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <p className="text-gray-500">加载中...</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">兑换记录</h2>
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="06 · Log"
|
||||
title="兑换记录"
|
||||
caption="账户、图章收集与兑换的完整轨迹"
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<LoadingBlock />
|
||||
) : (
|
||||
<>
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
{[
|
||||
{ label: "注册用户", value: stats.userCount },
|
||||
{ label: "当前收集数", value: stats.collectionCount },
|
||||
{ label: "累计兑换", value: stats.redemptionCount },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="bg-white rounded-lg border border-gray-200 p-4 text-center">
|
||||
<p className="text-2xl font-semibold text-gray-800">{s.value}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{s.label}</p>
|
||||
<div className="grid grid-cols-3 gap-5 mb-8">
|
||||
{STAT_CARDS.map((card, i) => (
|
||||
<div
|
||||
key={card.key}
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Records table */}
|
||||
<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-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-right px-4 py-3 font-medium text-gray-600">时间</th>
|
||||
</tr>
|
||||
{/* Records */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="block w-5 h-px bg-[var(--gold)]/40" />
|
||||
<span
|
||||
className="text-[10px] tracking-[0.3em] uppercase text-[var(--text-muted)]"
|
||||
style={{ fontFamily: "'Playfair Display', serif" }}
|
||||
>
|
||||
Ledger
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<TableCard>
|
||||
{records.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="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||
<path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-muted)]">暂无兑换记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<TableHeadRow cols={["用户", "手机号", "图章", "奖品", "时间"]} />
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((r) => (
|
||||
<tr key={r.id} className="border-b border-gray-100">
|
||||
<td className="px-4 py-3 text-gray-800">{r.user.username}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{r.user.phone}</td>
|
||||
<td className="px-4 py-3 text-gray-700">{r.rule.name}</td>
|
||||
<td className="px-4 py-3 text-center text-gray-500">{r.stampCount}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-500">
|
||||
{records.map((r, i) => (
|
||||
<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>
|
||||
))}
|
||||
{records.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-400">
|
||||
暂无兑换记录
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TableCard>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import Modal from "./Modal";
|
||||
import { adminFetch } from "./adminApi";
|
||||
import { useToast } from "./Toast";
|
||||
import { Field, ErrorRow, FormFooter, HintRow, fieldCls } from "./FormPrimitives";
|
||||
|
||||
type Prize = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
stock: number;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type Stamp = {
|
||||
id: string;
|
||||
@@ -10,13 +20,19 @@ type Stamp = {
|
||||
imageGrey: string;
|
||||
sortOrder: number;
|
||||
enabled: boolean;
|
||||
prize: Prize | null;
|
||||
};
|
||||
|
||||
export default function StampForm() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const isEdit = !!id;
|
||||
type Props = {
|
||||
open: boolean;
|
||||
id: string | null;
|
||||
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 [note, setNote] = useState("");
|
||||
const [sortOrder, setSortOrder] = useState(0);
|
||||
@@ -25,8 +41,29 @@ export default function StampForm() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [prizeName, setPrizeName] = useState("");
|
||||
const [prizeDescription, setPrizeDescription] = useState("");
|
||||
const [prizeStock, setPrizeStock] = useState(0);
|
||||
const [prizeEnabled, setPrizeEnabled] = useState(true);
|
||||
|
||||
const isEdit = !!currentId;
|
||||
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
const stamp = stamps.find((s) => s.id === id);
|
||||
if (stamp) {
|
||||
@@ -35,24 +72,42 @@ export default function StampForm() {
|
||||
setSortOrder(stamp.sortOrder);
|
||||
setImageColor(stamp.imageColor);
|
||||
setImageGrey(stamp.imageGrey);
|
||||
if (stamp.prize) {
|
||||
setPrizeName(stamp.prize.name);
|
||||
setPrizeDescription(stamp.prize.description || "");
|
||||
setPrizeStock(stamp.prize.stock);
|
||||
setPrizeEnabled(stamp.prize.enabled);
|
||||
} else {
|
||||
setPrizeName("");
|
||||
setPrizeDescription("");
|
||||
setPrizeStock(0);
|
||||
setPrizeEnabled(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [id]);
|
||||
}, [open, id]);
|
||||
|
||||
const handleUpload = async (file: File, field: "imageColor" | "imageGrey") => {
|
||||
if (!id) {
|
||||
if (!currentId) {
|
||||
setError("请先保存图章后再上传图片");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
const formData = new FormData();
|
||||
formData.append("image", file);
|
||||
formData.append("field", field);
|
||||
const data = await adminFetch<{ path: string }>(`/stamps/${id}/upload`, {
|
||||
try {
|
||||
const data = await adminFetch<{ path: string }>(`/stamps/${currentId}/upload`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (field === "imageColor") setImageColor(data.path);
|
||||
else setImageGrey(data.path);
|
||||
toast.show("图片已上传");
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "上传失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -61,22 +116,41 @@ export default function StampForm() {
|
||||
setError("请输入图章名称");
|
||||
return;
|
||||
}
|
||||
if (isEdit && prizeName.trim() && prizeStock < 0) {
|
||||
setError("库存不能为负数");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = { name: name.trim(), note: note.trim() || undefined, sortOrder };
|
||||
if (isEdit) {
|
||||
await adminFetch(`/stamps/${id}`, {
|
||||
await adminFetch(`/stamps/${currentId}`, {
|
||||
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 {
|
||||
const stamp = await adminFetch<Stamp>("/stamps", {
|
||||
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 });
|
||||
return;
|
||||
setCurrentId(stamp.id);
|
||||
toast.show("已创建,现在可以上传图片与配置奖品");
|
||||
onSaved();
|
||||
}
|
||||
navigate("/admin/stamps");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
@@ -85,100 +159,170 @@ export default function StampForm() {
|
||||
};
|
||||
|
||||
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>
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
eyebrow={isEdit ? "Edit Stamp" : "New Stamp"}
|
||||
title={isEdit ? "编辑图章" : "添加图章"}
|
||||
subtitle={isEdit ? "调整信息、上传图片并配置关联奖品" : "先保存基础信息,再上传图片与配置奖品"}
|
||||
>
|
||||
<div className="px-7 py-6 space-y-5">
|
||||
<Field label="名称" required>
|
||||
<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"
|
||||
placeholder="如:朝天宫"
|
||||
className={fieldCls}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">备注</label>
|
||||
<Field label="品牌说明" hint="选填,展示在收集弹窗与集章册详情中">
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(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"
|
||||
placeholder="例:品牌定位、特色亮点一句话"
|
||||
className={fieldCls + " resize-none"}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">排序</label>
|
||||
<Field label="排序" hint="数字小的在前">
|
||||
<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"
|
||||
className={fieldCls + " w-28"}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{isEdit ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<ImageSlot
|
||||
label="彩色图章"
|
||||
kind="color"
|
||||
image={imageColor}
|
||||
onUpload={(f) => handleUpload(f, "imageColor")}
|
||||
/>
|
||||
<ImageSlot
|
||||
label="灰色图章"
|
||||
kind="grey"
|
||||
image={imageGrey}
|
||||
onUpload={(f) => handleUpload(f, "imageGrey")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Image uploads - only available after saving */}
|
||||
{isEdit && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">彩色图章</label>
|
||||
{imageColor && (
|
||||
<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">
|
||||
<img src={imageColor} alt="彩色" className="w-[92%] h-[92%] object-contain" />
|
||||
<div className="pt-4 mt-2 border-t border-dashed border-[var(--border-muted)]">
|
||||
<div className="flex items-baseline justify-between mb-3">
|
||||
<span className="text-[13px] font-medium text-[var(--text-secondary)]">关联奖品</span>
|
||||
<span
|
||||
className="text-[9px] tracking-[0.3em] uppercase text-[var(--gold)]"
|
||||
style={{ fontFamily: "'Playfair Display', serif" }}
|
||||
>
|
||||
Prize
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Field label="奖品名称" hint="留空表示此图章暂不提供兑换">
|
||||
<input
|
||||
value={prizeName}
|
||||
onChange={(e) => setPrizeName(e.target.value)}
|
||||
placeholder="如:品牌 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>
|
||||
</>
|
||||
) : (
|
||||
<HintRow text="保存基础信息后,即可上传图章图片并配置关联奖品" />
|
||||
)}
|
||||
|
||||
{error && <ErrorRow text={error} />}
|
||||
</div>
|
||||
|
||||
<FormFooter onCancel={onClose} onSave={handleSave} saving={saving} primaryLabel={isEdit ? "保存" : "创建"} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageSlot({
|
||||
label,
|
||||
kind,
|
||||
image,
|
||||
onUpload,
|
||||
}: {
|
||||
label: string;
|
||||
kind: "color" | "grey";
|
||||
image: string;
|
||||
onUpload: (f: File) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="flex items-baseline justify-between mb-1.5">
|
||||
<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>
|
||||
<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] && handleUpload(e.target.files[0], "imageColor")}
|
||||
className="text-xs text-gray-500"
|
||||
onChange={(e) => e.target.files?.[0] && onUpload(e.target.files[0])}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">灰色图章</label>
|
||||
{imageGrey && (
|
||||
<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">
|
||||
<img src={imageGrey} alt="灰色" className="w-[92%] h-[92%] object-contain" />
|
||||
</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>
|
||||
)}
|
||||
|
||||
{!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/stamps")}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded-md hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
<span className="underline underline-offset-2 decoration-dotted">
|
||||
{image ? "更换图片" : "选择图片"}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
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 = {
|
||||
id: string;
|
||||
@@ -10,14 +24,24 @@ type Stamp = {
|
||||
imageGrey: string;
|
||||
sortOrder: number;
|
||||
enabled: boolean;
|
||||
prize: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
stock: number;
|
||||
enabled: boolean;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export default function StampList() {
|
||||
const toast = useToast();
|
||||
const [stamps, setStamps] = useState<Stamp[]>([]);
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await adminFetch<Stamp[]>("/stamps");
|
||||
setStamps(data);
|
||||
@@ -26,12 +50,19 @@ export default function StampList() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchStamps(); }, []);
|
||||
useEffect(() => {
|
||||
fetchStamps();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (!confirm(`确定删除图章「${name}」?`)) return;
|
||||
try {
|
||||
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) => {
|
||||
@@ -42,78 +73,178 @@ export default function StampList() {
|
||||
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 (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800">图章管理</h2>
|
||||
<Link
|
||||
to="/admin/stamps/new"
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
|
||||
>
|
||||
添加图章
|
||||
</Link>
|
||||
</div>
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="02 · Stamps"
|
||||
title="图章管理"
|
||||
caption="收集图章、生成点位 NFC 链接与备用二维码"
|
||||
action={
|
||||
<PrimaryButton onClick={() => setFormState({ open: true, id: null })}>添加图章</PrimaryButton>
|
||||
}
|
||||
/>
|
||||
|
||||
<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-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>
|
||||
{loading ? (
|
||||
<LoadingBlock />
|
||||
) : (
|
||||
<TableCard>
|
||||
{stamps.length === 0 ? (
|
||||
<EmptyState
|
||||
message="尚未创建图章"
|
||||
action={
|
||||
<PrimaryButton onClick={() => setFormState({ open: true, id: null })}>
|
||||
添加第一枚图章
|
||||
</PrimaryButton>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<TableHeadRow cols={["图章", "名称 · 备注", "奖品 · 库存", "排序", "状态", "操作"]} />
|
||||
</thead>
|
||||
<tbody>
|
||||
{stamps.map((stamp) => (
|
||||
<tr key={stamp.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="w-10 h-10 rounded-full bg-white border border-gray-200 overflow-hidden flex items-center justify-center shadow-sm">
|
||||
{stamp.imageColor && (
|
||||
{stamps.map((stamp, i) => (
|
||||
<tr
|
||||
key={stamp.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 w-[90px]">
|
||||
<div className="w-12 h-12 rounded-full bg-white border border-[var(--border-muted)] overflow-hidden flex items-center justify-center shadow-sm">
|
||||
{stamp.imageColor ? (
|
||||
<img src={stamp.imageColor} alt="" className="w-[92%] h-[92%] object-contain" />
|
||||
) : (
|
||||
<span className="text-[9px] tracking-[0.2em] uppercase text-[var(--text-muted)]/60">
|
||||
No image
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-800">{stamp.name}</td>
|
||||
<td className="px-4 py-3 text-gray-500 max-w-[200px] truncate">{stamp.note || "—"}</td>
|
||||
<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 ? "启用" : "禁用"}
|
||||
</button>
|
||||
<td className="px-5 py-4">
|
||||
<p className="text-[15px] font-medium text-[var(--text-primary)]">{stamp.name}</p>
|
||||
{stamp.note && (
|
||||
<p className="text-xs text-[var(--text-muted)] mt-0.5 max-w-[360px] truncate">
|
||||
{stamp.note}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right space-x-2">
|
||||
<Link to={`/admin/stamps/${stamp.id}/edit`} className="text-blue-600 hover:underline">
|
||||
编辑
|
||||
</Link>
|
||||
<Link to={`/admin/stamps/${stamp.id}/qrcode`} className="text-blue-600 hover:underline">
|
||||
二维码
|
||||
</Link>
|
||||
<button onClick={() => handleDelete(stamp.id, stamp.name)} className="text-red-500 hover:underline">
|
||||
删除
|
||||
</button>
|
||||
<td className="px-5 py-4 w-[220px]">
|
||||
{stamp.prize ? (
|
||||
<>
|
||||
<p className="text-sm text-[var(--text-primary)] truncate max-w-[200px]">
|
||||
{stamp.prize.name}
|
||||
</p>
|
||||
<p className="text-xs mt-0.5">
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
stamp.prize.stock > 0
|
||||
? "var(--text-muted)"
|
||||
: "var(--terracotta)",
|
||||
}}
|
||||
>
|
||||
库存 {stamp.prize.stock}
|
||||
</span>
|
||||
{!stamp.prize.enabled && (
|
||||
<span className="ml-2 text-[var(--text-muted)]">· 已禁用</span>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--text-muted)]/70 italic">未配置</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-center w-[80px]">
|
||||
<span
|
||||
className="text-[13px] text-[var(--text-secondary)]"
|
||||
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>
|
||||
))}
|
||||
{stamps.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
|
||||
暂无图章,点击右上角添加
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
51
packages/web/src/admin/Toast.tsx
Normal file
51
packages/web/src/admin/Toast.tsx
Normal 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;
|
||||
}
|
||||
151
packages/web/src/admin/UsersList.tsx
Normal file
151
packages/web/src/admin/UsersList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
packages/web/src/admin/utils.ts
Normal file
36
packages/web/src/admin/utils.ts
Normal 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;
|
||||
}
|
||||
@@ -1,26 +1,35 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { RedemptionRuleInfo } from "@stamp/shared";
|
||||
import type { StampWithStatus } from "@stamp/shared";
|
||||
|
||||
type RedeemModalProps = {
|
||||
rules: RedemptionRuleInfo[];
|
||||
collectedCount: number;
|
||||
onRedeem: (ruleId: string) => Promise<void>;
|
||||
stamp: StampWithStatus;
|
||||
onRedeem: (stampId: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const CONFIRM_COUNTDOWN = 5;
|
||||
|
||||
export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }: RedeemModalProps) {
|
||||
const [redeeming, setRedeeming] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [confirmRuleId, setConfirmRuleId] = useState<string | null>(null);
|
||||
type Mode = "uncollected" | "redeemed" | "sold-out" | "unavailable" | "ready";
|
||||
|
||||
function resolveMode(stamp: StampWithStatus): Mode {
|
||||
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 [redeeming, setRedeeming] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const confirmRule = confirmRuleId ? rules.find((r) => r.id === confirmRuleId) : null;
|
||||
const mode = resolveMode(stamp);
|
||||
const prize = stamp.prize;
|
||||
|
||||
// 5-second countdown that restarts each time the confirm panel opens
|
||||
useEffect(() => {
|
||||
if (!confirmRuleId) return;
|
||||
if (!confirming) return;
|
||||
setCountdown(CONFIRM_COUNTDOWN);
|
||||
const interval = setInterval(() => {
|
||||
setCountdown((c) => {
|
||||
@@ -32,45 +41,67 @@ export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [confirmRuleId]);
|
||||
}, [confirming]);
|
||||
|
||||
const openConfirm = (ruleId: string) => {
|
||||
const openConfirm = () => {
|
||||
if (mode !== "ready") return;
|
||||
setError("");
|
||||
setConfirmRuleId(ruleId);
|
||||
setConfirming(true);
|
||||
};
|
||||
|
||||
const cancelConfirm = () => {
|
||||
if (redeeming) return;
|
||||
setConfirmRuleId(null);
|
||||
setConfirming(false);
|
||||
};
|
||||
|
||||
const doRedeem = async () => {
|
||||
if (!confirmRule || countdown > 0) return;
|
||||
setRedeeming(confirmRule.id);
|
||||
if (countdown > 0 || redeeming || mode !== "ready") return;
|
||||
setRedeeming(true);
|
||||
setError("");
|
||||
try {
|
||||
await onRedeem(confirmRule.id);
|
||||
await onRedeem(stamp.id);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "兑换失败");
|
||||
setConfirmRuleId(null);
|
||||
setConfirming(false);
|
||||
} finally {
|
||||
setRedeeming(null);
|
||||
setRedeeming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buttonCopy = () => {
|
||||
switch (mode) {
|
||||
case "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 (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center animate-overlay-fade"
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center animate-overlay-fade"
|
||||
style={{ backgroundColor: "var(--overlay)" }}
|
||||
onClick={(e) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (confirmRuleId) return; // Don't dismiss during confirm flow
|
||||
if (confirming) return;
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
<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">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
@@ -78,57 +109,99 @@ export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-4">
|
||||
当前已收集 <span className="font-semibold text-[var(--jade)]">{collectedCount}</span> 枚图章
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-[var(--terracotta)] mb-3">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{rules.map((rule) => {
|
||||
const canRedeem = collectedCount >= rule.threshold;
|
||||
return (
|
||||
{/* Stamp header */}
|
||||
<div className="flex items-center gap-4 mb-5">
|
||||
<div
|
||||
key={rule.id}
|
||||
className="flex items-center justify-between p-4 rounded-xl border"
|
||||
className="w-16 h-16 rounded-full bg-white overflow-hidden flex items-center justify-center border border-[var(--border-muted)] shrink-0"
|
||||
style={{
|
||||
borderColor: canRedeem ? "var(--jade)" : "var(--border-muted)",
|
||||
backgroundColor: canRedeem ? "rgba(45, 106, 79, 0.05)" : "white",
|
||||
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="flex-1 min-w-0 mr-3">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||
{rule.name}
|
||||
</p>
|
||||
{rule.description && (
|
||||
<p className="text-xs text-[var(--text-muted)] mt-0.5 truncate">{rule.description}</p>
|
||||
)}
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1">
|
||||
需要 {rule.threshold} 枚图章
|
||||
</p>
|
||||
<img
|
||||
src={stamp.collected ? stamp.imageColor : stamp.imageGrey}
|
||||
alt={stamp.name}
|
||||
className="w-[92%] h-[92%] object-contain"
|
||||
style={{ opacity: stamp.collected ? 1 : 0.6 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] tracking-[0.25em] uppercase text-[var(--gold)] mb-0.5">Stamp</p>
|
||||
<p className="text-base font-semibold text-[var(--text-primary)] truncate">{stamp.name}</p>
|
||||
{mode === "uncollected" ? (
|
||||
<p className="text-xs text-[var(--text-muted)] mt-0.5">尚未收集</p>
|
||||
) : stamp.collectedAt ? (
|
||||
<p className="text-xs text-[var(--text-muted)] mt-0.5">
|
||||
收集于 {new Date(stamp.collectedAt).toLocaleDateString("zh-CN")}
|
||||
</p>
|
||||
) : null}
|
||||
</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(rule.id)}
|
||||
disabled={!canRedeem || !!redeeming}
|
||||
className="shrink-0 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
|
||||
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: canRedeem ? "var(--jade)" : "var(--border-muted)",
|
||||
color: canRedeem ? "white" : "var(--text-muted)",
|
||||
backgroundColor: buttonBg,
|
||||
color: buttonColor,
|
||||
boxShadow: mode === "ready" ? "0 2px 8px rgba(45,106,79,0.25)" : "none",
|
||||
}}
|
||||
>
|
||||
兑换
|
||||
{buttonCopy()}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Confirmation dialog — centered over the sheet, highest priority */}
|
||||
{confirmRule && (
|
||||
{/* Confirmation dialog */}
|
||||
{confirming && prize && (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center px-5 py-6 animate-overlay-fade"
|
||||
style={{ backgroundColor: "rgba(26, 26, 46, 0.6)" }}
|
||||
@@ -138,17 +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"
|
||||
style={{ boxShadow: "0 24px 60px rgba(26,26,46,0.35)" }}
|
||||
>
|
||||
{/* Warning at the top — most prominent, filled terracotta */}
|
||||
<div
|
||||
className="px-5 py-4"
|
||||
style={{
|
||||
backgroundColor: "var(--terracotta)",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
{/* Warning */}
|
||||
<div className="px-5 py-4" style={{ backgroundColor: "var(--terracotta)", color: "#fff" }}>
|
||||
<div className="flex gap-3">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2"
|
||||
className="shrink-0 mt-0.5">
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.2"
|
||||
className="shrink-0 mt-0.5"
|
||||
>
|
||||
<path d="M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
@@ -160,50 +234,37 @@ export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 pt-5 pb-5">
|
||||
<h3 className="text-base font-semibold text-[var(--text-primary)] mb-4">确认兑换</h3>
|
||||
|
||||
{/* Reward */}
|
||||
<div className="rounded-xl border border-[var(--jade)] bg-[rgba(45,106,79,0.05)] p-3.5 mb-3">
|
||||
<p className="text-[10px] tracking-[0.2em] text-[var(--jade)] uppercase mb-1">Reward</p>
|
||||
<p className="text-sm font-semibold text-[var(--text-primary)]">{confirmRule.name}</p>
|
||||
{confirmRule.description && (
|
||||
<p className="text-xs text-[var(--text-muted)] mt-0.5">{confirmRule.description}</p>
|
||||
<p className="text-[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-primary)] leading-relaxed">{prize.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Deduction summary */}
|
||||
<div className="rounded-xl border border-[var(--border-default)] bg-white p-3.5 mb-2">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-xs text-[var(--text-muted)]">将扣除</span>
|
||||
<span className="text-xl font-semibold text-[var(--terracotta)]">{confirmRule.threshold}</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1 leading-relaxed">
|
||||
按收集顺序扣除最早的 {confirmRule.threshold} 枚,剩余{" "}
|
||||
<span className="font-medium text-[var(--text-secondary)]">
|
||||
{collectedCount - confirmRule.threshold}
|
||||
</span>{" "}
|
||||
枚将继续保留。
|
||||
<p className="text-xs text-[var(--text-muted)] leading-relaxed">
|
||||
兑换后,「<span className="font-medium text-[var(--text-secondary)]">{stamp.name}</span>」图章将
|
||||
<span className="font-medium text-[var(--jade)]">保持彩色点亮</span>并标记为「已兑换」,此奖品不可再次兑换。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-[var(--text-muted)] text-center mb-4">
|
||||
一旦确认,图章将立即扣除,此操作不可撤销
|
||||
</p>
|
||||
<p className="text-[11px] text-[var(--text-muted)] text-center mb-4">一旦确认,操作不可撤销</p>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-2.5">
|
||||
<button
|
||||
onClick={cancelConfirm}
|
||||
disabled={!!redeeming}
|
||||
disabled={redeeming}
|
||||
className="flex-1 py-3 rounded-xl text-sm font-medium border border-[var(--border-default)] text-[var(--text-secondary)] bg-white disabled:opacity-40"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={doRedeem}
|
||||
disabled={countdown > 0 || !!redeeming}
|
||||
disabled={countdown > 0 || redeeming}
|
||||
className="flex-[1.3] py-3 rounded-xl text-sm font-medium transition-colors"
|
||||
style={{
|
||||
backgroundColor: countdown > 0 || redeeming ? "var(--border-default)" : "var(--jade)",
|
||||
@@ -211,11 +272,7 @@ export default function RedeemModal({ rules, collectedCount, onRedeem, onClose }
|
||||
boxShadow: countdown > 0 || redeeming ? "none" : "0 2px 8px rgba(45,106,79,0.25)",
|
||||
}}
|
||||
>
|
||||
{redeeming
|
||||
? "兑换中..."
|
||||
: countdown > 0
|
||||
? `请阅读提示 ${countdown}s`
|
||||
: "确认兑换"}
|
||||
{redeeming ? "兑换中..." : countdown > 0 ? `请阅读提示 ${countdown}s` : "确认兑换"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,10 +3,11 @@ type StampCardProps = {
|
||||
imageColor: string;
|
||||
imageGrey: string;
|
||||
collected: boolean;
|
||||
redeemed?: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export default function StampCard({ name, imageColor, imageGrey, collected, onClick }: StampCardProps) {
|
||||
export default function StampCard({ name, imageColor, imageGrey, collected, redeemed, onClick }: StampCardProps) {
|
||||
const src = collected ? imageColor : imageGrey;
|
||||
|
||||
return (
|
||||
@@ -43,13 +44,22 @@ export default function StampCard({ name, imageColor, imageGrey, collected, onCl
|
||||
/>
|
||||
</div>
|
||||
|
||||
{collected && (
|
||||
{collected && !redeemed && (
|
||||
<div className="absolute top-0 right-0 w-4 h-4 rounded-full bg-[var(--jade)] flex items-center justify-center shadow-sm z-10">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{redeemed && (
|
||||
<div
|
||||
className="absolute -top-1 right-0 px-1.5 py-[1px] rounded-full text-[9px] font-semibold leading-tight shadow-sm z-10"
|
||||
style={{ backgroundColor: "var(--gold)", color: "white", letterSpacing: "0.05em" }}
|
||||
>
|
||||
已兑换
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function StampGrid({ stamps, onStampClick }: StampGridProps) {
|
||||
imageColor={stamp.imageColor}
|
||||
imageGrey={stamp.imageGrey}
|
||||
collected={stamp.collected}
|
||||
redeemed={stamp.redeemed}
|
||||
onClick={() => onStampClick?.(stamp)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { PrizeInfo } from "@stamp/shared";
|
||||
|
||||
type StampPopupProps = {
|
||||
name: string;
|
||||
imageColor: string;
|
||||
note?: string | null;
|
||||
prize?: PrizeInfo | null;
|
||||
status: "preview" | "collected" | "already";
|
||||
onCollect?: () => 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();
|
||||
|
||||
return (
|
||||
<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)" }}
|
||||
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 */}
|
||||
<div className="w-40 h-40 mx-auto mb-4">
|
||||
<div className="w-36 h-36 mx-auto mb-4">
|
||||
<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"
|
||||
style={{
|
||||
@@ -44,13 +46,34 @@ export default function StampPopup({ name, imageColor, note, status, onCollect,
|
||||
|
||||
{/* Stamp name */}
|
||||
<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 === "preview" && (
|
||||
<button
|
||||
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)" }}
|
||||
>
|
||||
立即获取
|
||||
|
||||
@@ -111,6 +111,16 @@
|
||||
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) ===== */
|
||||
@layer components {
|
||||
.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-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-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 {
|
||||
-webkit-appearance: none;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { StampWithStatus, RedemptionRuleInfo, RedemptionRecord } from "@stamp/shared";
|
||||
import type { StampWithStatus, RedemptionRecord } from "@stamp/shared";
|
||||
import { apiFetch } from "../lib/api";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import StampGrid from "../components/StampGrid";
|
||||
@@ -11,27 +11,25 @@ export default function AlbumPage() {
|
||||
const navigate = useNavigate();
|
||||
const { user, isLoading: authLoading } = useAuth();
|
||||
const [stamps, setStamps] = useState<StampWithStatus[]>([]);
|
||||
const [rules, setRules] = useState<RedemptionRuleInfo[]>([]);
|
||||
const [history, setHistory] = useState<RedemptionRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showRedeem, setShowRedeem] = useState(false);
|
||||
const [selectedStampId, setSelectedStampId] = useState<string | null>(null);
|
||||
const [showRegister, setShowRegister] = useState(false);
|
||||
|
||||
const collectedCount = stamps.filter((s) => s.collected).length;
|
||||
const selectedStamp = selectedStampId ? stamps.find((s) => s.id === selectedStampId) ?? null : null;
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [stampsData, rulesData] = await Promise.all([
|
||||
apiFetch<StampWithStatus[]>("/stamps"),
|
||||
apiFetch<RedemptionRuleInfo[]>("/redemption/rules"),
|
||||
]);
|
||||
const stampsData = await apiFetch<StampWithStatus[]>("/stamps");
|
||||
setStamps(stampsData);
|
||||
setRules(rulesData);
|
||||
|
||||
if (user) {
|
||||
const historyData = await apiFetch<RedemptionRecord[]>("/redemption/history");
|
||||
setHistory(historyData);
|
||||
} else {
|
||||
setHistory([]);
|
||||
}
|
||||
} catch {
|
||||
// Stamps endpoint works without auth
|
||||
@@ -44,20 +42,20 @@ export default function AlbumPage() {
|
||||
if (!authLoading) fetchData();
|
||||
}, [authLoading, user]);
|
||||
|
||||
const handleRedeem = async (ruleId: string) => {
|
||||
const handleRedeem = async (stampId: string) => {
|
||||
await apiFetch("/redemption/redeem", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ruleId }),
|
||||
body: JSON.stringify({ stampId }),
|
||||
});
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
const handleRedeemClick = () => {
|
||||
const handleStampClick = (stamp: StampWithStatus) => {
|
||||
if (!user) {
|
||||
setShowRegister(true);
|
||||
return;
|
||||
}
|
||||
setShowRedeem(true);
|
||||
setSelectedStampId(stamp.id);
|
||||
};
|
||||
|
||||
if (loading || authLoading) {
|
||||
@@ -108,40 +106,16 @@ export default function AlbumPage() {
|
||||
style={{ width: stamps.length ? `${(collectedCount / stamps.length) * 100}%` : "0%" }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-[11px] text-[var(--text-muted)] leading-relaxed">
|
||||
点击任意图章查看品牌权益,已点亮的图章可直接兑换
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stamp Grid */}
|
||||
<div className="px-4 pb-6">
|
||||
<StampGrid stamps={stamps} />
|
||||
<StampGrid stamps={stamps} onStampClick={handleStampClick} />
|
||||
</div>
|
||||
|
||||
{/* Redeem Section */}
|
||||
{rules.length > 0 && (() => {
|
||||
const availableCount = rules.filter((r) => collectedCount >= r.threshold).length;
|
||||
const canRedeem = availableCount > 0;
|
||||
return (
|
||||
<div className="px-6 pb-6">
|
||||
<button
|
||||
onClick={handleRedeemClick}
|
||||
disabled={!canRedeem}
|
||||
className="w-full py-3.5 rounded-xl text-sm font-medium transition-all disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
style={{
|
||||
backgroundColor: canRedeem ? "var(--jade)" : "var(--border-muted)",
|
||||
color: canRedeem ? "white" : "var(--text-muted)",
|
||||
boxShadow: canRedeem ? "0 2px 8px rgba(45,106,79,0.25)" : "none",
|
||||
}}
|
||||
>
|
||||
<span>{canRedeem ? "兑换奖品" : "继续收集以解锁奖品"}</span>
|
||||
{canRedeem && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-white/20">
|
||||
{availableCount} 个可兑换
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Redemption History */}
|
||||
{history.length > 0 && (
|
||||
<div className="px-6 pb-8">
|
||||
@@ -149,13 +123,13 @@ export default function AlbumPage() {
|
||||
<div className="space-y-2">
|
||||
{history.map((r) => (
|
||||
<div key={r.id} className="flex items-center justify-between py-2 px-3 bg-white/60 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--text-primary)]">{r.ruleName}</p>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-[var(--text-primary)] truncate">{r.prizeName}</p>
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
{new Date(r.redeemedAt).toLocaleDateString("zh-CN")}
|
||||
{r.stampName} · {new Date(r.redeemedAt).toLocaleDateString("zh-CN")}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--jade)]">已兑换</span>
|
||||
<span className="text-xs text-[var(--jade)] shrink-0 ml-3">已兑换</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -163,12 +137,11 @@ export default function AlbumPage() {
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
{showRedeem && (
|
||||
{selectedStamp && (
|
||||
<RedeemModal
|
||||
rules={rules}
|
||||
collectedCount={collectedCount}
|
||||
stamp={selectedStamp}
|
||||
onRedeem={handleRedeem}
|
||||
onClose={() => setShowRedeem(false)}
|
||||
onClose={() => setSelectedStampId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||
import type { PrizeInfo } from "@stamp/shared";
|
||||
import { apiFetch } from "../lib/api";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import FloatingButton from "../components/FloatingButton";
|
||||
@@ -14,14 +16,49 @@ type StampDetail = {
|
||||
note: string | null;
|
||||
imageColor: string;
|
||||
imageGrey: string;
|
||||
prize: PrizeInfo | null;
|
||||
};
|
||||
|
||||
type CollectState = "idle" | "loading" | "show_stamp" | "needs_register" | "collecting" | "collected" | "already_collected";
|
||||
|
||||
const STEPS = [
|
||||
{ num: "01", title: "前往点位", desc: "跟随路线,探索散落在城市中的文化地标" },
|
||||
{ num: "02", title: "触碰集章", desc: "手机轻触点位 NFC 芯片,图章即刻收入囊中" },
|
||||
{ num: "03", title: "兑换好礼", desc: "集满图章,兑换城市限定纪念品" },
|
||||
const RULES: { num: string; title: string; desc: ReactNode }[] = [
|
||||
{
|
||||
num: "01",
|
||||
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() {
|
||||
@@ -100,149 +137,66 @@ export default function LandingPage() {
|
||||
const showRegister = collectState === "needs_register";
|
||||
|
||||
return (
|
||||
<div className="grain-overlay">
|
||||
{/* ═══════════ HERO ═══════════ */}
|
||||
<section className="relative min-h-svh flex flex-col items-center justify-center overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[var(--bg-dark-deep)]" />
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
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="min-h-svh bg-[var(--bg-cream)]">
|
||||
{/* ═══════════ POSTER ═══════════ */}
|
||||
<section className="relative w-full">
|
||||
<img
|
||||
src="/poster.jpg"
|
||||
alt="读城·行走朝天宫"
|
||||
className="block w-full h-auto select-none"
|
||||
draggable={false}
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* ═══════════ ABOUT ═══════════ */}
|
||||
<section className="relative bg-[var(--bg-dark)] py-20 px-6 overflow-hidden">
|
||||
<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="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">
|
||||
{/* ═══════════ RULES ═══════════ */}
|
||||
<section className="relative paper-texture px-6 py-14 pb-32">
|
||||
<div className="relative z-10 max-w-sm mx-auto">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<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>
|
||||
</div>
|
||||
<h2 className="text-[var(--text-primary)] text-2xl leading-snug mb-12"
|
||||
style={{ fontFamily: "'Playfair Display', serif" }}>
|
||||
三步开启旅程
|
||||
</h2>
|
||||
<div className="space-y-0 stagger-children">
|
||||
{STEPS.map((step, i) => (
|
||||
<div key={step.num} className="relative flex gap-5">
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<div className="w-11 h-11 rounded-full border-2 flex items-center justify-center"
|
||||
style={{ borderColor: "var(--gold)", background: "rgba(212, 165, 116, 0.06)" }}>
|
||||
<span className="text-[var(--gold)] text-xs"
|
||||
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 600 }}>
|
||||
{step.num}
|
||||
<span className="text-[var(--text-muted)] text-[10px] tracking-[0.3em] uppercase">
|
||||
Rules
|
||||
</span>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && <div className="w-px flex-1 min-h-[40px] bg-[var(--gold)]/20" />}
|
||||
<h2
|
||||
className="text-[var(--text-primary)] text-2xl leading-snug mb-10"
|
||||
style={{ fontFamily: "'Playfair Display', serif" }}
|
||||
>
|
||||
活动规则
|
||||
</h2>
|
||||
|
||||
<ol className="space-y-0 stagger-children">
|
||||
{RULES.map((rule, i) => (
|
||||
<li key={rule.num} className="relative flex gap-5">
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<div
|
||||
className="w-11 h-11 rounded-full border-2 flex items-center justify-center"
|
||||
style={{
|
||||
borderColor: "var(--gold)",
|
||||
background: "rgba(212, 165, 116, 0.06)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-[var(--gold)] text-xs"
|
||||
style={{ fontFamily: "'Playfair Display', serif", fontWeight: 600 }}
|
||||
>
|
||||
{rule.num}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pb-10 pt-1.5">
|
||||
<h3 className="text-base font-semibold text-[var(--text-primary)] mb-1">{step.title}</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)] leading-relaxed">{step.desc}</p>
|
||||
{i < RULES.length - 1 && (
|
||||
<div className="w-px flex-1 min-h-[40px] bg-[var(--gold)]/20" />
|
||||
)}
|
||||
</div>
|
||||
<div className="pb-8 pt-1.5">
|
||||
<h3 className="text-base font-semibold text-[var(--text-primary)] mb-1.5">
|
||||
{rule.title}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)] leading-[1.9]">
|
||||
{rule.desc}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -254,6 +208,7 @@ export default function LandingPage() {
|
||||
name={stamp.name}
|
||||
imageColor={stamp.imageColor}
|
||||
note={stamp.note}
|
||||
prize={stamp.prize}
|
||||
status="preview"
|
||||
onCollect={handleCollect}
|
||||
onClose={handleClose}
|
||||
@@ -264,6 +219,7 @@ export default function LandingPage() {
|
||||
name={stamp.name}
|
||||
imageColor={stamp.imageColor}
|
||||
note={stamp.note}
|
||||
prize={stamp.prize}
|
||||
status="collected"
|
||||
onClose={handleClose}
|
||||
/>
|
||||
@@ -273,6 +229,7 @@ export default function LandingPage() {
|
||||
name={stamp.name}
|
||||
imageColor={stamp.imageColor}
|
||||
note={stamp.note}
|
||||
prize={stamp.prize}
|
||||
status="already"
|
||||
onClose={handleClose}
|
||||
/>
|
||||
|
||||
22
packages/web/src/pages/VideoPage.tsx
Normal file
22
packages/web/src/pages/VideoPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -29,6 +29,21 @@ model Stamp {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
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 {
|
||||
@@ -43,27 +58,18 @@ model Collection {
|
||||
@@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 {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
ruleId String
|
||||
stampCount Int
|
||||
stampId String
|
||||
prizeId String
|
||||
prizeName String
|
||||
redeemedAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
rule RedemptionRule @relation(fields: [ruleId], references: [id])
|
||||
stamp Stamp @relation(fields: [stampId], references: [id])
|
||||
prize Prize @relation(fields: [prizeId], references: [id])
|
||||
|
||||
@@unique([userId, stampId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user