diff --git a/frontend/src/app/(auth)/admin/invites/page.tsx b/frontend/src/app/(auth)/admin/invites/page.tsx new file mode 100644 index 00000000..733f7cf4 --- /dev/null +++ b/frontend/src/app/(auth)/admin/invites/page.tsx @@ -0,0 +1,219 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createInviteCodeAPI, listInviteCodesAPI, toggleInviteCodeAPI, type InviteCodeItem } from "@/lib/api"; +import { useAuth } from "@/hooks/use-auth"; + +export default function AdminInvitesPage() { + const { user } = useAuth(); + const [invites, setInvites] = useState([]); + const [loading, setLoading] = useState(true); + const [message, setMessage] = useState(""); + const [inviteCode, setInviteCode] = useState(""); + const [inviteDesc, setInviteDesc] = useState(""); + const [maxUses, setMaxUses] = useState(10); + + const loadData = useCallback(async () => { + if (user?.role !== "admin") return; + setLoading(true); + setMessage(""); + try { + setInvites(await listInviteCodesAPI()); + } catch (error) { + setMessage(error instanceof Error ? error.message : "邀请码数据加载失败"); + } finally { + setLoading(false); + } + }, [user?.role]); + + useEffect(() => { + loadData(); + }, [loadData]); + + const stats = useMemo(() => { + const active = invites.filter((item) => item.is_active).length; + const used = invites.reduce((sum, item) => sum + (item.used_count || 0), 0); + const capacity = invites.reduce((sum, item) => sum + (item.max_uses || 0), 0); + return { active, disabled: invites.length - active, used, remaining: Math.max(0, capacity - used) }; + }, [invites]); + + const handleCreateInvite = async (event: React.FormEvent) => { + event.preventDefault(); + const code = inviteCode.trim().toUpperCase(); + if (!code) { + setMessage("请填写邀请码"); + return; + } + setMessage(""); + await createInviteCodeAPI(code, inviteDesc.trim(), maxUses); + setInviteCode(""); + setInviteDesc(""); + setMaxUses(10); + setMessage("邀请码已创建"); + await loadData(); + }; + + const handleToggleInvite = async (item: InviteCodeItem) => { + setMessage(""); + await toggleInviteCodeAPI(item.id); + setMessage(item.is_active ? "邀请码已停用" : "邀请码已启用"); + await loadData(); + }; + + if (user?.role !== "admin") { + return ; + } + + return ( +
+ + ← 返回系统管理 + + +
+
+
Invitations
+

邀请码管理

+

控制注册入口、发放额度和邀请码启停。

+
+ +
+ + {message ?
{message}
: null} + +
+ + + + +
+ +
+
+

新建邀请码

+
+ + +
+ + +
+
+
+ +
+
+

邀请码列表

+ {invites.length} +
+ {loading ? ( +
+ {[1, 2, 3, 4].map((item) =>
)} +
+ ) : ( +
+ {invites.map((item) => ( + handleToggleInvite(item)} /> + ))} + {!invites.length ?
暂无邀请码
: null} +
+ )} +
+
+
+ ); +} + +function NoAccess() { + return ( +
+
当前账号没有邀请码管理权限。
+
+ ); +} + +function StatCard({ label, value }: { label: string; value: string | number }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function InviteRow({ item, onToggle }: { item: InviteCodeItem; onToggle: () => void }) { + const remaining = Math.max(0, (item.max_uses || 0) - (item.used_count || 0)); + return ( +
+
+
+ {item.code} + +
+
{item.description || "无说明"}
+
+
+ + + +
+ +
+ ); +} + +function StatusBadge({ active }: { active: boolean }) { + return ( + + {active ? "启用" : "停用"} + + ); +} + +function MiniMetric({ label, value }: { label: string; value: number }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/frontend/src/app/(auth)/admin/page.tsx b/frontend/src/app/(auth)/admin/page.tsx new file mode 100644 index 00000000..c0eac888 --- /dev/null +++ b/frontend/src/app/(auth)/admin/page.tsx @@ -0,0 +1,146 @@ +"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([]); + const [invites, setInvites] = useState([]); + 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 ; + } + + return ( +
+
+
+
Admin Console
+

系统管理

+

账号访问和注册入口分开管理,减少误操作。

+
+ +
+ + {error ?
{error}
: null} + +
+ + + + +
+ +
+ + +
+
+ ); +} + +function NoAccess() { + return ( +
+
当前账号没有系统管理权限。
+
+ ); +} + +function StatCard({ label, value }: { label: string; value: string | number }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function AdminEntryCard({ + href, + title, + description, + primary, + secondary, + loading, +}: { + href: string; + title: string; + description: string; + primary: string; + secondary: string; + loading: boolean; +}) { + return ( + +
+
+

{title}

+

{description}

+
+ 进入 +
+
+
+
当前状态
+
{loading ? "加载中" : primary}
+
+
+
补充信息
+
{loading ? "加载中" : secondary}
+
+
+ + ); +} diff --git a/frontend/src/app/(auth)/admin/users/page.tsx b/frontend/src/app/(auth)/admin/users/page.tsx new file mode 100644 index 00000000..da02d8af --- /dev/null +++ b/frontend/src/app/(auth)/admin/users/page.tsx @@ -0,0 +1,190 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { disableUserAPI, listUsersAPI, resetPasswordAPI, type ResetPasswordResult, type UserItem } from "@/lib/api"; +import { useAuth } from "@/hooks/use-auth"; + +export default function AdminUsersPage() { + const { user } = useAuth(); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [message, setMessage] = useState(""); + const [resetResult, setResetResult] = useState(null); + + const loadData = useCallback(async () => { + if (user?.role !== "admin") return; + setLoading(true); + setMessage(""); + try { + setUsers(await listUsersAPI()); + } catch (error) { + setMessage(error instanceof Error ? error.message : "用户数据加载失败"); + } finally { + setLoading(false); + } + }, [user?.role]); + + useEffect(() => { + loadData(); + }, [loadData]); + + const stats = useMemo(() => { + const active = users.filter((item) => item.is_active).length; + const admin = users.filter((item) => item.role === "admin").length; + return { active, disabled: users.length - active, admin }; + }, [users]); + + const handleDisableUser = async (target: UserItem) => { + if (!window.confirm(`确认禁用 ${target.email || target.username}?`)) return; + setMessage(""); + await disableUserAPI(target.id); + setMessage("用户已禁用"); + await loadData(); + }; + + const handleResetPassword = async (target: UserItem) => { + if (!window.confirm(`确认重置 ${target.email || target.username} 的密码?`)) return; + setMessage(""); + const result = await resetPasswordAPI(target.id); + setResetResult(result); + await loadData(); + }; + + if (user?.role !== "admin") { + return ; + } + + return ( +
+ + ← 返回系统管理 + + +
+
+
Accounts
+

用户管理

+

处理账号启停和密码重置。

+
+ +
+ + {message ?
{message}
: null} + + {resetResult ? ( +
+
新密码仅显示一次
+
+
{resetResult.email}
+
{resetResult.password}
+
+
+ ) : null} + +
+ + + + +
+ +
+
+

账号列表

+ {users.length} +
+ {loading ? ( +
+ {[1, 2, 3, 4].map((item) =>
)} +
+ ) : ( +
+ {users.map((item) => ( + handleDisableUser(item)} + onReset={() => handleResetPassword(item)} + /> + ))} +
+ )} +
+
+ ); +} + +function NoAccess() { + return ( +
+
当前账号没有用户管理权限。
+
+ ); +} + +function StatCard({ label, value }: { label: string; value: string | number }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function UserRow({ + item, + isSelf, + onDisable, + onReset, +}: { + item: UserItem; + isSelf: boolean; + onDisable: () => void; + onReset: () => void; +}) { + return ( +
+
+
+ {item.email || item.username} + + {item.role} +
+
+ 邀请码 {item.invite_code_used || "-"} · {item.created_at ? item.created_at.slice(0, 10) : "-"} +
+
+
ID {item.id}
+
+ + +
+
+ ); +} + +function StatusBadge({ active }: { active: boolean }) { + return ( + + {active ? "启用" : "停用"} + + ); +} diff --git a/frontend/src/components/nav.tsx b/frontend/src/components/nav.tsx index e89e37e9..cd45fd3a 100644 --- a/frontend/src/components/nav.tsx +++ b/frontend/src/components/nav.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useAuth } from "@/hooks/use-auth"; function DashboardIcon() { return ( @@ -60,6 +61,15 @@ function WatchlistIcon() { ); } +function AdminIcon() { + return ( + + + + + ); +} + function SideNavItem({ href, icon, label, onNavigate }: { href: string; icon: React.ReactNode; label: string; onNavigate?: () => void }) { const pathname = usePathname(); @@ -82,6 +92,8 @@ function SideNavItem({ href, icon, label, onNavigate }: { href: string; icon: Re } export function SidebarNav({ onNavigate }: { onNavigate?: () => void }) { + const { user } = useAuth(); + return ( ); }