feat: Phase 2 — frontend-backend integration complete
- All 5 core pages now use real API instead of mock data - Dashboard: /api/v1/dashboard/stats - Tickets: list/create/detail/approve/execute/statistics - Users: list/create/toggle-active/delete - Instances: list/create/test-connection/delete - Queries: execute with result display + sensitive field masking - Updated DashboardStats type to match backend schema - Fixed InstancePage to work with InstanceResponse (no qps/cpu/metrics yet) - Added create forms for Tickets, Users, Instances - Added connection test in InstanceDetail - Updated TDD_PLAN.md: Phase 1 ✅ (44 tests), Phase 2 ✅ - Updated ROADMAP.md: v0.2.0 ✅ - Updated PROJECT_EVALUATION.md: score 5.7 → 6.2 - Frontend build passes with zero TS errors - 44 backend tests still passing
This commit is contained in:
1 parent
c0c731acfc
commit
27989274f5
10 files changed
+1074
-840
No files matched your search
@@ -1,8 +1,25 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Plus, Search, Edit2, Lock, Trash2, } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Plus, Search, Edit2, Trash2, XCircle } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { users, roles } from '@/lib/mock';
|
||||
// type User is used implicitly
|
||||
import { roles } from '@/lib/mock';
|
||||
import { userApi } from '@/api/user';
|
||||
|
||||
interface UserItem {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string | null;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const roleNameMap: Record<string, string> = {
|
||||
admin: '超级管理员',
|
||||
dba: '数据库管理员',
|
||||
developer: '开发人员',
|
||||
guest: '访客',
|
||||
};
|
||||
|
||||
const roleTagStyle: Record<string, string> = {
|
||||
admin: 'bg-purple-500/15 text-purple-400',
|
||||
@@ -15,15 +32,47 @@ export default function UsersPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [roleFilter, setRoleFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return users.filter(u => {
|
||||
if (roleFilter && u.role !== roleFilter) return false;
|
||||
if (statusFilter && u.status !== statusFilter) return false;
|
||||
if (search && !u.username.includes(search) && !u.email.includes(search)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [search, roleFilter, statusFilter]);
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await userApi.list();
|
||||
const data = (res as any)?.items || res;
|
||||
setUsers(Array.isArray(data) ? data : []);
|
||||
} catch {
|
||||
// keep existing data
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
const handleToggleActive = async (user: UserItem) => {
|
||||
try {
|
||||
await userApi.update(user.id, { is_active: !user.is_active });
|
||||
fetchUsers();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确定删除该用户?')) return;
|
||||
try {
|
||||
await userApi.delete(id);
|
||||
fetchUsers();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -32,7 +81,7 @@ export default function UsersPage() {
|
||||
<h1 className="text-2xl font-semibold">用户管理</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">管理系统用户,分配角色和数据库访问权限</p>
|
||||
</div>
|
||||
<button 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">
|
||||
<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>
|
||||
@@ -57,63 +106,138 @@ export default function UsersPage() {
|
||||
{/* 用户表格 */}
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-0">
|
||||
<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-5 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.avatar}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{user.username}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">{user.email}</div>
|
||||
</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 as any)[user.role] || ''}`}>
|
||||
{user.roleName}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{user.department}</td>
|
||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{user.lastLogin}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<button
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${user.status === '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-transform ${user.status === 'active' ? 'translate-x-5.5' : 'translate-x-0.5'}`} style={{ transform: user.status === '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-purple-400 hover:text-purple-300 transition-colors"><Lock className="w-4 h-4" /></button>
|
||||
<button className="text-red-400 hover:text-red-300 transition-colors"><Trash2 className="w-4 h-4" /></button>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
{loading && users.length === 0 ? (
|
||||
<div className="p-10 text-center text-[var(--dg-text-muted)]">加载中...</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>
|
||||
</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)}
|
||||
</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>
|
||||
</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>
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 新建用户弹窗 */}
|
||||
{showCreate && (
|
||||
<CreateUserModal onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); fetchUsers(); }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateUserModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => 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 handleSubmit = async () => {
|
||||
if (!username || !password) { setError('用户名和密码必填'); return; }
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
await userApi.create({ username, display_name: displayName, password, email: email || undefined, role });
|
||||
onCreated();
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.detail || '创建失败');
|
||||
} 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="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>
|
||||
</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" />
|
||||
</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="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" />
|
||||
</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" />
|
||||
</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>
|
||||
{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">
|
||||
{submitting ? '创建中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user