Files
ViGent2/frontend/src/app/admin/page.tsx
Kevin Wong 1a291a03b8 更新
2026-02-08 10:46:08 +08:00

195 lines
8.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 { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { getCurrentUser, User } from "@/shared/lib/auth";
import api from "@/shared/api/axios";
import { ApiResponse, unwrap } from "@/shared/api/types";
import { toast } from "sonner";
interface UserListItem {
id: string;
phone: string;
username: string | null;
role: string;
is_active: boolean;
expires_at: string | null;
created_at: string;
}
export default function AdminPage() {
const router = useRouter();
const [, setCurrentUser] = useState<User | null>(null);
const [users, setUsers] = useState<UserListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [activatingId, setActivatingId] = useState<string | null>(null);
const [expireDays, setExpireDays] = useState<number>(30);
useEffect(() => {
checkAdmin();
fetchUsers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const checkAdmin = async () => {
const user = await getCurrentUser();
if (!user || user.role !== 'admin') {
router.push('/login');
return;
}
setCurrentUser(user);
};
const fetchUsers = async () => {
try {
const { data: res } = await api.get<ApiResponse<UserListItem[]>>('/api/admin/users');
setUsers(unwrap(res));
} catch {
setError('获取用户列表失败');
} finally {
setLoading(false);
}
};
const activateUser = async (userId: string) => {
setActivatingId(userId);
try {
await api.post(`/api/admin/users/${userId}/activate`, {
expires_days: expireDays || null
});
fetchUsers();
} catch {
// axios interceptor handles 401/403
} finally {
setActivatingId(null);
}
};
const deactivateUser = async (userId: string) => {
if (!confirm('确定要停用该用户吗?')) return;
try {
await api.post(`/api/admin/users/${userId}/deactivate`);
fetchUsers();
} catch {
toast.error('操作失败');
}
};
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '永久';
return new Date(dateStr).toLocaleDateString('zh-CN');
};
const getRoleBadge = (role: string, isActive: boolean) => {
if (role === 'admin') {
return <span className="px-2 py-1 text-xs rounded-full bg-purple-500/20 text-purple-300"></span>;
}
if (role === 'pending') {
return <span className="px-2 py-1 text-xs rounded-full bg-yellow-500/20 text-yellow-300"></span>;
}
if (!isActive) {
return <span className="px-2 py-1 text-xs rounded-full bg-red-500/20 text-red-300"></span>;
}
return <span className="px-2 py-1 text-xs rounded-full bg-green-500/20 text-green-300"></span>;
};
if (loading) {
return (
<div className="min-h-dvh flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-purple-500"></div>
</div>
);
}
return (
<div className="min-h-dvh p-8">
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-white"></h1>
<Link href="/" className="text-purple-300 hover:text-purple-200">
</Link>
</div>
{error && (
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-200">
{error}
</div>
)}
<div className="mb-4 flex items-center gap-4">
<label className="text-gray-300"></label>
<input
type="number"
value={expireDays}
onChange={(e) => setExpireDays(parseInt(e.target.value) || 0)}
className="w-24 px-3 py-2 bg-white/5 border border-white/10 rounded text-white"
placeholder="0=永久"
/>
<span className="text-gray-400 text-sm">(0 )</span>
</div>
<div className="bg-white/5 backdrop-blur-lg rounded-xl border border-white/10 overflow-hidden">
<table className="w-full">
<thead className="bg-white/5">
<tr>
<th className="px-6 py-4 text-left text-sm font-medium text-gray-300"></th>
<th className="px-6 py-4 text-left text-sm font-medium text-gray-300"></th>
<th className="px-6 py-4 text-left text-sm font-medium text-gray-300"></th>
<th className="px-6 py-4 text-left text-sm font-medium text-gray-300"></th>
<th className="px-6 py-4 text-left text-sm font-medium text-gray-300"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{users.map((user) => (
<tr key={user.id} className="hover:bg-white/5">
<td className="px-6 py-4">
<div>
<div className="text-white font-medium">{user.username || `用户${user.phone.slice(-4)}`}</div>
<div className="text-gray-400 text-sm">{user.phone}</div>
</div>
</td>
<td className="px-6 py-4">
{getRoleBadge(user.role, user.is_active)}
</td>
<td className="px-6 py-4 text-gray-300">
{formatDate(user.expires_at)}
</td>
<td className="px-6 py-4 text-gray-400 text-sm">
{formatDate(user.created_at)}
</td>
<td className="px-6 py-4">
{user.role !== 'admin' && (
<div className="flex gap-2">
{!user.is_active || user.role === 'pending' ? (
<button
onClick={() => activateUser(user.id)}
disabled={activatingId === user.id}
className="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded disabled:opacity-50"
>
{activatingId === user.id ? '...' : '激活'}
</button>
) : (
<button
onClick={() => deactivateUser(user.id)}
className="px-3 py-1 bg-red-600 hover:bg-red-700 text-white text-sm rounded"
>
</button>
)}
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}