feat: complete Rust backend UI compatibility
This commit is contained in:
+595
-367
File diff suppressed because it is too large
Load Diff
+487
-157
@@ -1,204 +1,534 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import CenteredModal from '../components/CenteredModal.js';
|
||||
import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
|
||||
|
||||
type UserRole = 'admin' | 'user';
|
||||
type UserStatus = 'active' | 'disabled';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
role: UserRole;
|
||||
status: UserStatus;
|
||||
createdAt?: string;
|
||||
created_at?: string;
|
||||
updatedAt?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
interface UserStats {
|
||||
total: number;
|
||||
active: number;
|
||||
disabled: number;
|
||||
admins: number;
|
||||
}
|
||||
|
||||
interface UserListMeta {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
type UserFormMode = 'create' | 'edit';
|
||||
|
||||
interface UserFormState {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role: UserRole;
|
||||
status: UserStatus;
|
||||
}
|
||||
|
||||
const emptyForm: UserFormState = {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
function normalizeUsers(result: any): User[] {
|
||||
const rawUsers = Array.isArray(result?.data)
|
||||
? result.data
|
||||
: Array.isArray(result?.users)
|
||||
? result.users
|
||||
: [];
|
||||
return rawUsers.map((user: any) => ({
|
||||
id: Number(user.id),
|
||||
username: String(user.username || ''),
|
||||
email: String(user.email || ''),
|
||||
role: user.role === 'admin' ? 'admin' : 'user',
|
||||
status: user.status === 'disabled' ? 'disabled' : 'active',
|
||||
createdAt: user.createdAt ?? user.created_at,
|
||||
created_at: user.created_at,
|
||||
updatedAt: user.updatedAt ?? user.updated_at,
|
||||
updated_at: user.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeStats(result: any, users: User[]): UserStats {
|
||||
const stats = result?.stats;
|
||||
if (stats) {
|
||||
return {
|
||||
total: Number(stats.total || 0),
|
||||
active: Number(stats.active || 0),
|
||||
disabled: Number(stats.disabled || 0),
|
||||
admins: Number(stats.admins || 0),
|
||||
};
|
||||
}
|
||||
return {
|
||||
total: users.length,
|
||||
active: users.filter((user) => user.status === 'active').length,
|
||||
disabled: users.filter((user) => user.status === 'disabled').length,
|
||||
admins: users.filter((user) => user.role === 'admin').length,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMeta(result: any, users: User[], page: number, pageSize: number): UserListMeta {
|
||||
const meta = result?.meta || {};
|
||||
const total = Number(meta.total ?? users.length);
|
||||
return {
|
||||
page: Number(meta.page || page),
|
||||
pageSize: Number(meta.pageSize || pageSize),
|
||||
total,
|
||||
totalPages: Math.max(1, Number(meta.totalPages || Math.ceil(total / pageSize) || 1)),
|
||||
};
|
||||
}
|
||||
|
||||
function formatDate(value?: string): string {
|
||||
if (!value) return '-';
|
||||
const normalized = value.includes('T') ? value : value.replace(' ', 'T');
|
||||
const date = new Date(normalized);
|
||||
if (Number.isNaN(date.getTime())) return value.slice(0, 16);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function roleLabel(role: UserRole): string {
|
||||
return role === 'admin' ? '管理员' : '普通用户';
|
||||
}
|
||||
|
||||
function statusLabel(status: UserStatus): string {
|
||||
return status === 'active' ? '正常' : '已禁用';
|
||||
}
|
||||
|
||||
export default function UserManagement() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [stats, setStats] = useState<UserStats>({ total: 0, active: 0, disabled: 0, admins: 0 });
|
||||
const [meta, setMeta] = useState<UserListMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 1 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [editingUser, setEditingUser] = useState<number | null>(null);
|
||||
const [editRole, setEditRole] = useState('');
|
||||
const [editStatus, setEditStatus] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [roleFilter, setRoleFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [formMode, setFormMode] = useState<UserFormMode>('create');
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [form, setForm] = useState<UserFormState>(emptyForm);
|
||||
const [deleteTarget, setDeleteTarget] = useState<User | 'selected' | null>(null);
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await api.getUsers();
|
||||
setUsers(Array.isArray(result.users) ? result.users : []);
|
||||
const params = new URLSearchParams();
|
||||
if (search.trim()) params.set('q', search.trim());
|
||||
if (roleFilter) params.set('role', roleFilter);
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
params.set('page', String(page));
|
||||
params.set('page_size', String(pageSize));
|
||||
|
||||
const result = await api.getUsers(params);
|
||||
const nextUsers = normalizeUsers(result);
|
||||
setUsers(nextUsers);
|
||||
setStats(normalizeStats(result, nextUsers));
|
||||
setMeta(normalizeMeta(result, nextUsers, page, pageSize));
|
||||
setSelectedIds((ids) => ids.filter((id) => nextUsers.some((user) => user.id === id)));
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '加载用户列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [page, pageSize, roleFilter, search, statusFilter]);
|
||||
|
||||
useEffect(() => { loadUsers(); }, [loadUsers]);
|
||||
useEffect(() => {
|
||||
const handle = window.setTimeout(() => {
|
||||
loadUsers();
|
||||
}, 180);
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [loadUsers]);
|
||||
|
||||
const handleUpdateUser = async (id: number) => {
|
||||
const selectedUsers = useMemo(
|
||||
() => users.filter((user) => selectedIds.includes(user.id)),
|
||||
[selectedIds, users],
|
||||
);
|
||||
const allVisibleSelected = users.length > 0 && users.every((user) => selectedIds.includes(user.id));
|
||||
|
||||
const openCreateModal = () => {
|
||||
setFormMode('create');
|
||||
setEditingUser(null);
|
||||
setForm(emptyForm);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (user: User) => {
|
||||
setFormMode('edit');
|
||||
setEditingUser(user);
|
||||
setForm({
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
password: '',
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
});
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const updateSelection = (id: number, checked: boolean) => {
|
||||
setSelectedIds((ids) => (
|
||||
checked ? Array.from(new Set([...ids, id])) : ids.filter((item) => item !== id)
|
||||
));
|
||||
};
|
||||
|
||||
const toggleVisibleSelection = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIds((ids) => Array.from(new Set([...ids, ...users.map((user) => user.id)])));
|
||||
return;
|
||||
}
|
||||
setSelectedIds((ids) => ids.filter((id) => !users.some((user) => user.id === id)));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const username = form.username.trim();
|
||||
const email = form.email.trim();
|
||||
const password = form.password.trim();
|
||||
if (!username || !email) {
|
||||
setError('用户名和邮箱不能为空');
|
||||
return;
|
||||
}
|
||||
if (formMode === 'create' && password.length < 6) {
|
||||
setError('新用户密码至少 6 位');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await api.updateUser(id, { role: editRole, status: editStatus });
|
||||
setEditingUser(null);
|
||||
if (formMode === 'create') {
|
||||
await api.createUser({
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
role: form.role,
|
||||
});
|
||||
} else if (editingUser) {
|
||||
await api.updateUser(editingUser.id, {
|
||||
username,
|
||||
email,
|
||||
role: form.role,
|
||||
status: form.status,
|
||||
...(password ? { password } : {}),
|
||||
});
|
||||
}
|
||||
setFormOpen(false);
|
||||
await loadUsers();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '更新失败');
|
||||
setError(err?.message || '保存用户失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisableUser = async (id: number) => {
|
||||
const updateUserStatus = async (user: User, status: UserStatus) => {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await api.deleteUser(id);
|
||||
await api.updateUser(user.id, { status });
|
||||
await loadUsers();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '操作失败');
|
||||
setError(err?.message || '更新用户状态失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-fade-in" style={{ padding: 16 }}>
|
||||
<div className="skeleton" style={{ width: 220, height: 24, marginBottom: 16 }} />
|
||||
<div className="skeleton" style={{ width: '100%', height: 120, borderRadius: 12 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const updateSelectedStatus = async (status: UserStatus) => {
|
||||
if (selectedUsers.length === 0) return;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await Promise.all(selectedUsers.map((user) => api.updateUser(user.id, { status })));
|
||||
setSelectedIds([]);
|
||||
await loadUsers();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '批量更新失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const targets = deleteTarget === 'selected' ? selectedUsers : [deleteTarget];
|
||||
await Promise.all(targets.map((user) => api.deleteUser(user.id)));
|
||||
setDeleteTarget(null);
|
||||
setSelectedIds([]);
|
||||
await loadUsers();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '删除用户失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const statCards = [
|
||||
{ label: '总用户', value: stats.total, tone: 'primary' },
|
||||
{ label: '正常用户', value: stats.active, tone: 'success' },
|
||||
{ label: '管理员', value: stats.admins, tone: 'info' },
|
||||
{ label: '已禁用', value: stats.disabled, tone: 'danger' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 600, margin: 0 }}>用户管理</h1>
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>
|
||||
共 {users.length} 个用户
|
||||
</span>
|
||||
<div className="page-container animate-fade-in user-management-page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h2 className="page-title">用户管理</h2>
|
||||
<div className="page-subtitle">维护平台用户、角色与登录状态。禁用不会删除用户数据,可随时恢复。</div>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={loadUsers} disabled={loading || saving}>
|
||||
刷新
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={openCreateModal}>
|
||||
新建用户
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
|
||||
{error ? <div className="alert alert-error user-management-alert">{error}</div> : null}
|
||||
|
||||
{/* Stats cards */}
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
|
||||
{[
|
||||
{ label: '总用户', value: users.length, color: '#818cf8' },
|
||||
{ label: '管理员', value: users.filter((u) => u.role === 'admin').length, color: '#4ade80' },
|
||||
{ label: '已禁用', value: users.filter((u) => u.status === 'disabled').length, color: '#f87171' },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} style={{
|
||||
flex: 1, padding: '14px 16px', borderRadius: 10,
|
||||
background: 'var(--color-surface, #1a1a2e)',
|
||||
border: '1px solid var(--color-border, #2a2a3e)',
|
||||
}}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>{stat.label}</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, color: stat.color }}>{stat.value}</div>
|
||||
<div className="user-management-stats">
|
||||
{statCards.map((stat) => (
|
||||
<div className={`user-stat-card user-stat-${stat.tone}`} key={stat.label}>
|
||||
<div className="user-stat-label">{stat.label}</div>
|
||||
<div className="user-stat-value">{loading ? '-' : stat.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Users table */}
|
||||
<div style={{
|
||||
background: 'var(--color-surface, #1a1a2e)',
|
||||
borderRadius: 12, border: '1px solid var(--color-border, #2a2a3e)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--color-border, #2a2a3e)' }}>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>ID</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>用户名</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>邮箱</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>角色</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>状态</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>注册时间</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'right', fontWeight: 600, color: 'var(--color-text-muted)' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ padding: 24, textAlign: 'center', color: 'var(--color-text-muted)' }}>
|
||||
暂无用户
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<tr key={user.id} style={{ borderBottom: '1px solid var(--color-border, #2a2a3e)' }}>
|
||||
<td style={{ padding: '12px 16px', fontFamily: 'monospace', fontSize: 12 }}>{user.id}</td>
|
||||
<td style={{ padding: '12px 16px' }}>{user.username}</td>
|
||||
<td style={{ padding: '12px 16px', color: 'var(--color-text-secondary)' }}>{user.email}</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
{editingUser === user.id ? (
|
||||
<select
|
||||
value={editRole}
|
||||
onChange={(e) => setEditRole(e.target.value)}
|
||||
style={{
|
||||
padding: '4px 8px', borderRadius: 4, fontSize: 12,
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
) : (
|
||||
<span style={{
|
||||
fontSize: 12, padding: '2px 8px', borderRadius: 4,
|
||||
background: user.role === 'admin' ? 'rgba(99,102,241,0.15)' : 'rgba(34,197,94,0.15)',
|
||||
color: user.role === 'admin' ? '#818cf8' : '#4ade80',
|
||||
}}>
|
||||
{user.role === 'admin' ? '管理员' : '用户'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
{editingUser === user.id ? (
|
||||
<select
|
||||
value={editStatus}
|
||||
onChange={(e) => setEditStatus(e.target.value)}
|
||||
style={{
|
||||
padding: '4px 8px', borderRadius: 4, fontSize: 12,
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
<option value="active">active</option>
|
||||
<option value="disabled">disabled</option>
|
||||
</select>
|
||||
) : (
|
||||
<span style={{
|
||||
fontSize: 12, padding: '2px 8px', borderRadius: 4,
|
||||
background: user.status === 'active' ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
|
||||
color: user.status === 'active' ? '#4ade80' : '#f87171',
|
||||
}}>
|
||||
{user.status === 'active' ? '正常' : '禁用'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
{user.createdAt?.slice(0, 10) || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
|
||||
{editingUser === user.id ? (
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => handleUpdateUser(user.id)} className="btn btn-primary" style={{ fontSize: 12, padding: '4px 12px' }}>保存</button>
|
||||
<button onClick={() => setEditingUser(null)} className="btn btn-ghost" style={{ fontSize: 12, padding: '4px 12px' }}>取消</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => { setEditingUser(user.id); setEditRole(user.role); setEditStatus(user.status); }}
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: '4px 12px' }}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
{user.status !== 'disabled' && (
|
||||
<button onClick={() => handleDisableUser(user.id)} className="btn btn-ghost danger" style={{ fontSize: 12, padding: '4px 12px' }}>
|
||||
禁用
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="card user-management-toolbar-card">
|
||||
<div className="toolbar user-management-toolbar">
|
||||
<div className="toolbar-search user-management-search">
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="m21 21-4.35-4.35M10.5 18a7.5 7.5 0 1 1 0-15 7.5 7.5 0 0 1 0 15Z" />
|
||||
</svg>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => { setSearch(event.target.value); setPage(1); }}
|
||||
placeholder="搜索 ID / 用户名 / 邮箱"
|
||||
/>
|
||||
</div>
|
||||
<select className="user-management-select" value={roleFilter} onChange={(event) => { setRoleFilter(event.target.value); setPage(1); }}>
|
||||
<option value="">全部角色</option>
|
||||
<option value="admin">管理员</option>
|
||||
<option value="user">普通用户</option>
|
||||
</select>
|
||||
<select className="user-management-select" value={statusFilter} onChange={(event) => { setStatusFilter(event.target.value); setPage(1); }}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="active">正常</option>
|
||||
<option value="disabled">已禁用</option>
|
||||
</select>
|
||||
<select className="user-management-select" value={pageSize} onChange={(event) => { setPageSize(Number(event.target.value)); setPage(1); }}>
|
||||
<option value={25}>25 / 页</option>
|
||||
<option value={50}>50 / 页</option>
|
||||
<option value={100}>100 / 页</option>
|
||||
</select>
|
||||
</div>
|
||||
{selectedUsers.length > 0 ? (
|
||||
<div className="user-management-batchbar">
|
||||
<span>已选 {selectedUsers.length} 个用户</span>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => updateSelectedStatus('active')} disabled={saving}>批量启用</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => updateSelectedStatus('disabled')} disabled={saving}>批量禁用</button>
|
||||
<button type="button" className="btn btn-link btn-link-danger" onClick={() => setDeleteTarget('selected')} disabled={saving}>批量删除</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="card user-management-table-card">
|
||||
{loading ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-title">加载中...</div>
|
||||
<div className="empty-state-desc">正在读取用户列表。</div>
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M18 18.72a9.1 9.1 0 0 0 3.74-1.22 5.25 5.25 0 0 0-7.49-3.82M15 11.25a3.75 3.75 0 1 0-7.5 0 3.75 3.75 0 0 0 7.5 0ZM4.5 20.1a7.5 7.5 0 0 1 15 0" />
|
||||
</svg>
|
||||
<div className="empty-state-title">没有匹配的用户</div>
|
||||
<div className="empty-state-desc">调整筛选条件,或使用右上角新建用户。</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="user-management-table-wrap">
|
||||
<table className="data-table user-management-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="user-management-check-col">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allVisibleSelected}
|
||||
onChange={(event) => toggleVisibleSelection(event.target.checked)}
|
||||
aria-label="选择当前页用户"
|
||||
/>
|
||||
</th>
|
||||
<th>用户</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>注册时间</th>
|
||||
<th>最近更新</th>
|
||||
<th className="user-management-actions-col">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className={selectedIds.includes(user.id) ? 'row-selected' : ''}>
|
||||
<td className="user-management-check-col">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(user.id)}
|
||||
onChange={(event) => updateSelection(user.id, event.target.checked)}
|
||||
aria-label={`选择 ${user.username}`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="user-identity-cell">
|
||||
<div className="user-avatar">{user.username.slice(0, 1).toUpperCase() || 'U'}</div>
|
||||
<div className="user-identity-text">
|
||||
<div className="user-name-line">
|
||||
<span className="user-name">{user.username}</span>
|
||||
<span className="user-id">#{user.id}</span>
|
||||
</div>
|
||||
<div className="user-email">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${user.role === 'admin' ? 'badge-info' : 'badge-muted'}`}>
|
||||
{roleLabel(user.role)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${user.status === 'active' ? 'badge-success' : 'badge-error'}`}>
|
||||
{statusLabel(user.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td>{formatDate(user.createdAt || user.created_at)}</td>
|
||||
<td>{formatDate(user.updatedAt || user.updated_at)}</td>
|
||||
<td className="user-management-actions-col">
|
||||
<div className="user-management-row-actions">
|
||||
<button type="button" className="btn btn-link btn-link-primary" onClick={() => openEditModal(user)}>编辑</button>
|
||||
{user.status === 'active' ? (
|
||||
<button type="button" className="btn btn-link btn-link-warning" onClick={() => updateUserStatus(user, 'disabled')} disabled={saving}>禁用</button>
|
||||
) : (
|
||||
<button type="button" className="btn btn-link btn-link-info" onClick={() => updateUserStatus(user, 'active')} disabled={saving}>启用</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-link btn-link-danger" onClick={() => setDeleteTarget(user)} disabled={saving}>删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div className="user-management-pagination">
|
||||
<div>共 {meta.total} 个用户,第 {meta.page} / {meta.totalPages} 页</div>
|
||||
<div className="user-management-page-controls">
|
||||
<button type="button" className="btn btn-ghost" disabled={page <= 1 || loading} onClick={() => setPage((value) => Math.max(1, value - 1))}>
|
||||
上一页
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" disabled={page >= meta.totalPages || loading} onClick={() => setPage((value) => value + 1)}>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CenteredModal
|
||||
open={formOpen}
|
||||
onClose={() => setFormOpen(false)}
|
||||
title={formMode === 'create' ? '新建用户' : `编辑用户 #${editingUser?.id ?? ''}`}
|
||||
maxWidth={620}
|
||||
closeOnEscape
|
||||
footer={(
|
||||
<>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setFormOpen(false)} disabled={saving}>取消</button>
|
||||
<button type="button" className="btn btn-primary" onClick={handleSubmit} disabled={saving}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="user-management-form">
|
||||
<label>
|
||||
<span>用户名</span>
|
||||
<input value={form.username} onChange={(event) => setForm((value) => ({ ...value, username: event.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span>邮箱</span>
|
||||
<input value={form.email} onChange={(event) => setForm((value) => ({ ...value, email: event.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span>{formMode === 'create' ? '密码' : '重置密码'}</span>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(event) => setForm((value) => ({ ...value, password: event.target.value }))}
|
||||
placeholder={formMode === 'create' ? '至少 6 位' : '留空则不修改'}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>角色</span>
|
||||
<select value={form.role} onChange={(event) => setForm((value) => ({ ...value, role: event.target.value as UserRole }))}>
|
||||
<option value="user">普通用户</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>状态</span>
|
||||
<select value={form.status} onChange={(event) => setForm((value) => ({ ...value, status: event.target.value as UserStatus }))} disabled={formMode === 'create'}>
|
||||
<option value="active">正常</option>
|
||||
<option value="disabled">已禁用</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</CenteredModal>
|
||||
|
||||
<DeleteConfirmModal
|
||||
open={deleteTarget !== null}
|
||||
title="确认删除用户"
|
||||
confirmText="确认删除"
|
||||
loading={saving}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={confirmDelete}
|
||||
description={deleteTarget === 'selected'
|
||||
? `将永久删除选中的 ${selectedUsers.length} 个用户。`
|
||||
: `将永久删除用户 ${deleteTarget?.username || ''}。`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user