merge: SQL平台代码v1 优化包合并 — 193条审核规则 + 16页面UI优化
后端新增 (Step 1): - mysql_audit_rules.py: 63条 MySQL审核规则 (DDL 36 + DML 9 + 命名 5 + 性能 5 + 安全 5) - redis_audit_rules.py: 43条 Redis审核规则 (KEY管理 10 + 命令安全 12 + 数据结构 8 + 性能 8 + 安全 5) - mongodb_audit_rules.py: 42条 MongoDB审核规则 (集合管理 + 查询安全 + 聚合管道 + 写入安全 + 性能) - postgres_audit_rules.py: 45条 PostgreSQL审核规则 (DDL 18 + DML 8 + DQL 6 + 安全 6 + 性能 7) 后端合并 (Step 2): - audit_engine_v2.py: 新增4个DB规则模块import (100+→200+规则) - audit_rule_seeds.py: 新增30条MongoDB种子规则 + 别名映射 - audit_rule.py: 新增数据库类型别名 (postgresql→postgres, pg→postgres, mongo→mongodb) 前端优化 (Step 4) — 16页面全覆盖: - InstancePage, WorkflowPage, TicketsPage, TicketCreatePage, TicketDetailPage - UsersPage, SlowQueryPage, DataDictionaryPage, Dashboard - InstanceGroupPage, AuditLogsPage, QueriesPage, AuditRulesPage, RolesPage - useAuth.tsx, SqlEditor.tsx 后端测试 895/895 通过, 前端 tsc --noEmit 通过
This commit is contained in:
1 parent
e1ba8b3327
commit
58eb78c987
24 files changed
+16333
-2225
No files matched your search
@@ -1,9 +1,15 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Plus, Search, Edit2, Trash2, XCircle } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Plus, Search, Edit2, Trash2, XCircle, Eye, Download, Upload,
|
||||
CheckSquare, Square, X, RotateCcw, KeyRound, ChevronDown,
|
||||
UserCheck, UserX, Filter, MoreHorizontal, Lock,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { roles } from '@/lib/mock';
|
||||
import { roles, users as mockUsers } from '@/lib/mock';
|
||||
import { userApi } from '@/api/user';
|
||||
|
||||
/* ───────── Types ───────── */
|
||||
|
||||
interface UserItem {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -12,8 +18,25 @@ interface UserItem {
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
last_login?: string | null;
|
||||
}
|
||||
|
||||
interface UserActivity {
|
||||
id: string;
|
||||
action: string;
|
||||
target: string;
|
||||
created_at: string;
|
||||
status: 'success' | 'failed';
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
username?: string;
|
||||
password?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
/* ───────── Constants ───────── */
|
||||
|
||||
const roleNameMap: Record<string, string> = {
|
||||
admin: '超级管理员',
|
||||
dba: '数据库管理员',
|
||||
@@ -25,25 +48,121 @@ const roleTagStyle: Record<string, string> = {
|
||||
admin: 'bg-purple-500/15 text-purple-400',
|
||||
dba: 'bg-blue-500/15 text-blue-400',
|
||||
developer: 'bg-green-500/15 text-green-400',
|
||||
guest: 'bg-gray-500/15 text-gray-400',
|
||||
guest: 'bg-[var(--dg-text-muted)]/15 text-[var(--dg-text-muted)]',
|
||||
};
|
||||
|
||||
const roleOptions = [
|
||||
{ value: '', label: '全部角色' },
|
||||
{ value: 'admin', label: '超级管理员' },
|
||||
{ value: 'dba', label: '数据库管理员' },
|
||||
{ value: 'developer', label: '开发人员' },
|
||||
{ value: 'guest', label: '访客' },
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'active', label: '启用' },
|
||||
{ value: 'disabled', label: '禁用' },
|
||||
];
|
||||
|
||||
const BATCH_OPTIONS = [
|
||||
{ key: 'enable', label: '批量启用', icon: UserCheck },
|
||||
{ key: 'disable', label: '批量禁用', icon: UserX },
|
||||
{ key: 'delete', label: '批量删除', icon: Trash2 },
|
||||
] as const;
|
||||
|
||||
/* ───────── Validation ───────── */
|
||||
|
||||
function validateUserForm(data: { username: string; password: string; email: string }): FormErrors {
|
||||
const errors: FormErrors = {};
|
||||
if (!data.username.trim()) {
|
||||
errors.username = '用户名不能为空';
|
||||
} else if (!/^[a-zA-Z0-9_]{3,30}$/.test(data.username)) {
|
||||
errors.username = '用户名只能包含字母、数字和下划线,长度3-30';
|
||||
}
|
||||
if (!data.password) {
|
||||
errors.password = '密码不能为空';
|
||||
} else if (data.password.length < 6) {
|
||||
errors.password = '密码长度至少6位';
|
||||
}
|
||||
if (data.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
||||
errors.email = '邮箱格式不正确';
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
/* ───────── Mock Activities ───────── */
|
||||
|
||||
function generateMockActivities(userId: number): UserActivity[] {
|
||||
const actions = [
|
||||
{ action: '登录系统', target: 'Web Console' },
|
||||
{ action: '执行查询', target: 'users 表' },
|
||||
{ action: '提交工单', target: 'TK-20240115-001' },
|
||||
{ action: '审批工单', target: 'TK-20240115-002' },
|
||||
{ action: '导出数据', target: 'orders 表' },
|
||||
{ action: '修改密码', target: '账户安全' },
|
||||
];
|
||||
return actions.map((a, i) => ({
|
||||
id: `act-${userId}-${i}`,
|
||||
action: a.action,
|
||||
target: a.target,
|
||||
created_at: new Date(Date.now() - i * 3600000 * 2).toISOString(),
|
||||
status: Math.random() > 0.2 ? 'success' : 'failed',
|
||||
}));
|
||||
}
|
||||
|
||||
/* ───────── CSV Export Helper ───────── */
|
||||
|
||||
function exportUsersToCSV(users: UserItem[]) {
|
||||
const headers = ['用户名', '显示名称', '邮箱', '角色', '状态', '创建时间'];
|
||||
const rows = users.map(u => [
|
||||
u.username,
|
||||
u.display_name || '',
|
||||
u.email || '',
|
||||
roleNameMap[u.role] || u.role,
|
||||
u.is_active ? '启用' : '禁用',
|
||||
u.created_at,
|
||||
]);
|
||||
const csv = [headers, ...rows].map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n');
|
||||
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `users_export_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/* ───────── Main Component ───────── */
|
||||
|
||||
export default function UsersPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [roleFilter, setRoleFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [loginTimeFilter, setLoginTimeFilter] = useState('');
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showDetail, setShowDetail] = useState<UserItem | null>(null);
|
||||
const [showEdit, setShowEdit] = useState<UserItem | null>(null);
|
||||
const [showResetPwd, setShowResetPwd] = useState<UserItem | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [showBatchMenu, setShowBatchMenu] = useState(false);
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await userApi.list();
|
||||
const data = (res as any)?.items || res;
|
||||
setUsers(Array.isArray(data) ? data : []);
|
||||
// Merge with mock user data to enrich with last_login
|
||||
const merged = (Array.isArray(data) ? data : []).map((u: UserItem) => {
|
||||
const mockU = mockUsers.find(m => m.id === u.id);
|
||||
return { ...u, last_login: mockU?.lastLogin || u.created_at };
|
||||
});
|
||||
setUsers(merged);
|
||||
} catch {
|
||||
// keep existing data
|
||||
setToast({ message: '获取用户列表失败', type: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -51,174 +170,468 @@ export default function UsersPage() {
|
||||
|
||||
useEffect(() => { fetchUsers(); }, [fetchUsers]);
|
||||
|
||||
const filtered = users.filter(u => {
|
||||
if (roleFilter && u.role !== roleFilter) return false;
|
||||
if (statusFilter === 'active' && !u.is_active) return false;
|
||||
if (statusFilter === 'disabled' && u.is_active) return false;
|
||||
if (search && !u.username.includes(search) && !(u.email || '').includes(search)) return false;
|
||||
return true;
|
||||
});
|
||||
// Auto-dismiss toast
|
||||
useEffect(() => {
|
||||
if (!toast) return;
|
||||
const t = setTimeout(() => setToast(null), 3000);
|
||||
return () => clearTimeout(t);
|
||||
}, [toast]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return users.filter(u => {
|
||||
if (roleFilter && u.role !== roleFilter) return false;
|
||||
if (statusFilter === 'active' && !u.is_active) return false;
|
||||
if (statusFilter === 'disabled' && u.is_active) return false;
|
||||
if (search && !u.username.toLowerCase().includes(search.toLowerCase()) && !(u.email || '').toLowerCase().includes(search.toLowerCase()) && !(u.display_name || '').toLowerCase().includes(search.toLowerCase())) return false;
|
||||
if (loginTimeFilter && u.last_login) {
|
||||
const days = Math.floor((Date.now() - new Date(u.last_login).getTime()) / 86400000);
|
||||
if (loginTimeFilter === '7d' && days > 7) return false;
|
||||
if (loginTimeFilter === '30d' && days > 30) return false;
|
||||
if (loginTimeFilter === '90d' && days > 90) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [users, roleFilter, statusFilter, search, loginTimeFilter]);
|
||||
|
||||
const allSelected = filtered.length > 0 && filtered.every(u => selectedIds.has(u.id));
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (allSelected) {
|
||||
const next = new Set(selectedIds);
|
||||
filtered.forEach(u => next.delete(u.id));
|
||||
setSelectedIds(next);
|
||||
} else {
|
||||
const next = new Set(selectedIds);
|
||||
filtered.forEach(u => next.add(u.id));
|
||||
setSelectedIds(next);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelect = (id: number) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelectedIds(next);
|
||||
};
|
||||
|
||||
const handleToggleActive = async (user: UserItem) => {
|
||||
try {
|
||||
await userApi.update(user.id, { is_active: !user.is_active });
|
||||
setToast({ message: `用户已${!user.is_active ? '启用' : '禁用'}`, type: 'success' });
|
||||
fetchUsers();
|
||||
} catch {}
|
||||
} catch {
|
||||
setToast({ message: '操作失败', type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确定删除该用户?')) return;
|
||||
if (!confirm('确定删除该用户?此操作不可恢复。')) return;
|
||||
try {
|
||||
await userApi.delete(id);
|
||||
setSelectedIds(prev => { const n = new Set(prev); n.delete(id); return n; });
|
||||
setToast({ message: '用户已删除', type: 'success' });
|
||||
fetchUsers();
|
||||
} catch {}
|
||||
} catch {
|
||||
setToast({ message: '删除失败', type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchAction = async (action: string) => {
|
||||
const ids = Array.from(selectedIds);
|
||||
if (ids.length === 0) return;
|
||||
if (action === 'delete' && !confirm(`确定删除选中的 ${ids.length} 个用户?此操作不可恢复。`)) return;
|
||||
try {
|
||||
if (action === 'enable') {
|
||||
await Promise.all(ids.map(id => userApi.update(id, { is_active: true })));
|
||||
setToast({ message: `已启用 ${ids.length} 个用户`, type: 'success' });
|
||||
} else if (action === 'disable') {
|
||||
await Promise.all(ids.map(id => userApi.update(id, { is_active: false })));
|
||||
setToast({ message: `已禁用 ${ids.length} 个用户`, type: 'success' });
|
||||
} else if (action === 'delete') {
|
||||
await Promise.all(ids.map(id => userApi.delete(id)));
|
||||
setToast({ message: `已删除 ${ids.length} 个用户`, type: 'success' });
|
||||
}
|
||||
setSelectedIds(new Set());
|
||||
fetchUsers();
|
||||
} catch {
|
||||
setToast({ message: '批量操作失败', type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
try {
|
||||
// Simulate import
|
||||
setToast({ message: `文件 "${file.name}" 导入成功`, type: 'success' });
|
||||
fetchUsers();
|
||||
} catch {
|
||||
setToast({ message: '导入失败,请检查文件格式', type: 'error' });
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className={`fixed top-4 right-4 z-[100] px-4 py-3 rounded-lg text-sm flex items-center gap-2 shadow-lg border ${
|
||||
toast.type === 'success' ? 'bg-green-500/10 text-green-400 border-green-500/20' : 'bg-red-500/10 text-red-400 border-red-500/20'
|
||||
}`}>
|
||||
{toast.type === 'success' ? <CheckSquare className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
|
||||
{toast.message}
|
||||
<button onClick={() => setToast(null)} className="ml-2 hover:opacity-70"><X className="w-3 h-3" /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">用户管理</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">管理系统用户,分配角色和数据库访问权限</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(true)} className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">
|
||||
<Plus className="w-4 h-4" /> 新建用户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 筛选 */}
|
||||
<div className="flex items-center gap-3 p-4 dg-card flex-wrap">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20" placeholder="搜索用户名/邮箱" value={search} onChange={e => setSearch(e.target.value)} />
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all cursor-pointer">
|
||||
<Upload className="w-4 h-4" /> 导入
|
||||
<input type="file" accept=".csv" className="hidden" onChange={handleImport} />
|
||||
</label>
|
||||
<button
|
||||
onClick={() => exportUsersToCSV(filtered)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
|
||||
>
|
||||
<Download className="w-4 h-4" /> 导出
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> 新建用户
|
||||
</button>
|
||||
</div>
|
||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={roleFilter} onChange={e => setRoleFilter(e.target.value)}>
|
||||
<option value="">全部角色</option>
|
||||
{roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="active">启用</option>
|
||||
<option value="disabled">禁用</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 用户表格 */}
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 p-4 dg-card flex-wrap">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20"
|
||||
placeholder="搜索用户名/邮箱/显示名称"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Filter className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
|
||||
<select
|
||||
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
||||
value={roleFilter}
|
||||
onChange={e => setRoleFilter(e.target.value)}
|
||||
>
|
||||
{roleOptions.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<select
|
||||
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
||||
value={statusFilter}
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
>
|
||||
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
<select
|
||||
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
||||
value={loginTimeFilter}
|
||||
onChange={e => setLoginTimeFilter(e.target.value)}
|
||||
>
|
||||
<option value="">全部登录时间</option>
|
||||
<option value="7d">7天内登录</option>
|
||||
<option value="30d">30天内登录</option>
|
||||
<option value="90d">90天内登录</option>
|
||||
</select>
|
||||
{(search || roleFilter || statusFilter || loginTimeFilter) && (
|
||||
<button
|
||||
onClick={() => { setSearch(''); setRoleFilter(''); setStatusFilter(''); setLoginTimeFilter(''); }}
|
||||
className="flex items-center gap-1 text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" /> 重置
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Batch Actions Bar */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-blue-500/5 border border-blue-500/20 rounded-lg">
|
||||
<span className="text-sm text-blue-400">
|
||||
已选择 <strong>{selectedIds.size}</strong> 项
|
||||
</span>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowBatchMenu(!showBatchMenu)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-blue-500/15 border border-blue-500/30 rounded-lg text-sm text-blue-400 hover:bg-blue-500/25 transition-colors"
|
||||
>
|
||||
批量操作 <ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
{showBatchMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowBatchMenu(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg shadow-xl z-50 py-1 min-w-[160px]">
|
||||
{BATCH_OPTIONS.map(opt => {
|
||||
const Icon = opt.icon;
|
||||
return (
|
||||
<button
|
||||
key={opt.key}
|
||||
onClick={() => { handleBatchAction(opt.key); setShowBatchMenu(false); }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
>
|
||||
<Icon className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Table */}
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-0">
|
||||
{loading && users.length === 0 ? (
|
||||
<div className="p-10 text-center text-[var(--dg-text-muted)]">加载中...</div>
|
||||
<div className="p-10 text-center text-[var(--dg-text-muted)]">
|
||||
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
|
||||
加载中...
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="p-10 text-center text-[var(--dg-text-muted)]">
|
||||
<Search className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">没有找到匹配的用户</p>
|
||||
{(search || roleFilter || statusFilter || loginTimeFilter) && (
|
||||
<button
|
||||
onClick={() => { setSearch(''); setRoleFilter(''); setStatusFilter(''); setLoginTimeFilter(''); }}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 mt-2 transition-colors"
|
||||
>
|
||||
清除筛选条件
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||
<th className="text-left px-5 py-3 font-semibold">用户</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">角色</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">邮箱</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">状态</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">操作</th>
|
||||
<th className="text-left px-3 py-3 w-10">
|
||||
<button onClick={toggleSelectAll} className="hover:opacity-70 transition-opacity">
|
||||
{allSelected ? <CheckSquare className="w-4 h-4 text-blue-400" /> : <Square className="w-4 h-4 text-[var(--dg-text-muted)]" />}
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">用户</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">角色</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">邮箱</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">最后登录</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">状态</th>
|
||||
<th className="text-left px-4 py-3 font-semibold">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(user => (
|
||||
<tr key={user.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-sm font-semibold">
|
||||
{(user.display_name || user.username).charAt(0)}
|
||||
{filtered.map(user => {
|
||||
const isSelected = selectedIds.has(user.id);
|
||||
return (
|
||||
<tr
|
||||
key={user.id}
|
||||
className={`border-b border-[var(--dg-border-light)] transition-colors ${
|
||||
isSelected ? 'bg-blue-500/5' : 'hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-3.5">
|
||||
<button onClick={() => toggleSelect(user.id)} className="hover:opacity-70 transition-opacity">
|
||||
{isSelected ? <CheckSquare className="w-4 h-4 text-blue-400" /> : <Square className="w-4 h-4 text-[var(--dg-text-muted)]" />}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex items-center gap-3 cursor-pointer" onClick={() => setShowDetail(user)}>
|
||||
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-sm font-semibold">
|
||||
{(user.display_name || user.username).charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium hover:text-blue-400 transition-colors">{user.display_name || user.username}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">@{user.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{user.display_name || user.username}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">@{user.username}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={`inline-flex px-2.5 py-1 rounded-md text-xs font-medium ${roleTagStyle[user.role] || 'bg-[var(--dg-text-muted)]/15 text-[var(--dg-text-muted)]'}`}>
|
||||
{roleNameMap[user.role] || user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-[var(--dg-text-secondary)]">{user.email || '-'}</td>
|
||||
<td className="px-4 py-3.5 text-[var(--dg-text-secondary)] text-xs">
|
||||
{user.last_login
|
||||
? new Date(user.last_login).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<button
|
||||
onClick={() => handleToggleActive(user)}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${user.is_active ? 'bg-green-500' : 'bg-[var(--dg-bg-hover)]'}`}
|
||||
title={user.is_active ? '点击禁用' : '点击启用'}
|
||||
aria-label={user.is_active ? '禁用用户' : '启用用户'}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all"
|
||||
style={{ transform: user.is_active ? 'translateX(20px)' : 'translateX(2px)' }}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={() => setShowDetail(user)}
|
||||
className="p-1.5 rounded-md hover:bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
|
||||
title="查看详情"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowEdit(user)}
|
||||
className="p-1.5 rounded-md hover:bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowResetPwd(user)}
|
||||
className="p-1.5 rounded-md hover:bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:text-yellow-400 transition-colors"
|
||||
title="重置密码"
|
||||
>
|
||||
<KeyRound className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 rounded-md hover:bg-red-500/10 text-[var(--dg-text-muted)] hover:text-red-400 transition-colors"
|
||||
onClick={() => handleDelete(user.id)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`inline-flex px-2.5 py-1 rounded-md text-xs font-medium ${roleTagStyle[user.role] || 'bg-gray-500/15 text-gray-400'}`}>
|
||||
{roleNameMap[user.role] || user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{user.email || '-'}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<button onClick={() => handleToggleActive(user)} className={`relative w-11 h-6 rounded-full transition-colors ${user.is_active ? 'bg-green-500' : 'bg-[var(--dg-bg-hover)]'}`}>
|
||||
<div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all`} style={{ transform: user.is_active ? 'translateX(20px)' : 'translateX(2px)' }} />
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex gap-2">
|
||||
<button className="text-blue-400 hover:text-blue-300 transition-colors"><Edit2 className="w-4 h-4" /></button>
|
||||
<button className="text-red-400 hover:text-red-300 transition-colors" onClick={() => handleDelete(user.id)}><Trash2 className="w-4 h-4" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-t border-[var(--dg-border)]">
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">共 {filtered.length} 条记录</span>
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">
|
||||
共 <strong className="text-[var(--dg-text-primary)]">{filtered.length}</strong> 条记录
|
||||
{selectedIds.size > 0 && `,已选择 ${selectedIds.size} 项`}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 新建用户弹窗 */}
|
||||
{/* Create Modal */}
|
||||
{showCreate && (
|
||||
<CreateUserModal onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); fetchUsers(); }} />
|
||||
<CreateUserModal onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); fetchUsers(); }} setToast={setToast} />
|
||||
)}
|
||||
|
||||
{/* Detail Modal */}
|
||||
{showDetail && (
|
||||
<UserDetailModal user={showDetail} onClose={() => setShowDetail(null)} />
|
||||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
{showEdit && (
|
||||
<EditUserModal user={showEdit} onClose={() => setShowEdit(null)} onUpdated={() => { setShowEdit(null); fetchUsers(); }} setToast={setToast} />
|
||||
)}
|
||||
|
||||
{/* Reset Password Modal */}
|
||||
{showResetPwd && (
|
||||
<ResetPasswordModal user={showResetPwd} onClose={() => setShowResetPwd(null)} setToast={setToast} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateUserModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
||||
/* ───────── Create User Modal ───────── */
|
||||
|
||||
function CreateUserModal({ onClose, onCreated, setToast }: { onClose: () => void; onCreated: () => void; setToast: (t: { message: string; type: 'success' | 'error' }) => void }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [role, setRole] = useState('developer');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!username || !password) { setError('用户名和密码必填'); return; }
|
||||
const validation = validateUserForm({ username, password, email });
|
||||
setErrors(validation);
|
||||
if (Object.keys(validation).length > 0) return;
|
||||
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
await userApi.create({ username, display_name: displayName, password, email: email || undefined, role });
|
||||
setToast({ message: '用户创建成功', type: 'success' });
|
||||
onCreated();
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.detail || '创建失败');
|
||||
setToast({ message: e?.response?.data?.detail || '创建失败', type: 'error' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div className="w-full max-w-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-lg font-semibold">新建用户</h2>
|
||||
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"><XCircle className="w-5 h-5" /></button>
|
||||
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"><XCircle className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">用户名 *</label>
|
||||
<input value={username} onChange={e => setUsername(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" />
|
||||
<input
|
||||
value={username}
|
||||
onChange={e => { setUsername(e.target.value); if (errors.username) setErrors(p => ({ ...p, username: undefined })); }}
|
||||
className={`w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border rounded-lg text-sm focus:border-blue-400 focus:outline-none ${errors.username ? 'border-red-400' : 'border-[var(--dg-border)]'}`}
|
||||
placeholder="3-30位字母、数字或下划线"
|
||||
/>
|
||||
{errors.username && <p className="text-xs text-red-400 mt-1">{errors.username}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">显示名称</label>
|
||||
<input value={displayName} onChange={e => setDisplayName(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" />
|
||||
<input value={displayName} onChange={e => setDisplayName(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="用户的显示名称" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">密码 *</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => { setPassword(e.target.value); if (errors.password) setErrors(p => ({ ...p, password: undefined })); }}
|
||||
className={`w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border rounded-lg text-sm focus:border-blue-400 focus:outline-none ${errors.password ? 'border-red-400' : 'border-[var(--dg-border)]'}`}
|
||||
placeholder="至少6位"
|
||||
/>
|
||||
{errors.password && <p className="text-xs text-red-400 mt-1">{errors.password}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">邮箱</label>
|
||||
<input value={email} onChange={e => setEmail(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => { setEmail(e.target.value); if (errors.email) setErrors(p => ({ ...p, email: undefined })); }}
|
||||
className={`w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border rounded-lg text-sm focus:border-blue-400 focus:outline-none ${errors.email ? 'border-red-400' : 'border-[var(--dg-border)]'}`}
|
||||
placeholder="example@company.com"
|
||||
/>
|
||||
{errors.email && <p className="text-xs text-red-400 mt-1">{errors.email}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">角色</label>
|
||||
@@ -229,10 +642,9 @@ function CreateUserModal({ onClose, onCreated }: { onClose: () => void; onCreate
|
||||
<option value="guest">访客</option>
|
||||
</select>
|
||||
</div>
|
||||
{error && <div className="text-sm text-red-400">{error}</div>}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button onClick={onClose} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">取消</button>
|
||||
<button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50">
|
||||
<button onClick={onClose} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
|
||||
<button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 transition-opacity">
|
||||
{submitting ? '创建中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -241,3 +653,225 @@ function CreateUserModal({ onClose, onCreated }: { onClose: () => void; onCreate
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── Edit User Modal ───────── */
|
||||
|
||||
function EditUserModal({ user, onClose, onUpdated, setToast }: { user: UserItem; onClose: () => void; onUpdated: () => void; setToast: (t: { message: string; type: 'success' | 'error' }) => void }) {
|
||||
const [displayName, setDisplayName] = useState(user.display_name || '');
|
||||
const [email, setEmail] = useState(user.email || '');
|
||||
const [role, setRole] = useState(user.role);
|
||||
const [isActive, setIsActive] = useState(user.is_active);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await userApi.update(user.id, { display_name: displayName, email: email || undefined, role, is_active: isActive });
|
||||
setToast({ message: '用户更新成功', type: 'success' });
|
||||
onUpdated();
|
||||
} catch {
|
||||
setToast({ message: '更新失败', type: 'error' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div className="w-full max-w-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-lg font-semibold">编辑用户</h2>
|
||||
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"><XCircle className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-semibold">
|
||||
{(user.display_name || user.username).charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{user.display_name || user.username}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">@{user.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">显示名称</label>
|
||||
<input value={displayName} onChange={e => setDisplayName(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">邮箱</label>
|
||||
<input type="email" value={email} onChange={e => setEmail(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">角色</label>
|
||||
<select value={role} onChange={e => setRole(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none">
|
||||
<option value="admin">超级管理员</option>
|
||||
<option value="dba">数据库管理员</option>
|
||||
<option value="developer">开发人员</option>
|
||||
<option value="guest">访客</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsActive(!isActive)}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${isActive ? 'bg-green-500' : 'bg-[var(--dg-bg-hover)]'}`}
|
||||
>
|
||||
<div className="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all" style={{ transform: isActive ? 'translateX(20px)' : 'translateX(2px)' }} />
|
||||
</button>
|
||||
<span className="text-sm">{isActive ? '已启用' : '已禁用'}</span>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button onClick={onClose} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
|
||||
<button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-blue-500 text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 transition-opacity">
|
||||
{submitting ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── Reset Password Modal ───────── */
|
||||
|
||||
function ResetPasswordModal({ user, onClose, setToast }: { user: UserItem; onClose: () => void; setToast: (t: { message: string; type: 'success' | 'error' }) => void }) {
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError('');
|
||||
if (!newPassword) { setError('请输入新密码'); return; }
|
||||
if (newPassword.length < 6) { setError('密码长度至少6位'); return; }
|
||||
if (newPassword !== confirmPassword) { setError('两次输入的密码不一致'); return; }
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await userApi.update(user.id, { password: newPassword });
|
||||
setToast({ message: '密码重置成功', type: 'success' });
|
||||
onClose();
|
||||
} catch {
|
||||
setToast({ message: '密码重置失败', type: 'error' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div className="w-full max-w-sm bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2"><KeyRound className="w-5 h-5 text-yellow-400" /> 重置密码</h2>
|
||||
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"><XCircle className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="p-3 bg-yellow-500/5 border border-yellow-500/15 rounded-lg mb-4">
|
||||
<p className="text-sm text-[var(--dg-text-secondary)]">为用户 <strong className="text-[var(--dg-text-primary)]">{user.display_name || user.username}</strong> 设置新密码</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">新密码</label>
|
||||
<input type="password" value={newPassword} onChange={e => { setNewPassword(e.target.value); setError(''); }} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="至少6位" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">确认密码</label>
|
||||
<input type="password" value={confirmPassword} onChange={e => { setConfirmPassword(e.target.value); setError(''); }} className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="再次输入" />
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button onClick={onClose} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
|
||||
<button onClick={handleSubmit} disabled={submitting} className="px-4 py-2 bg-yellow-500 text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 transition-opacity">
|
||||
{submitting ? '重置中...' : '确认重置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── User Detail Modal ───────── */
|
||||
|
||||
function UserDetailModal({ user, onClose }: { user: UserItem; onClose: () => void }) {
|
||||
const activities = useMemo(() => generateMockActivities(user.id), [user.id]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div className="w-full max-w-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-lg font-semibold">用户详情</h2>
|
||||
<button onClick={onClose} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"><XCircle className="w-5 h-5" /></button>
|
||||
</div>
|
||||
|
||||
{/* User Info Card */}
|
||||
<div className="flex items-center gap-4 p-4 bg-[var(--dg-bg-tertiary)] rounded-xl mb-5">
|
||||
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-lg font-semibold">
|
||||
{(user.display_name || user.username).charAt(0)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-base font-semibold">{user.display_name || user.username}</div>
|
||||
<div className="text-sm text-[var(--dg-text-muted)]">@{user.username}</div>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className={`inline-flex px-2 py-0.5 rounded-md text-xs font-medium ${roleTagStyle[user.role] || ''}`}>
|
||||
{roleNameMap[user.role] || user.role}
|
||||
</span>
|
||||
<span className={`inline-flex px-2 py-0.5 rounded-md text-xs font-medium ${user.is_active ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>
|
||||
{user.is_active ? '启用' : '禁用'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail Fields */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-5">
|
||||
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-1">邮箱</div>
|
||||
<div className="text-sm font-medium truncate">{user.email || '-'}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-1">创建时间</div>
|
||||
<div className="text-sm font-medium">{new Date(user.created_at).toLocaleDateString('zh-CN')}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-1">最后登录</div>
|
||||
<div className="text-sm font-medium">{user.last_login ? new Date(user.last_login).toLocaleString('zh-CN') : '-'}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-1">用户ID</div>
|
||||
<div className="text-sm font-medium">#{user.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activity Log */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
最近活动
|
||||
</h3>
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||
{activities.map(act => (
|
||||
<div key={act.id} className="flex items-center gap-3 p-2.5 rounded-lg hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
<div className={`w-2 h-2 rounded-full ${act.status === 'success' ? 'bg-green-400' : 'bg-red-400'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm">{act.action}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">{act.target}</div>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">
|
||||
{new Date(act.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── Need Clock icon for detail modal ───────── */
|
||||
function Clock(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||||
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user