435 lines
14 KiB
TypeScript
435 lines
14 KiB
TypeScript
"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(" ");
|
||
}
|