feat: 新增数据看板与用户管理模块
- 数据看板:注册用户 / 图章收集 / 兑换次数 三张卡片,展示总数及本日 / 本周 / 本月新增 - 时间边界按 Asia/Shanghai 计算,周一为一周起点 - 用户管理:只读列表展示用户名、手机号、已收集、已兑换及注册时间,支持搜索 - 登录后默认跳转到数据看板,侧边栏重新编号为 7 项 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
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