feat: complete Rust backend UI compatibility

This commit is contained in:
boos
2026-05-18 16:52:54 +08:00
parent f2434e725d
commit f986fe0377
41 changed files with 3585 additions and 811 deletions
File diff suppressed because it is too large Load Diff
+487 -157
View File
@@ -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>
);
}