195 lines
8.6 KiB
TypeScript
195 lines
8.6 KiB
TypeScript
'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>
|
||
);
|
||
}
|