From 52169ac71d1e65573f3915e4dd7bab389e5071fb Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Sun, 19 Apr 2026 19:37:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=9C=8B=E6=9D=BF=E4=B8=8E=E7=94=A8=E6=88=B7=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据看板:注册用户 / 图章收集 / 兑换次数 三张卡片,展示总数及本日 / 本周 / 本月新增 - 时间边界按 Asia/Shanghai 计算,周一为一周起点 - 用户管理:只读列表展示用户名、手机号、已收集、已兑换及注册时间,支持搜索 - 登录后默认跳转到数据看板,侧边栏重新编号为 7 项 Co-Authored-By: Claude Opus 4.6 --- packages/server/src/routes/admin.ts | 65 ++++++++ packages/web/src/App.tsx | 4 + packages/web/src/admin/AdminLayout.tsx | 12 +- packages/web/src/admin/AdminLogin.tsx | 2 +- packages/web/src/admin/ArticleList.tsx | 2 +- packages/web/src/admin/Dashboard.tsx | 181 +++++++++++++++++++++++ packages/web/src/admin/MusicList.tsx | 2 +- packages/web/src/admin/RedemptionLog.tsx | 2 +- packages/web/src/admin/RuleList.tsx | 2 +- packages/web/src/admin/StampList.tsx | 2 +- packages/web/src/admin/UsersList.tsx | 151 +++++++++++++++++++ 11 files changed, 414 insertions(+), 11 deletions(-) create mode 100644 packages/web/src/admin/Dashboard.tsx create mode 100644 packages/web/src/admin/UsersList.tsx diff --git a/packages/server/src/routes/admin.ts b/packages/server/src/routes/admin.ts index c528871..1b05765 100644 --- a/packages/server/src/routes/admin.ts +++ b/packages/server/src/routes/admin.ts @@ -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) => { diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 65bd7a0..c569501 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -7,10 +7,12 @@ import MusicPage from "./pages/MusicPage"; 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 ArticleList from "./admin/ArticleList"; import MusicList from "./admin/MusicList"; import RuleList from "./admin/RuleList"; +import UsersList from "./admin/UsersList"; import RedemptionLog from "./admin/RedemptionLog"; function CollectRedirect() { @@ -33,10 +35,12 @@ export default function App() { } /> }> }> + } /> } /> } /> } /> } /> + } /> } /> diff --git a/packages/web/src/admin/AdminLayout.tsx b/packages/web/src/admin/AdminLayout.tsx index 91a66ab..f373f18 100644 --- a/packages/web/src/admin/AdminLayout.tsx +++ b/packages/web/src/admin/AdminLayout.tsx @@ -2,11 +2,13 @@ import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { ToastProvider } from "./Toast"; const navItems = [ - { path: "/admin/stamps", label: "图章管理", eyebrow: "01", tag: "Stamps" }, - { path: "/admin/articles", label: "文章管理", eyebrow: "02", tag: "Articles" }, - { path: "/admin/music", label: "音乐管理", eyebrow: "03", tag: "Music" }, - { path: "/admin/rules", label: "兑换规则", eyebrow: "04", tag: "Rules" }, - { path: "/admin/redemptions", label: "兑换记录", eyebrow: "05", tag: "Log" }, + { 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/rules", label: "兑换规则", eyebrow: "05", tag: "Rules" }, + { path: "/admin/users", label: "用户管理", eyebrow: "06", tag: "Users" }, + { path: "/admin/redemptions", label: "兑换记录", eyebrow: "07", tag: "Log" }, ]; export default function AdminLayout() { diff --git a/packages/web/src/admin/AdminLogin.tsx b/packages/web/src/admin/AdminLogin.tsx index 73a79ff..4153315 100644 --- a/packages/web/src/admin/AdminLogin.tsx +++ b/packages/web/src/admin/AdminLogin.tsx @@ -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("密钥不正确"); } diff --git a/packages/web/src/admin/ArticleList.tsx b/packages/web/src/admin/ArticleList.tsx index 055ce24..36c8fff 100644 --- a/packages/web/src/admin/ArticleList.tsx +++ b/packages/web/src/admin/ArticleList.tsx @@ -78,7 +78,7 @@ export default function ArticleList() { return ( <> setFormState({ open: true, id: null })}>添加文章} diff --git a/packages/web/src/admin/Dashboard.tsx b/packages/web/src/admin/Dashboard.tsx new file mode 100644 index 0000000..f39106d --- /dev/null +++ b/packages/web/src/admin/Dashboard.tsx @@ -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: ( + + + + + + ), + }, + { + key: "collections", + eyebrow: "Collected", + label: "图章收集", + caption: "触发一次 NFC 即记一次", + accent: "var(--gold)", + icon: ( + + + + + ), + }, + { + key: "redemptions", + eyebrow: "Redeemed", + label: "兑换次数", + caption: "访客将图章兑换为奖品", + accent: "var(--terracotta)", + icon: ( + + + + ), + }, +]; + +export default function Dashboard() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + adminFetch("/dashboard") + .then(setData) + .finally(() => setLoading(false)); + }, []); + + return ( + <> + + + {loading || !data ? ( + + ) : ( +
+ {CARDS.map((card, i) => ( + + ))} +
+ )} + + ); +} + +function MetricCard({ card, metric, index }: { card: CardSpec; metric: MetricBlock; index: number }) { + return ( +
+ + + {/* Header: eyebrow + icon */} +
+
+ + + {card.eyebrow} + +
+ {card.icon} +
+ + {/* Total */} +
+

+ {metric.total} +

+
+ {card.label} + Total +
+

{card.caption}

+
+ + {/* Time-slice metrics */} +
+ + + +
+
+ ); +} + +function SliceCell({ + label, + labelCn, + value, + accent, +}: { + label: string; + labelCn: string; + value: number; + accent: string; +}) { + const isZero = value === 0; + return ( +
+
+ {label} +
+
+ {isZero ? "0" : `+${value}`} +
+
{labelCn}
+
+ ); +} diff --git a/packages/web/src/admin/MusicList.tsx b/packages/web/src/admin/MusicList.tsx index 63ed9ce..16956c2 100644 --- a/packages/web/src/admin/MusicList.tsx +++ b/packages/web/src/admin/MusicList.tsx @@ -78,7 +78,7 @@ export default function MusicList() { return ( <> setFormState({ open: true, id: null })}>添加音乐} diff --git a/packages/web/src/admin/RedemptionLog.tsx b/packages/web/src/admin/RedemptionLog.tsx index e964b94..7319546 100644 --- a/packages/web/src/admin/RedemptionLog.tsx +++ b/packages/web/src/admin/RedemptionLog.tsx @@ -44,7 +44,7 @@ export default function RedemptionLog() { return ( <> diff --git a/packages/web/src/admin/RuleList.tsx b/packages/web/src/admin/RuleList.tsx index 7a953f5..1511092 100644 --- a/packages/web/src/admin/RuleList.tsx +++ b/packages/web/src/admin/RuleList.tsx @@ -63,7 +63,7 @@ export default function RuleList() { return ( <> setFormState({ open: true, id: null })}>添加规则} diff --git a/packages/web/src/admin/StampList.tsx b/packages/web/src/admin/StampList.tsx index 2f2d978..dc89b0e 100644 --- a/packages/web/src/admin/StampList.tsx +++ b/packages/web/src/admin/StampList.tsx @@ -78,7 +78,7 @@ export default function StampList() { return ( <> ([]); + const [loading, setLoading] = useState(true); + const [query, setQuery] = useState(""); + + useEffect(() => { + adminFetch("/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 ( + <> + + + + + + 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" + /> + + } + /> + + {loading ? ( + + ) : ( + + {filtered.length === 0 ? ( +
+
+ + + + +
+

+ {query ? `没有匹配「${query}」的用户` : "尚无注册用户"} +

+
+ ) : ( + + + + + + {filtered.map((u, i) => ( + + + + + + + + ))} + +
+
+
+ + {u.username.slice(0, 1).toUpperCase()} + +
+ + {u.username} + +
+
+ {u.phone} + + + + + + + {new Date(u.createdAt).toLocaleString("zh-CN")} + +
+ )} +
+ )} + + ); +} + +function CountBadge({ value, accent, suffix }: { value: number; accent: string; suffix: string }) { + const isZero = value === 0; + return ( + + + {value} + + {suffix} + + ); +}