feat: 新增数据看板与用户管理模块

- 数据看板:注册用户 / 图章收集 / 兑换次数 三张卡片,展示总数及本日 / 本周 / 本月新增
- 时间边界按 Asia/Shanghai 计算,周一为一周起点
- 用户管理:只读列表展示用户名、手机号、已收集、已兑换及注册时间,支持搜索
- 登录后默认跳转到数据看板,侧边栏重新编号为 7 项

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 19:37:05 +08:00
parent b4a0e23c7e
commit 52169ac71d
11 changed files with 414 additions and 11 deletions

View File

@@ -195,6 +195,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) => {