feat: 新增数据看板与用户管理模块
- 数据看板:注册用户 / 图章收集 / 兑换次数 三张卡片,展示总数及本日 / 本周 / 本月新增 - 时间边界按 Asia/Shanghai 计算,周一为一周起点 - 用户管理:只读列表展示用户名、手机号、已收集、已兑换及注册时间,支持搜索 - 登录后默认跳转到数据看板,侧边栏重新编号为 7 项 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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() {
|
||||
<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/articles" element={<ArticleList />} />
|
||||
<Route path="/admin/music" element={<MusicList />} />
|
||||
<Route path="/admin/rules" element={<RuleList />} />
|
||||
<Route path="/admin/users" element={<UsersList />} />
|
||||
<Route path="/admin/redemptions" element={<RedemptionLog />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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("密钥不正确");
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function ArticleList() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="02 · Articles"
|
||||
eyebrow="03 · Articles"
|
||||
title="文章管理"
|
||||
caption="静态文章与对应点位的 NFC 链接"
|
||||
action={<PrimaryButton onClick={() => setFormState({ open: true, id: null })}>添加文章</PrimaryButton>}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export default function MusicList() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="03 · Music"
|
||||
eyebrow="04 · Music"
|
||||
title="音乐管理"
|
||||
caption="音频作品与对应点位的 NFC 链接"
|
||||
action={<PrimaryButton onClick={() => setFormState({ open: true, id: null })}>添加音乐</PrimaryButton>}
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function RedemptionLog() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="05 · Log"
|
||||
eyebrow="07 · Log"
|
||||
title="兑换记录"
|
||||
caption="账户、图章收集与兑换的完整轨迹"
|
||||
/>
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function RuleList() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="04 · Rules"
|
||||
eyebrow="05 · Rules"
|
||||
title="兑换规则"
|
||||
caption="设置可兑换的奖品与所需图章数"
|
||||
action={<PrimaryButton onClick={() => setFormState({ open: true, id: null })}>添加规则</PrimaryButton>}
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function StampList() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="01 · Stamps"
|
||||
eyebrow="02 · Stamps"
|
||||
title="图章管理"
|
||||
caption="收集图章、生成点位 NFC 链接与备用二维码"
|
||||
action={
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user