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,18 +1,57 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Plus, Search, Edit2, Trash2, Server, Users, ChevronRight, X } from 'lucide-react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Plus, Search, Edit2, Trash2, Server, Users, ChevronRight, X,
|
||||
XCircle, Shield, Grid3x3, BarChart3, Clock, CheckCircle2,
|
||||
Database, HardDrive, Cpu, Activity, Filter, RotateCcw, ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { instanceGroupApi, type InstanceGroup } from '@/api/instanceGroup';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
import { userApi } from '@/api/user';
|
||||
import { useActionPermission } from '@/hooks/useAuth';
|
||||
|
||||
// 权限项 — 与后端 PERMISSION_ACTIONS 保持一致
|
||||
var PERMISSION_CATEGORIES = [
|
||||
/* ───────── Types ───────── */
|
||||
|
||||
interface PermissionCategory {
|
||||
label: string;
|
||||
items: { code: string; name: string }[];
|
||||
}
|
||||
|
||||
interface ResourceQuota {
|
||||
max_connections: number;
|
||||
max_qps: number;
|
||||
max_storage_gb: number;
|
||||
max_queries_per_day: number;
|
||||
}
|
||||
|
||||
interface ChangeLogEntry {
|
||||
id: string;
|
||||
user: string;
|
||||
action: string;
|
||||
target: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/* ───────── Constants ───────── */
|
||||
|
||||
const PERMISSION_CATEGORIES: PermissionCategory[] = [
|
||||
{
|
||||
label: '查询权限',
|
||||
items: [
|
||||
{ code: 'query:select', name: 'SELECT 查询' },
|
||||
{ code: 'query:write', name: '写入操作 (INSERT/UPDATE/DELETE)' },
|
||||
{ code: 'query:select', name: 'SELECT' },
|
||||
{ code: 'query:insert', name: 'INSERT' },
|
||||
{ code: 'query:update', name: 'UPDATE' },
|
||||
{ code: 'query:delete', name: 'DELETE' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'DDL 权限',
|
||||
items: [
|
||||
{ code: 'ddl:create_table', name: '创建表' },
|
||||
{ code: 'ddl:alter_table', name: '修改表' },
|
||||
{ code: 'ddl:drop_table', name: '删除表' },
|
||||
{ code: 'ddl:create_index', name: '创建索引' },
|
||||
{ code: 'ddl:drop_index', name: '删除索引' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -20,26 +59,28 @@ var PERMISSION_CATEGORIES = [
|
||||
items: [
|
||||
{ code: 'ticket:create', name: '创建工单' },
|
||||
{ code: 'ticket:approve', name: '审批工单' },
|
||||
{ code: 'ticket:reject', name: '拒绝工单' },
|
||||
{ code: 'ticket:execute', name: '执行工单' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'DDL 权限',
|
||||
label: '数据操作',
|
||||
items: [
|
||||
{ code: 'ddl:execute', name: 'DDL 执行' },
|
||||
{ code: 'export:data', name: '导出数据' },
|
||||
{ code: 'import:data', name: '导入数据' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '运维权限',
|
||||
label: '管理权限',
|
||||
items: [
|
||||
{ code: 'slowlog:view', name: '查看慢日志' },
|
||||
{ code: 'backup:manage', name: '备份管理' },
|
||||
{ code: 'instance:detail', name: '实例详情' },
|
||||
{ code: 'instance:connect', name: '连接实例' },
|
||||
{ code: 'instance:view_slow_query', name: '查看慢查询' },
|
||||
{ code: 'instance:kill_process', name: '终止进程' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
var DB_TYPE_OPTIONS = [
|
||||
const DB_TYPE_OPTIONS = [
|
||||
{ value: 'all', label: '全部类型' },
|
||||
{ value: 'mysql', label: 'MySQL' },
|
||||
{ value: 'pg', label: 'PostgreSQL' },
|
||||
@@ -47,7 +88,7 @@ var DB_TYPE_OPTIONS = [
|
||||
{ value: 'mongo', label: 'MongoDB' },
|
||||
];
|
||||
|
||||
var DB_TYPE_COLORS: Record<string, string> = {
|
||||
const DB_TYPE_COLORS: Record<string, string> = {
|
||||
mysql: 'bg-[rgba(0,117,143,0.15)] text-[#26a69a]',
|
||||
pg: 'bg-[rgba(51,103,145,0.15)] text-[#42a5f5]',
|
||||
redis: 'bg-[rgba(220,50,32,0.15)] text-[#ef5350]',
|
||||
@@ -55,67 +96,119 @@ var DB_TYPE_COLORS: Record<string, string> = {
|
||||
all: 'bg-[rgba(148,163,184,0.15)] text-[var(--dg-text-secondary)]',
|
||||
};
|
||||
|
||||
const DEFAULT_QUOTA: ResourceQuota = {
|
||||
max_connections: 100,
|
||||
max_qps: 1000,
|
||||
max_storage_gb: 50,
|
||||
max_queries_per_day: 10000,
|
||||
};
|
||||
|
||||
/* ───────── Mock Change Logs ───────── */
|
||||
|
||||
function generateMockChangeLogs(groupId: number): ChangeLogEntry[] {
|
||||
const actions = [
|
||||
{ action: '修改权限配置', target: 'query:select, query:insert' },
|
||||
{ action: '添加关联实例', target: '生产主库' },
|
||||
{ action: '移除关联用户', target: 'wangwu' },
|
||||
{ action: '更新资源配额', target: 'max_qps: 1000 -> 2000' },
|
||||
{ action: '启用权限组', target: '-' },
|
||||
];
|
||||
return actions.map((a, i) => ({
|
||||
id: `cl-${groupId}-${i}`,
|
||||
user: ['admin', 'zhangsan', 'lisi'][i % 3],
|
||||
action: a.action,
|
||||
target: a.target,
|
||||
created_at: new Date(Date.now() - i * 86400000).toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
/* ───────── Main Component ───────── */
|
||||
|
||||
export default function InstanceGroupPage() {
|
||||
var [groups, setGroups] = useState<InstanceGroup[]>([]);
|
||||
var [loading, setLoading] = useState(true);
|
||||
var [search, setSearch] = useState('');
|
||||
var [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
var [showCreateModal, setShowCreateModal] = useState(false);
|
||||
var [showEditModal, setShowEditModal] = useState(false);
|
||||
var [showInstanceModal, setShowInstanceModal] = useState(false);
|
||||
var [showUserModal, setShowUserModal] = useState(false);
|
||||
var canManage = useActionPermission('instance-group.manage');
|
||||
const [groups, setGroups] = useState<InstanceGroup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showInstanceModal, setShowInstanceModal] = useState(false);
|
||||
const [showUserModal, setShowUserModal] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'list' | 'matrix' | 'quota' | 'logs'>('list');
|
||||
const canManage = useActionPermission('instance-group.manage');
|
||||
|
||||
// 表单状态
|
||||
var [formName, setFormName] = useState('');
|
||||
var [formDesc, setFormDesc] = useState('');
|
||||
var [formDbType, setFormDbType] = useState('all');
|
||||
var [formPermissions, setFormPermissions] = useState<string[]>([]);
|
||||
var [formIsActive, setFormIsActive] = useState(true);
|
||||
// Form state
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formDesc, setFormDesc] = useState('');
|
||||
const [formDbType, setFormDbType] = useState('all');
|
||||
const [formPermissions, setFormPermissions] = useState<string[]>([]);
|
||||
const [formIsActive, setFormIsActive] = useState(true);
|
||||
|
||||
// 关联数据
|
||||
var [allInstances, setAllInstances] = useState<any[]>([]);
|
||||
var [allUsers, setAllUsers] = useState<any[]>([]);
|
||||
var [selectedInstanceIds, setSelectedInstanceIds] = useState<number[]>([]);
|
||||
var [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
||||
// Quota state
|
||||
const [quota, setQuota] = useState<ResourceQuota>(DEFAULT_QUOTA);
|
||||
|
||||
var selectedGroup = groups.find(g => g.id === selectedId);
|
||||
// Related data
|
||||
const [allInstances, setAllInstances] = useState<any[]>([]);
|
||||
const [allUsers, setAllUsers] = useState<any[]>([]);
|
||||
const [selectedInstanceIds, setSelectedInstanceIds] = useState<number[]>([]);
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
||||
const [instanceSearch, setInstanceSearch] = useState('');
|
||||
const [userSearch, setUserSearch] = useState('');
|
||||
|
||||
var fetchGroups = useCallback(async () => {
|
||||
const selectedGroup = groups.find(g => g.id === selectedId);
|
||||
|
||||
const fetchGroups = useCallback(async () => {
|
||||
try {
|
||||
var data = await instanceGroupApi.list();
|
||||
const data = await instanceGroupApi.list();
|
||||
setGroups(data);
|
||||
setLoading(false);
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchGroups(); }, [fetchGroups]);
|
||||
|
||||
// 加载关联数据
|
||||
// Load related data
|
||||
useEffect(() => {
|
||||
if (showInstanceModal) {
|
||||
instanceApi.list().then(setAllInstances).catch(() => {});
|
||||
}
|
||||
if (showUserModal) {
|
||||
userApi.list().then(setAllUsers).catch(() => {});
|
||||
userApi.list().then((data: any) => {
|
||||
const items = (data as any)?.items || data;
|
||||
setAllUsers(Array.isArray(items) ? items : []);
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, [showInstanceModal, showUserModal]);
|
||||
|
||||
var filteredGroups = groups.filter(g =>
|
||||
!search || g.name.includes(search) || (g.description || '').includes(search)
|
||||
);
|
||||
const filteredGroups = useMemo(() => {
|
||||
return groups.filter(g => !search || g.name.includes(search) || (g.description || '').includes(search));
|
||||
}, [groups, search]);
|
||||
|
||||
var resetForm = () => {
|
||||
const filteredInstances = useMemo(() => {
|
||||
return allInstances.filter(inst => !instanceSearch || inst.name.toLowerCase().includes(instanceSearch.toLowerCase()) || inst.host.includes(instanceSearch));
|
||||
}, [allInstances, instanceSearch]);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
return allUsers.filter(u => !userSearch || (u.display_name || u.username).toLowerCase().includes(userSearch.toLowerCase()));
|
||||
}, [allUsers, userSearch]);
|
||||
|
||||
const changeLogs = useMemo(() => {
|
||||
if (!selectedId) return [];
|
||||
return generateMockChangeLogs(selectedId);
|
||||
}, [selectedId]);
|
||||
|
||||
const resetForm = () => {
|
||||
setFormName('');
|
||||
setFormDesc('');
|
||||
setFormDbType('all');
|
||||
setFormPermissions([]);
|
||||
setFormIsActive(true);
|
||||
setQuota(DEFAULT_QUOTA);
|
||||
};
|
||||
|
||||
var handleCreate = async () => {
|
||||
const handleCreate = async () => {
|
||||
if (!formName.trim()) return;
|
||||
await instanceGroupApi.create({
|
||||
name: formName,
|
||||
@@ -129,7 +222,7 @@ export default function InstanceGroupPage() {
|
||||
fetchGroups();
|
||||
};
|
||||
|
||||
var handleEdit = async () => {
|
||||
const handleEdit = async () => {
|
||||
if (!selectedId || !formName.trim()) return;
|
||||
await instanceGroupApi.update(selectedId, {
|
||||
name: formName,
|
||||
@@ -143,14 +236,14 @@ export default function InstanceGroupPage() {
|
||||
fetchGroups();
|
||||
};
|
||||
|
||||
var handleDelete = async (id: number) => {
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确定删除该权限组?')) return;
|
||||
await instanceGroupApi.delete(id);
|
||||
if (selectedId === id) setSelectedId(null);
|
||||
fetchGroups();
|
||||
};
|
||||
|
||||
var openEditModal = (group: InstanceGroup) => {
|
||||
const openEditModal = (group: InstanceGroup) => {
|
||||
setFormName(group.name);
|
||||
setFormDesc(group.description || '');
|
||||
setFormDbType(group.db_type);
|
||||
@@ -159,7 +252,7 @@ export default function InstanceGroupPage() {
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
var handleAssignInstances = async () => {
|
||||
const handleAssignInstances = async () => {
|
||||
if (!selectedId || selectedInstanceIds.length === 0) return;
|
||||
await instanceGroupApi.assignInstances(selectedId, selectedInstanceIds);
|
||||
setShowInstanceModal(false);
|
||||
@@ -167,7 +260,7 @@ export default function InstanceGroupPage() {
|
||||
fetchGroups();
|
||||
};
|
||||
|
||||
var handleAssignUsers = async () => {
|
||||
const handleAssignUsers = async () => {
|
||||
if (!selectedId || selectedUserIds.length === 0) return;
|
||||
await instanceGroupApi.assignUsers(selectedId, selectedUserIds);
|
||||
setShowUserModal(false);
|
||||
@@ -175,14 +268,14 @@ export default function InstanceGroupPage() {
|
||||
fetchGroups();
|
||||
};
|
||||
|
||||
var togglePermission = (code: string) => {
|
||||
const togglePermission = (code: string) => {
|
||||
setFormPermissions(prev =>
|
||||
prev.includes(code) ? prev.filter(p => p !== code) : [...prev, code]
|
||||
);
|
||||
};
|
||||
|
||||
var toggleCategoryAll = (items: { code: string }[], checked: boolean) => {
|
||||
var codes = items.map(i => i.code);
|
||||
const toggleCategoryAll = (items: { code: string }[], checked: boolean) => {
|
||||
const codes = items.map(i => i.code);
|
||||
if (checked) {
|
||||
setFormPermissions(prev => [...new Set([...prev, ...codes])]);
|
||||
} else {
|
||||
@@ -190,8 +283,17 @@ export default function InstanceGroupPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染权限组表单
|
||||
var renderGroupForm = (onSubmit: () => void, submitLabel: string) => (
|
||||
// Permission matrix data
|
||||
const permissionMatrix = useMemo(() => {
|
||||
if (!selectedGroup) return [];
|
||||
const allPerms = PERMISSION_CATEGORIES.flatMap(cat => cat.items);
|
||||
return allPerms.map(p => ({
|
||||
...p,
|
||||
enabled: (selectedGroup.permissions || []).includes(p.code),
|
||||
}));
|
||||
}, [selectedGroup]);
|
||||
|
||||
const renderGroupForm = (onSubmit: () => void, submitLabel: string) => (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5">名称 <span className="text-red-400">*</span></label>
|
||||
@@ -220,7 +322,7 @@ export default function InstanceGroupPage() {
|
||||
<label className="block text-sm font-medium mb-2">权限配置</label>
|
||||
<div className="space-y-3 max-h-60 overflow-y-auto pr-2">
|
||||
{PERMISSION_CATEGORIES.map(cat => {
|
||||
var allChecked = cat.items.every(i => formPermissions.includes(i.code));
|
||||
const allChecked = cat.items.every(i => formPermissions.includes(i.code));
|
||||
return (
|
||||
<div key={cat.label}>
|
||||
<label className="flex items-center gap-2 text-xs font-semibold text-[var(--dg-text-secondary)] mb-1.5 cursor-pointer">
|
||||
@@ -247,7 +349,7 @@ export default function InstanceGroupPage() {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center h-64 text-[var(--dg-text-muted)]">加载中...</div>;
|
||||
if (loading) return <div className="flex items-center justify-center h-64 text-[var(--dg-text-muted)]"><div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mr-2" />加载中...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -263,30 +365,33 @@ export default function InstanceGroupPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: '权限组总数', value: groups.length, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
||||
{ label: '活跃权限组', value: groups.filter(g => g.is_active).length, color: 'text-green-400', bg: 'bg-green-400/10' },
|
||||
{ label: '关联实例数', value: groups.reduce((sum, g) => sum + (g.instance_count || 0), 0), color: 'text-purple-400', bg: 'bg-purple-400/10' },
|
||||
{ label: '关联用户数', value: groups.reduce((sum, g) => sum + (g.user_count || 0), 0), color: 'text-orange-400', bg: 'bg-orange-400/10' },
|
||||
].map((s, i) => (
|
||||
<Card key={i} className="dg-card">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}>
|
||||
{[Server, Server, Users, Users][i] && (() => { var Icon = [Server, Server, Users, Users][i]; return <Icon className={`w-5 h-5 ${s.color}`} />; })()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{s.value}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{ label: '权限组总数', value: groups.length, color: 'text-blue-400', bg: 'bg-blue-400/10', icon: Server },
|
||||
{ label: '活跃权限组', value: groups.filter(g => g.is_active).length, color: 'text-green-400', bg: 'bg-green-400/10', icon: CheckCircle2 },
|
||||
{ label: '关联实例数', value: groups.reduce((sum, g) => sum + (g.instance_count || 0), 0), color: 'text-purple-400', bg: 'bg-purple-400/10', icon: Database },
|
||||
{ label: '关联用户数', value: groups.reduce((sum, g) => sum + (g.user_count || 0), 0), color: 'text-orange-400', bg: 'bg-orange-400/10', icon: Users },
|
||||
].map((s, i) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<Card key={i} className="dg-card">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}>
|
||||
<Icon className={`w-5 h-5 ${s.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{s.value}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-5">
|
||||
{/* 左侧列表 */}
|
||||
{/* Left list */}
|
||||
<div className="w-80 flex-shrink-0 space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
@@ -316,102 +421,276 @@ export default function InstanceGroupPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧详情 */}
|
||||
{/* Right detail */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{selectedGroup ? (() => {
|
||||
var sg = selectedGroup!;
|
||||
const sg = selectedGroup!;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold">{sg.name}</h2>
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${DB_TYPE_COLORS[sg.db_type] || DB_TYPE_COLORS.all}`}>{DB_TYPE_OPTIONS.find(o => o.value === sg.db_type)?.label}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${sg.is_active ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>{sg.is_active ? '启用' : '禁用'}</span>
|
||||
<div className="space-y-4">
|
||||
{/* Info Card */}
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold">{sg.name}</h2>
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${DB_TYPE_COLORS[sg.db_type] || DB_TYPE_COLORS.all}`}>{DB_TYPE_OPTIONS.find(o => o.value === sg.db_type)?.label}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${sg.is_active ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>{sg.is_active ? '启用' : '禁用'}</span>
|
||||
</div>
|
||||
{sg.description && <p className="text-sm text-[var(--dg-text-secondary)] mt-1">{sg.description}</p>}
|
||||
</div>
|
||||
{sg.description && <p className="text-sm text-[var(--dg-text-secondary)] mt-1">{sg.description}</p>}
|
||||
{canManage && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => openEditModal(sg)} className="p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"><Edit2 className="w-4 h-4" /></button>
|
||||
<button onClick={() => handleDelete(sg.id)} className="p-2 rounded-lg hover:bg-red-500/10 text-[var(--dg-text-secondary)] hover:text-red-400 transition-colors"><Trash2 className="w-4 h-4" /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => openEditModal(sg)} className="p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"><Edit2 className="w-4 h-4" /></button>
|
||||
<button onClick={() => handleDelete(sg.id)} className="p-2 rounded-lg hover:bg-red-500/10 text-[var(--dg-text-secondary)] hover:text-red-400 transition-colors"><Trash2 className="w-4 h-4" /></button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* View Tabs */}
|
||||
<div className="flex items-center gap-1 p-1 bg-[var(--dg-bg-tertiary)] rounded-lg w-fit">
|
||||
{[
|
||||
{ key: 'list' as const, label: '权限列表', icon: Shield },
|
||||
{ key: 'matrix' as const, label: '权限矩阵', icon: Grid3x3 },
|
||||
{ key: 'quota' as const, label: '资源配额', icon: HardDrive },
|
||||
{ key: 'logs' as const, label: '变更日志', icon: Clock },
|
||||
].map(tab => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setViewMode(tab.key)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
viewMode === tab.key ? 'bg-blue-600 text-white' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* View: Permission List */}
|
||||
{viewMode === 'list' && (
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-5">
|
||||
{/* Permission tags */}
|
||||
{sg.permissions && sg.permissions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{sg.permissions.map(p => {
|
||||
const parts = p.split(':');
|
||||
return (
|
||||
<span key={p} className="px-2 py-1 text-xs bg-blue-500/10 text-blue-400 rounded-md flex items-center gap-1">
|
||||
<Shield className="w-3 h-3" />
|
||||
{parts[0]} · {parts[1]}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{(!sg.permissions || sg.permissions.length === 0) && (
|
||||
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无权限配置</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* View: Permission Matrix */}
|
||||
{viewMode === 'matrix' && (
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-5">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--dg-border)]">
|
||||
<th className="text-left px-3 py-2 font-medium text-[var(--dg-text-muted)]">权限类别</th>
|
||||
<th className="text-left px-3 py-2 font-medium text-[var(--dg-text-muted)]">权限项</th>
|
||||
<th className="text-center px-3 py-2 font-medium text-[var(--dg-text-muted)] w-20">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{PERMISSION_CATEGORIES.map(cat => (
|
||||
cat.items.map((item, itemIdx) => (
|
||||
<tr key={item.code} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
{itemIdx === 0 && (
|
||||
<td rowSpan={cat.items.length} className="px-3 py-2 font-medium text-xs text-[var(--dg-text-secondary)] align-top">
|
||||
{cat.label}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-3 py-2">{item.name}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{permissionMatrix.find(p => p.code === item.code)?.enabled ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-green-400 inline" />
|
||||
) : (
|
||||
<X className="w-4 h-4 text-[var(--dg-text-muted)] inline" />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 权限标签 */}
|
||||
{sg.permissions && sg.permissions.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="text-xs font-medium text-[var(--dg-text-secondary)] mb-2">已配置权限</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{sg.permissions.map(p => {
|
||||
var parts = p.split(':');
|
||||
return <span key={p} className="px-2 py-1 text-xs bg-blue-500/10 text-blue-400 rounded-md">{parts[0]} · {parts[1]}</span>;
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* View: Resource Quota */}
|
||||
{viewMode === 'quota' && (
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-5">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{ label: '最大连接数', key: 'max_connections' as const, icon: Users, unit: '' },
|
||||
{ label: '最大 QPS', key: 'max_qps' as const, icon: Activity, unit: '/s' },
|
||||
{ label: '最大存储', key: 'max_storage_gb' as const, icon: HardDrive, unit: 'GB' },
|
||||
{ label: '每日最大查询数', key: 'max_queries_per_day' as const, icon: Database, unit: '' },
|
||||
].map(item => {
|
||||
const Icon = item.icon;
|
||||
const value = quota[item.key];
|
||||
const percentage = Math.min(100, (value / (DEFAULT_QUOTA[item.key] * 2)) * 100);
|
||||
return (
|
||||
<div key={item.key} className="p-4 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<span className="text-sm">{item.label}</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold">{value.toLocaleString()}{item.unit}</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-[var(--dg-bg-secondary)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300 bg-blue-500"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 关联实例 */}
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2"><Server className="w-4 h-4 text-[var(--dg-text-muted)]" />关联实例 ({sg.instances?.length || 0})</h3>
|
||||
{canManage && (
|
||||
<button onClick={() => { setSelectedInstanceIds([]); setShowInstanceModal(true); }} className="text-xs text-blue-400 hover:text-blue-300 transition-colors">添加实例</button>
|
||||
)}
|
||||
</div>
|
||||
{sg.instances && sg.instances.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{sg.instances.map(inst => (
|
||||
<div key={inst.id} className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${inst.status === 'connected' ? 'bg-green-400' : 'bg-[var(--dg-text-muted)]'}`} />
|
||||
<span className="text-sm">{inst.name}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{inst.host}:{inst.port}</span>
|
||||
</div>
|
||||
{canManage && (
|
||||
<button onClick={async () => { await instanceGroupApi.removeInstance(sg.id, inst.id); fetchGroups(); }} className="text-[var(--dg-text-muted)] hover:text-red-400 transition-colors"><X className="w-3.5 h-3.5" /></button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂未关联实例</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 关联用户 */}
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2"><Users className="w-4 h-4 text-[var(--dg-text-muted)]" />关联用户 ({sg.users?.length || 0})</h3>
|
||||
{canManage && (
|
||||
<button onClick={() => { setSelectedUserIds([]); setShowUserModal(true); }} className="text-xs text-blue-400 hover:text-blue-300 transition-colors">添加用户</button>
|
||||
)}
|
||||
</div>
|
||||
{sg.users && sg.users.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{sg.users.map(u => (
|
||||
<div key={u.id} className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">{(u.display_name || u.username).charAt(0)}</div>
|
||||
<span className="text-sm">{u.display_name || u.username}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{u.role}</span>
|
||||
{canManage && (
|
||||
<div className="mt-4 pt-4 border-t border-[var(--dg-border)]">
|
||||
<h4 className="text-sm font-medium mb-3">编辑配额</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{([
|
||||
{ label: '最大连接数', key: 'max_connections' as const },
|
||||
{ label: '最大 QPS', key: 'max_qps' as const },
|
||||
{ label: '最大存储 (GB)', key: 'max_storage_gb' as const },
|
||||
{ label: '每日最大查询数', key: 'max_queries_per_day' as const },
|
||||
]).map(item => (
|
||||
<div key={item.key}>
|
||||
<label className="block text-xs font-medium mb-1">{item.label}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={quota[item.key]}
|
||||
onChange={e => setQuota(p => ({ ...p, [item.key]: Number(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>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* View: Change Logs */}
|
||||
{viewMode === 'logs' && (
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-5">
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||
{changeLogs.map(log => (
|
||||
<div key={log.id} className="flex items-center gap-3 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||
<Clock className="w-4 h-4 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm">{log.action}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">{log.target !== '-' ? log.target : ''}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="w-6 h-6 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">
|
||||
{log.user.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||
{new Date(log.created_at).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{changeLogs.length === 0 && (
|
||||
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无变更记录</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Related Instances */}
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2"><Server className="w-4 h-4 text-[var(--dg-text-muted)]" />关联实例 ({sg.instances?.length || 0})</h3>
|
||||
{canManage && (
|
||||
<button onClick={() => { setSelectedInstanceIds([]); setShowInstanceModal(true); }} className="text-xs text-blue-400 hover:text-blue-300 transition-colors">添加实例</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂未关联用户</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{sg.instances && sg.instances.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{sg.instances.map(inst => (
|
||||
<div key={inst.id} className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${inst.status === 'connected' ? 'bg-green-400' : 'bg-[var(--dg-text-muted)]'}`} />
|
||||
<span className="text-sm">{inst.name}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{inst.host}:{inst.port}</span>
|
||||
</div>
|
||||
{canManage && (
|
||||
<button onClick={async () => { await instanceGroupApi.removeInstance(sg.id, inst.id); fetchGroups(); }} className="text-[var(--dg-text-muted)] hover:text-red-400 transition-colors"><X className="w-3.5 h-3.5" /></button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)] flex flex-col items-center">
|
||||
<Server className="w-8 h-8 mb-2 opacity-30" />
|
||||
暂未关联实例
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Related Users */}
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2"><Users className="w-4 h-4 text-[var(--dg-text-muted)]" />关联用户 ({sg.users?.length || 0})</h3>
|
||||
{canManage && (
|
||||
<button onClick={() => { setSelectedUserIds([]); setShowUserModal(true); }} className="text-xs text-blue-400 hover:text-blue-300 transition-colors">添加用户</button>
|
||||
)}
|
||||
</div>
|
||||
{sg.users && sg.users.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{sg.users.map(u => (
|
||||
<div key={u.id} className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">{(u.display_name || u.username).charAt(0)}</div>
|
||||
<span className="text-sm">{u.display_name || u.username}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{u.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)] flex flex-col items-center">
|
||||
<Users className="w-8 h-8 mb-2 opacity-30" />
|
||||
暂未关联用户
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})() : (
|
||||
<div className="flex flex-col items-center justify-center h-96 text-[var(--dg-text-muted)]">
|
||||
@@ -422,7 +701,7 @@ export default function InstanceGroupPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 创建弹框 */}
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => { setShowCreateModal(false); resetForm(); }}>
|
||||
<div className="w-full max-w-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||
@@ -432,7 +711,7 @@ export default function InstanceGroupPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑弹框 */}
|
||||
{/* Edit Modal */}
|
||||
{showEditModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => { setShowEditModal(false); resetForm(); }}>
|
||||
<div className="w-full max-w-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||
@@ -442,14 +721,23 @@ export default function InstanceGroupPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 关联实例弹框 */}
|
||||
{/* Instance Modal with search */}
|
||||
{showInstanceModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowInstanceModal(false)}>
|
||||
<div className="w-full max-w-md bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-semibold mb-4">关联实例</h3>
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
value={instanceSearch}
|
||||
onChange={e => setInstanceSearch(e.target.value)}
|
||||
placeholder="搜索实例..."
|
||||
className="w-full pl-10 pr-4 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 className="space-y-2 max-h-72 overflow-y-auto mb-4">
|
||||
{allInstances.length === 0 && <div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无可选实例</div>}
|
||||
{allInstances.map(inst => (
|
||||
{filteredInstances.length === 0 && <div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无可选实例</div>}
|
||||
{filteredInstances.map(inst => (
|
||||
<label key={inst.id} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors ${selectedInstanceIds.includes(inst.id) ? 'bg-blue-500/10' : 'hover:bg-[var(--dg-bg-hover)]'}`}>
|
||||
<input type="checkbox" checked={selectedInstanceIds.includes(inst.id)} onChange={e => setSelectedInstanceIds(e.target.checked ? [...selectedInstanceIds, inst.id] : selectedInstanceIds.filter(id => id !== inst.id))} className="rounded" />
|
||||
<div className="flex-1">
|
||||
@@ -468,14 +756,23 @@ export default function InstanceGroupPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 关联用户弹框 */}
|
||||
{/* User Modal with search */}
|
||||
{showUserModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowUserModal(false)}>
|
||||
<div className="w-full max-w-md bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-semibold mb-4">关联用户</h3>
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
value={userSearch}
|
||||
onChange={e => setUserSearch(e.target.value)}
|
||||
placeholder="搜索用户..."
|
||||
className="w-full pl-10 pr-4 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 className="space-y-2 max-h-72 overflow-y-auto mb-4">
|
||||
{allUsers.length === 0 && <div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无可选用户</div>}
|
||||
{allUsers.map(u => (
|
||||
{filteredUsers.length === 0 && <div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无可选用户</div>}
|
||||
{filteredUsers.map(u => (
|
||||
<label key={u.id} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors ${selectedUserIds.includes(u.id) ? 'bg-blue-500/10' : 'hover:bg-[var(--dg-bg-hover)]'}`}>
|
||||
<input type="checkbox" checked={selectedUserIds.includes(u.id)} onChange={e => setSelectedUserIds(e.target.checked ? [...selectedUserIds, u.id] : selectedUserIds.filter(id => id !== u.id))} className="rounded" />
|
||||
<div className="w-7 h-7 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">{(u.display_name || u.username).charAt(0)}</div>
|
||||
|
||||
Reference in new issue
Block a user