astock-agent/frontend/src/app/(auth)/admin/page.tsx
2026-06-02 13:31:24 +08:00

147 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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