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

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