147 lines
5.6 KiB
TypeScript
147 lines
5.6 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { listInviteCodesAPI, listUsersAPI, type InviteCodeItem, type UserItem } from "@/lib/api";
|
||
import { useAuth } from "@/hooks/use-auth";
|
||
|
||
export default function AdminPage() {
|
||
const { user } = useAuth();
|
||
const [users, setUsers] = useState<UserItem[]>([]);
|
||
const [invites, setInvites] = useState<InviteCodeItem[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState("");
|
||
|
||
const loadData = useCallback(async () => {
|
||
if (user?.role !== "admin") return;
|
||
setLoading(true);
|
||
setError("");
|
||
try {
|
||
const [userRows, inviteRows] = await Promise.all([listUsersAPI(), listInviteCodesAPI()]);
|
||
setUsers(userRows);
|
||
setInvites(inviteRows);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "管理数据加载失败");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [user?.role]);
|
||
|
||
useEffect(() => {
|
||
loadData();
|
||
}, [loadData]);
|
||
|
||
const stats = useMemo(() => {
|
||
const activeUsers = users.filter((item) => item.is_active).length;
|
||
const disabledUsers = users.length - activeUsers;
|
||
const activeInvites = invites.filter((item) => item.is_active).length;
|
||
const remainingInvites = invites.reduce((sum, item) => sum + Math.max(0, (item.max_uses || 0) - (item.used_count || 0)), 0);
|
||
return { activeUsers, disabledUsers, activeInvites, remainingInvites };
|
||
}, [invites, users]);
|
||
|
||
if (user?.role !== "admin") {
|
||
return <NoAccess />;
|
||
}
|
||
|
||
return (
|
||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
||
<header className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||
<div>
|
||
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-400">Admin Console</div>
|
||
<h1 className="mt-2 text-2xl font-bold tracking-tight text-text-primary">系统管理</h1>
|
||
<p className="mt-1 text-sm text-text-muted">账号访问和注册入口分开管理,减少误操作。</p>
|
||
</div>
|
||
<button
|
||
onClick={loadData}
|
||
className="rounded-xl border border-border-subtle bg-surface-2 px-4 py-2 text-xs text-text-secondary hover:text-text-primary"
|
||
>
|
||
刷新概览
|
||
</button>
|
||
</header>
|
||
|
||
{error ? <div className="glass-card-static border-red-500/15 px-4 py-2.5 text-xs text-red-300">{error}</div> : null}
|
||
|
||
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||
<StatCard label="启用用户" value={loading ? "-" : stats.activeUsers} />
|
||
<StatCard label="停用用户" value={loading ? "-" : stats.disabledUsers} />
|
||
<StatCard label="启用邀请码" value={loading ? "-" : stats.activeInvites} />
|
||
<StatCard label="剩余注册名额" value={loading ? "-" : stats.remainingInvites} />
|
||
</section>
|
||
|
||
<section className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||
<AdminEntryCard
|
||
href="/admin/users"
|
||
title="用户管理"
|
||
description="查看账号、禁用账号、重置密码。适合处理访问权限和账号生命周期。"
|
||
primary={`${stats.activeUsers} 个启用账号`}
|
||
secondary={`${stats.disabledUsers} 个停用账号`}
|
||
loading={loading}
|
||
/>
|
||
<AdminEntryCard
|
||
href="/admin/invites"
|
||
title="邀请码管理"
|
||
description="创建邀请码、控制启停、查看使用量。适合管理新用户注册入口。"
|
||
primary={`${stats.activeInvites} 个启用邀请码`}
|
||
secondary={`${stats.remainingInvites} 个剩余名额`}
|
||
loading={loading}
|
||
/>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NoAccess() {
|
||
return (
|
||
<div className="max-w-5xl mx-auto px-4 md:px-8 pt-8 pb-20">
|
||
<div className="glass-card-static p-6 text-sm text-text-secondary">当前账号没有系统管理权限。</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StatCard({ label, value }: { label: string; value: string | number }) {
|
||
return (
|
||
<div className="glass-card-static p-4">
|
||
<div className="text-[10px] text-text-muted">{label}</div>
|
||
<div className="mt-2 font-mono text-2xl font-bold tabular-nums text-text-primary">{value}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AdminEntryCard({
|
||
href,
|
||
title,
|
||
description,
|
||
primary,
|
||
secondary,
|
||
loading,
|
||
}: {
|
||
href: string;
|
||
title: string;
|
||
description: string;
|
||
primary: string;
|
||
secondary: string;
|
||
loading: boolean;
|
||
}) {
|
||
return (
|
||
<Link href={href} className="glass-card-static block p-5 transition-colors hover:border-amber-500/20">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<h2 className="text-lg font-bold tracking-tight text-text-primary">{title}</h2>
|
||
<p className="mt-2 max-w-xl text-sm leading-6 text-text-muted">{description}</p>
|
||
</div>
|
||
<span className="rounded-xl border border-border-subtle bg-surface-2 px-3 py-1.5 text-xs text-text-secondary">进入</span>
|
||
</div>
|
||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||
<div className="rounded-xl bg-surface-2 px-3 py-3">
|
||
<div className="text-[10px] text-text-muted">当前状态</div>
|
||
<div className="mt-1 text-sm font-semibold text-text-primary">{loading ? "加载中" : primary}</div>
|
||
</div>
|
||
<div className="rounded-xl bg-surface-2 px-3 py-3">
|
||
<div className="text-[10px] text-text-muted">补充信息</div>
|
||
<div className="mt-1 text-sm font-semibold text-text-primary">{loading ? "加载中" : secondary}</div>
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
);
|
||
}
|