Files
novalon-website/src/app/admin/users/page.tsx
T

434 lines
16 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 { logger } from '@/lib/logger';
const log = logger.child('AdminUsers');
import {
Users as UsersIcon,
Plus,
Edit,
Trash2,
Loader2,
Search
} from 'lucide-react';
interface User {
id: string;
email: string;
name: string;
role: 'admin' | 'editor' | 'viewer';
createdAt: string;
}
const roleLabels = {
admin: '管理员',
editor: '编辑',
viewer: '查看者'
};
const roleColors = {
admin: 'bg-red-100 text-red-800',
editor: 'bg-blue-100 text-blue-800',
viewer: 'bg-gray-100 text-gray-800'
};
export default function UsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [saving, setSaving] = useState(false);
const [deletingUserId, setDeletingUserId] = useState<string | null>(null);
const [formData, setFormData] = useState({
email: '',
name: '',
password: '',
role: 'viewer' as 'admin' | 'editor' | 'viewer'
});
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
try {
setLoading(true);
const res = await fetch('/api/admin/users');
const data = await res.json();
if (res.ok) {
setUsers(data.users || []);
}
} catch (error) {
console.error('获取用户列表失败:', error);
} finally {
setLoading(false);
}
};
const handleCreate = async () => {
if (!formData.email || !formData.name || !formData.password || !formData.role) {
return;
}
try {
setSaving(true);
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
if (res.ok) {
setShowCreateModal(false);
setFormData({ email: '', name: '', password: '', role: 'viewer' });
await fetchUsers();
} else {
const data = await res.json();
alert(data.error || '创建失败');
}
} catch (error) {
console.error('创建用户失败:', error);
} finally {
setSaving(false);
}
};
const handleDelete = async (userId: string) => {
if (deletingUserId) {
log.warn('删除操作正在进行中,请勿重复点击');
return;
}
if (!confirm('确定要删除此用户吗?此操作不可恢复。')) {
return;
}
try {
setDeletingUserId(userId);
const res = await fetch(`/api/admin/users/${userId}`, {
method: 'DELETE'
});
if (res.ok) {
await fetchUsers();
} else {
const data = await res.json();
alert(data.error || '删除失败');
}
} catch (error) {
console.error('删除用户失败:', error);
alert('删除失败,请稍后重试');
} finally {
setDeletingUserId(null);
}
};
const filteredUsers = users.filter(user =>
user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.name.toLowerCase().includes(searchTerm.toLowerCase())
);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-gray-600 mt-1"></p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-[#C41E3A] text-white rounded-lg hover:bg-[#A01830] transition-colors"
>
<Plus className="h-4 w-4" />
</button>
</div>
<div className="bg-white rounded-lg border">
<div className="p-4 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="搜索用户..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredUsers.map(user => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
<UsersIcon className="h-5 w-5 text-gray-600" />
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">{user.name}</div>
<div className="text-sm text-gray-500">{user.email}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${roleColors[user.role]}`}>
{roleLabels[user.role]}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.createdAt).toLocaleDateString('zh-CN')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={(e) => {
e.stopPropagation();
setSelectedUser(user);
setFormData({
email: user.email,
name: user.name,
password: '',
role: user.role
});
setShowEditModal(true);
}}
className="text-[#C41E3A] hover:text-[#A01830] mr-4"
>
<Edit className="h-4 w-4 inline" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete(user.id);
}}
disabled={deletingUserId === user.id}
className="text-red-600 hover:text-red-800 disabled:opacity-50 disabled:cursor-not-allowed"
>
{deletingUserId === user.id ? (
<Loader2 className="h-4 w-4 inline animate-spin" />
) : (
<Trash2 className="h-4 w-4 inline" />
)}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredUsers.length === 0 && (
<div className="text-center py-12 text-gray-500">
</div>
)}
</div>
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4"></h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="email"
name="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="text"
name="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="password"
name="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<select
name="role"
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value as any })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
>
<option value="viewer"></option>
<option value="editor"></option>
<option value="admin"></option>
</select>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => {
setShowCreateModal(false);
setFormData({ email: '', name: '', password: '', role: 'viewer' });
}}
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
>
</button>
<button
onClick={handleCreate}
disabled={saving}
className="px-4 py-2 bg-[#C41E3A] text-white rounded-lg hover:bg-[#A01830] disabled:opacity-50"
>
{saving ? '创建中...' : '创建'}
</button>
</div>
</div>
</div>
)}
{/* Edit Modal */}
{showEditModal && selectedUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4"></h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="email"
name="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="text"
name="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="password"
name="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<select
name="role"
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value as any })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
>
<option value="viewer"></option>
<option value="editor"></option>
<option value="admin"></option>
</select>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => {
setShowEditModal(false);
setSelectedUser(null);
setFormData({ email: '', name: '', password: '', role: 'viewer' });
}}
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
>
</button>
<button
onClick={async () => {
setSaving(true);
try {
const updateData: any = {
email: formData.email,
name: formData.name,
role: formData.role
};
if (formData.password) {
updateData.password = formData.password;
}
const res = await fetch(`/api/admin/users/${selectedUser.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateData)
});
if (res.ok) {
setShowEditModal(false);
setSelectedUser(null);
setFormData({ email: '', name: '', password: '', role: 'viewer' });
await fetchUsers();
}
} catch (error) {
console.error('更新用户失败:', error);
} finally {
setSaving(false);
}
}}
disabled={saving}
className="px-4 py-2 bg-[#C41E3A] text-white rounded-lg hover:bg-[#A01830] disabled:opacity-50"
>
{saving ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
)}
</div>
);
}