geo/frontend/app/(dashboard)/dashboard/admin/page.tsx

435 lines
14 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 { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/lib/api";
import {
Users,
Search,
Quote,
Percent,
Loader2,
AlertTriangle,
CheckCircle,
Ban,
UserCheck,
ChevronLeft,
ChevronRight,
} from "lucide-react";
interface StatsData {
total_users: number;
total_queries: number;
total_citations: number;
citation_rate: number;
today_active_users: number;
}
interface AdminUser {
id: string;
email: string;
name: string | null;
plan: string;
is_active: boolean;
is_admin: boolean;
email_verified: boolean;
query_count: number;
created_at: string;
}
const PLAN_OPTIONS = [
{ value: "free", label: "免费版" },
{ value: "starter", label: "入门版" },
{ value: "pro", label: "专业版" },
{ value: "business", label: "企业版" },
];
const LIMIT = 10;
export default function AdminPage() {
const { data: session } = useSession();
const [stats, setStats] = useState<StatsData | null>(null);
const [users, setUsers] = useState<AdminUser[]>([]);
const [totalUsers, setTotalUsers] = useState(0);
const [skip, setSkip] = useState(0);
const [search, setSearch] = useState("");
const [loadingStats, setLoadingStats] = useState(false);
const [loadingUsers, setLoadingUsers] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogType, setDialogType] = useState<"toggle" | "plan">("toggle");
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
const [selectedPlan, setSelectedPlan] = useState("");
const [actionLoading, setActionLoading] = useState(false);
const token = session?.accessToken;
useEffect(() => {
if (!token) return;
loadStats();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
useEffect(() => {
if (!token) return;
loadUsers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, skip, search]);
async function loadStats() {
if (!token) return;
setLoadingStats(true);
try {
const data = await api.admin.getStats(token);
setStats(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "加载统计失败");
} finally {
setLoadingStats(false);
}
}
async function loadUsers() {
if (!token) return;
setLoadingUsers(true);
try {
const data = await api.admin.getUsers(token, { skip, limit: LIMIT, search: search || undefined });
setUsers(data.items || []);
setTotalUsers(data.total || 0);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "加载用户列表失败");
} finally {
setLoadingUsers(false);
}
}
function openToggleDialog(user: AdminUser) {
setSelectedUser(user);
setDialogType("toggle");
setDialogOpen(true);
}
function openPlanDialog(user: AdminUser) {
setSelectedUser(user);
setSelectedPlan(user.plan);
setDialogType("plan");
setDialogOpen(true);
}
async function handleConfirm() {
if (!token || !selectedUser) return;
setActionLoading(true);
setSuccess(null);
try {
if (dialogType === "toggle") {
const res = await api.admin.toggleUserActive(token, selectedUser.id);
setSuccess(res.message || "操作成功");
} else {
const res = await api.admin.updateUserPlan(token, selectedUser.id, selectedPlan);
setSuccess(res.message || "套餐更新成功");
}
await loadUsers();
setDialogOpen(false);
setTimeout(() => setSuccess(null), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : "操作失败");
setDialogOpen(false);
} finally {
setActionLoading(false);
}
}
const totalPages = Math.ceil(totalUsers / LIMIT);
const currentPage = Math.floor(skip / LIMIT) + 1;
const statCards = [
{
title: "总用户数",
value: stats?.total_users ?? 0,
icon: Users,
color: "text-blue-600",
bg: "bg-blue-50",
},
{
title: "总查询数",
value: stats?.total_queries ?? 0,
icon: Search,
color: "text-emerald-600",
bg: "bg-emerald-50",
},
{
title: "总引用次数",
value: stats?.total_citations ?? 0,
icon: Quote,
color: "text-violet-600",
bg: "bg-violet-50",
},
{
title: "引用率",
value: stats ? `${stats.citation_rate}%` : "0%",
icon: Percent,
color: "text-amber-600",
bg: "bg-amber-50",
},
];
function formatDate(dateStr: string) {
if (!dateStr) return "-";
const d = new Date(dateStr);
return d.toLocaleDateString("zh-CN");
}
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
</div>
{error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
{success && (
<div className="flex items-center gap-2 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
<CheckCircle className="h-4 w-4 shrink-0" />
<span>{success}</span>
</div>
)}
{/* Stats Cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{statCards.map((card) => (
<Card key={card.title}>
<CardContent className="flex items-center gap-4 p-6">
<div className={cn("flex h-12 w-12 items-center justify-center rounded-lg", card.bg)}>
<card.icon className={cn("h-6 w-6", card.color)} />
</div>
<div>
<p className="text-sm text-muted-foreground">{card.title}</p>
<p className="text-2xl font-bold">
{loadingStats ? <Loader2 className="h-5 w-5 animate-spin" /> : card.value}
</p>
</div>
</CardContent>
</Card>
))}
</div>
{/* User Management */}
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-2">
<Input
placeholder="搜索邮箱或用户名"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setSkip(0);
}}
className="max-w-sm"
/>
<Button variant="outline" onClick={() => { setSearch(""); setSkip(0); }}>
</Button>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loadingUsers ? (
<TableRow>
<TableCell colSpan={8} className="py-8 text-center text-muted-foreground">
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
</TableCell>
</TableRow>
) : users.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="py-8 text-center text-muted-foreground">
</TableCell>
</TableRow>
) : (
users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.email}</TableCell>
<TableCell>{user.name || "-"}</TableCell>
<TableCell>
<Badge variant="secondary">{user.plan}</Badge>
</TableCell>
<TableCell>{user.query_count}</TableCell>
<TableCell>
{user.email_verified ? (
<span className="inline-flex items-center gap-1 text-xs text-emerald-600">
<CheckCircle className="h-3 w-3" />
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Ban className="h-3 w-3" />
</span>
)}
</TableCell>
<TableCell>
{user.is_active ? (
<Badge variant="default" className="bg-emerald-500 hover:bg-emerald-600">
</Badge>
) : (
<Badge variant="destructive"></Badge>
)}
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(user.created_at)}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => openToggleDialog(user)}
>
{user.is_active ? <Ban className="h-3 w-3 mr-1" /> : <UserCheck className="h-3 w-3 mr-1" />}
{user.is_active ? "禁用" : "启用"}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => openPlanDialog(user)}
>
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setSkip((p) => Math.max(0, p - LIMIT))}
disabled={skip === 0}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm text-muted-foreground">
{currentPage} / {totalPages} ( {totalUsers} )
</span>
<Button
variant="outline"
size="sm"
onClick={() => setSkip((p) => p + LIMIT)}
disabled={skip + LIMIT >= totalUsers}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</CardContent>
</Card>
{/* Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{dialogType === "toggle"
? selectedUser?.is_active
? "禁用用户"
: "启用用户"
: "修改套餐"}
</DialogTitle>
<DialogDescription>
{dialogType === "toggle"
? `确认${selectedUser?.is_active ? "禁用" : "启用"}用户 ${selectedUser?.email}`
: `请选择用户 ${selectedUser?.email} 的新套餐`}
</DialogDescription>
</DialogHeader>
{dialogType === "plan" && (
<div className="py-2">
<Select value={selectedPlan} onValueChange={setSelectedPlan}>
<SelectTrigger>
<SelectValue placeholder="选择套餐" />
</SelectTrigger>
<SelectContent>
{PLAN_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={actionLoading}>
</Button>
<Button onClick={handleConfirm} disabled={actionLoading}>
{actionLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function cn(...classes: (string | undefined | false)[]) {
return classes.filter(Boolean).join(" ");
}