feat: 权限组重构为一组双Transfer(只读+写入) + 用户管理加权限组关联
后端: - 新增 instance_group_members 中间表 (group_id + instance_id + access_type) - 一个权限组同时包含 readonly 和 write 两类实例 - API create/update 支持 readonly_instance_ids + write_instance_ids - Schema Response 返回 readonly_instances + write_instances 分类型列表 - 20 个测试全部通过 前端: - InstanceGroupPage: 创建/编辑都是上下两个 Transfer 同时配置只读和写入 - 列表卡片显示 [N只读 · M写入] 标签 - 详情页分只读实例和写入实例两块展示 - UsersPage 编辑用户弹框加权限组多选 - instanceGroup.ts API 适配新接口
This commit is contained in:
1 parent
39d9600e82
commit
88d064ea95
3 files changed
+196
-181
No files matched your search
@@ -6,14 +6,15 @@ export interface InstanceGroup {
|
|||||||
description?: string;
|
description?: string;
|
||||||
db_type: string;
|
db_type: string;
|
||||||
permissions?: string[];
|
permissions?: string[];
|
||||||
access_type?: 'readonly' | 'write';
|
|
||||||
allowed_databases?: string[];
|
allowed_databases?: string[];
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
instance_count?: number;
|
readonly_instance_count: number;
|
||||||
user_count?: number;
|
write_instance_count: number;
|
||||||
instances?: { id: number; name: string; db_type: string; host: string; port: number; env: string; status: string }[];
|
user_count: number;
|
||||||
|
readonly_instances?: { id: number; name: string; db_type: string; host: string; port: number; env: string; status: string }[];
|
||||||
|
write_instances?: { id: number; name: string; db_type: string; host: string; port: number; env: string; status: string }[];
|
||||||
users?: { id: number; username: string; display_name: string; role: string }[];
|
users?: { id: number; username: string; display_name: string; role: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,10 +24,10 @@ export const instanceGroupApi = {
|
|||||||
create: (data: any) => request.post<any, InstanceGroup>('/instance-groups', data),
|
create: (data: any) => request.post<any, InstanceGroup>('/instance-groups', data),
|
||||||
update: (id: number, data: any) => request.put<any, InstanceGroup>(`/instance-groups/${id}`, data),
|
update: (id: number, data: any) => request.put<any, InstanceGroup>(`/instance-groups/${id}`, data),
|
||||||
delete: (id: number) => request.delete(`/instance-groups/${id}`),
|
delete: (id: number) => request.delete(`/instance-groups/${id}`),
|
||||||
assignInstances: (groupId: number, instanceIds: number[]) =>
|
assignInstances: (groupId: number, instanceIds: number[], accessType: string = 'readonly') =>
|
||||||
request.post(`/instance-groups/${groupId}/instances`, { instance_ids: instanceIds }),
|
request.post(`/instance-groups/${groupId}/instances`, { instance_ids: instanceIds, access_type: accessType }),
|
||||||
removeInstance: (groupId: number, instanceId: number) =>
|
removeInstance: (groupId: number, instanceId: number, accessType?: string) =>
|
||||||
request.delete(`/instance-groups/${groupId}/instances/${instanceId}`),
|
request.delete(`/instance-groups/${groupId}/instances/${instanceId}`, { params: accessType ? { access_type: accessType } : undefined }),
|
||||||
listUsers: (groupId: number) => request.get<any, any[]>(`/instance-groups/${groupId}/users`),
|
listUsers: (groupId: number) => request.get<any, any[]>(`/instance-groups/${groupId}/users`),
|
||||||
assignUsers: (groupId: number, userIds: number[], permissions?: string[]) =>
|
assignUsers: (groupId: number, userIds: number[], permissions?: string[]) =>
|
||||||
request.post(`/instance-groups/${groupId}/users`, { user_ids: userIds, permissions }),
|
request.post(`/instance-groups/${groupId}/users`, { user_ids: userIds, permissions }),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Plus, Search, Edit2, Trash2, Server, Users, ChevronRight, X,
|
Plus, Search, Edit2, Trash2, Server, Users, ChevronRight,
|
||||||
Eye, PenLine, ChevronLeft, ChevronRight as ChevRight,
|
Eye, PenLine, ChevronLeft, ChevronRight as ChevRight, X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { instanceGroupApi, type InstanceGroup } from '@/api/instanceGroup';
|
import { instanceGroupApi, type InstanceGroup } from '@/api/instanceGroup';
|
||||||
@@ -11,11 +11,6 @@ import { useActionPermission } from '@/hooks/useAuth';
|
|||||||
|
|
||||||
/* ───────── Constants ───────── */
|
/* ───────── Constants ───────── */
|
||||||
|
|
||||||
const ACCESS_TYPES = [
|
|
||||||
{ value: 'readonly', label: '只读查看', desc: '关联只读/从库,用于查询数据', color: 'text-emerald-400', bg: 'bg-emerald-500/15', icon: Eye },
|
|
||||||
{ value: 'write', label: '写入变更', desc: '关联读写/主库,用于数据变更工单', color: 'text-amber-400', bg: 'bg-amber-500/15', icon: PenLine },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const DB_TYPE_COLORS: Record<string, string> = {
|
const DB_TYPE_COLORS: Record<string, string> = {
|
||||||
mysql: 'bg-[rgba(0,117,143,0.15)] text-[#26a69a]',
|
mysql: 'bg-[rgba(0,117,143,0.15)] text-[#26a69a]',
|
||||||
pg: 'bg-[rgba(51,103,145,0.15)] text-[#42a5f5]',
|
pg: 'bg-[rgba(51,103,145,0.15)] text-[#42a5f5]',
|
||||||
@@ -33,9 +28,10 @@ interface TransferProps {
|
|||||||
items: TransferItem[];
|
items: TransferItem[];
|
||||||
selected: string[];
|
selected: string[];
|
||||||
onChange: (v: string[]) => void;
|
onChange: (v: string[]) => void;
|
||||||
|
emptyText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Transfer({ title, items, selected, onChange }: TransferProps) {
|
function Transfer({ title, items, selected, onChange, emptyText = '请从左侧选择实例' }: TransferProps) {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [pendingAdd, setPendingAdd] = useState<Set<string>>(new Set());
|
const [pendingAdd, setPendingAdd] = useState<Set<string>>(new Set());
|
||||||
const [pendingRemove, setPendingRemove] = useState<Set<string>>(new Set());
|
const [pendingRemove, setPendingRemove] = useState<Set<string>>(new Set());
|
||||||
@@ -60,7 +56,7 @@ function Transfer({ title, items, selected, onChange }: TransferProps) {
|
|||||||
<div className="flex-1 border border-[var(--dg-border)] rounded-lg overflow-hidden">
|
<div className="flex-1 border border-[var(--dg-border)] rounded-lg overflow-hidden">
|
||||||
<div className="px-3 py-1.5 bg-[var(--dg-bg-tertiary)] flex items-center justify-between">
|
<div className="px-3 py-1.5 bg-[var(--dg-bg-tertiary)] flex items-center justify-between">
|
||||||
<span className="text-xs font-medium">可选实例</span>
|
<span className="text-xs font-medium">可选实例</span>
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">{unselected.length} 项</span>
|
<span className="text-xs text-[var(--dg-text-muted)]">{unselected.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<input className="w-full px-2 py-1 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs focus:border-blue-400 focus:outline-none"
|
<input className="w-full px-2 py-1 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs focus:border-blue-400 focus:outline-none"
|
||||||
@@ -85,10 +81,10 @@ function Transfer({ title, items, selected, onChange }: TransferProps) {
|
|||||||
<div className="flex-1 border border-[var(--dg-border)] rounded-lg overflow-hidden">
|
<div className="flex-1 border border-[var(--dg-border)] rounded-lg overflow-hidden">
|
||||||
<div className="px-3 py-1.5 bg-[var(--dg-bg-tertiary)] flex items-center justify-between">
|
<div className="px-3 py-1.5 bg-[var(--dg-bg-tertiary)] flex items-center justify-between">
|
||||||
<span className="text-xs font-medium">{title}</span>
|
<span className="text-xs font-medium">{title}</span>
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">{selectedFiltered.length} 项</span>
|
<span className="text-xs text-[var(--dg-text-muted)]">{selectedFiltered.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-48 overflow-y-auto px-2 py-2 space-y-0.5">
|
<div className="max-h-48 overflow-y-auto px-2 py-2 space-y-0.5">
|
||||||
{selectedFiltered.length === 0 && <div className="text-center py-6 text-xs text-[var(--dg-text-muted)]">请从左侧选择实例</div>}
|
{selectedFiltered.length === 0 && <div className="text-center py-6 text-xs text-[var(--dg-text-muted)]">{emptyText}</div>}
|
||||||
{selectedFiltered.map(item => (
|
{selectedFiltered.map(item => (
|
||||||
<label key={item.value} className={row(pendingRemove.has(item.value), 'bg-red-500/10 text-red-400')}>
|
<label key={item.value} className={row(pendingRemove.has(item.value), 'bg-red-500/10 text-red-400')}>
|
||||||
<input type="checkbox" checked={pendingRemove.has(item.value)} onChange={() => toggleRemove(item.value)} className="rounded" />
|
<input type="checkbox" checked={pendingRemove.has(item.value)} onChange={() => toggleRemove(item.value)} className="rounded" />
|
||||||
@@ -115,24 +111,18 @@ export default function InstanceGroupPage() {
|
|||||||
const [showUserModal, setShowUserModal] = useState(false);
|
const [showUserModal, setShowUserModal] = useState(false);
|
||||||
const canManage = useActionPermission('instance-group.manage');
|
const canManage = useActionPermission('instance-group.manage');
|
||||||
|
|
||||||
// Create form — dual transfer
|
// Form state — shared by create & edit
|
||||||
const [formName, setFormName] = useState('');
|
const [formName, setFormName] = useState('');
|
||||||
const [formDesc, setFormDesc] = useState('');
|
const [formDesc, setFormDesc] = useState('');
|
||||||
const [formIsActive, setFormIsActive] = useState(true);
|
const [formIsActive, setFormIsActive] = useState(true);
|
||||||
const [readonlyInstanceIds, setReadonlyInstanceIds] = useState<string[]>([]);
|
const [readonlyIds, setReadonlyIds] = useState<string[]>([]);
|
||||||
const [writeInstanceIds, setWriteInstanceIds] = useState<string[]>([]);
|
const [writeIds, setWriteIds] = useState<string[]>([]);
|
||||||
|
|
||||||
// Edit form — single group
|
|
||||||
const [editAccessType, setEditAccessType] = useState<'readonly' | 'write'>('readonly');
|
|
||||||
const [editInstanceIds, setEditInstanceIds] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const [allInstances, setAllInstances] = useState<any[]>([]);
|
const [allInstances, setAllInstances] = useState<any[]>([]);
|
||||||
const [allUsers, setAllUsers] = useState<any[]>([]);
|
const [allUsers, setAllUsers] = useState<any[]>([]);
|
||||||
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
||||||
const [userSearch, setUserSearch] = useState('');
|
const [userSearch, setUserSearch] = useState('');
|
||||||
|
|
||||||
const selectedGroup = groups.find(g => g.id === selectedId);
|
|
||||||
|
|
||||||
const fetchGroups = useCallback(async () => {
|
const fetchGroups = useCallback(async () => {
|
||||||
try { setGroups(await instanceGroupApi.list()); } catch {} finally { setLoading(false); }
|
try { setGroups(await instanceGroupApi.list()); } catch {} finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
@@ -150,8 +140,7 @@ export default function InstanceGroupPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showUserModal) {
|
if (showUserModal) {
|
||||||
userApi.list().then((d: any) => {
|
userApi.list().then((d: any) => {
|
||||||
const items = d?.items || d;
|
setAllUsers(Array.isArray(d?.items || d) ? (d?.items || d) : []);
|
||||||
setAllUsers(Array.isArray(items) ? items : []);
|
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
}, [showUserModal]);
|
}, [showUserModal]);
|
||||||
@@ -169,50 +158,49 @@ export default function InstanceGroupPage() {
|
|||||||
const filteredGroups = useMemo(() => groups.filter(g => !search || g.name.includes(search)), [groups, search]);
|
const filteredGroups = useMemo(() => groups.filter(g => !search || g.name.includes(search)), [groups, search]);
|
||||||
const filteredUsers = useMemo(() => allUsers.filter(u => !userSearch || (u.display_name || u.username).toLowerCase().includes(userSearch.toLowerCase())), [allUsers, userSearch]);
|
const filteredUsers = useMemo(() => allUsers.filter(u => !userSearch || (u.display_name || u.username).toLowerCase().includes(userSearch.toLowerCase())), [allUsers, userSearch]);
|
||||||
|
|
||||||
const resetCreateForm = () => {
|
const resetForm = () => {
|
||||||
setFormName(''); setFormDesc(''); setFormIsActive(true);
|
setFormName(''); setFormDesc(''); setFormIsActive(true);
|
||||||
setReadonlyInstanceIds([]); setWriteInstanceIds([]);
|
setReadonlyIds([]); setWriteIds([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── Create: one shot, two groups ── */
|
/* ── Create ── */
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!formName.trim()) return;
|
if (!formName.trim()) return;
|
||||||
const base = formName.trim();
|
await instanceGroupApi.create({
|
||||||
const desc = formDesc.trim() || undefined;
|
name: formName.trim(),
|
||||||
|
description: formDesc.trim() || undefined,
|
||||||
// Create readonly group
|
db_type: 'all',
|
||||||
if (readonlyInstanceIds.length > 0) {
|
is_active: formIsActive,
|
||||||
const g = await instanceGroupApi.create({ name: `${base}-只读`, description: desc, access_type: 'readonly', is_active: formIsActive, db_type: 'all' } as any);
|
readonly_instance_ids: readonlyIds.map(Number),
|
||||||
await instanceGroupApi.assignInstances(g.id, readonlyInstanceIds.map(Number));
|
write_instance_ids: writeIds.map(Number),
|
||||||
}
|
});
|
||||||
// Create write group
|
setShowCreateModal(false); resetForm(); fetchGroups();
|
||||||
if (writeInstanceIds.length > 0) {
|
|
||||||
const g = await instanceGroupApi.create({ name: `${base}-写入`, description: desc, access_type: 'write', is_active: formIsActive, db_type: 'all' } as any);
|
|
||||||
await instanceGroupApi.assignInstances(g.id, writeInstanceIds.map(Number));
|
|
||||||
}
|
|
||||||
setShowCreateModal(false); resetCreateForm(); fetchGroups();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── Edit: single group ── */
|
/* ── Edit ── */
|
||||||
const openEditModal = (group: InstanceGroup) => {
|
const openEditModal = (group: InstanceGroup) => {
|
||||||
setFormName(group.name.replace(/-只读$/, '').replace(/-写入$/, ''));
|
setFormName(group.name);
|
||||||
setFormDesc(group.description || '');
|
setFormDesc(group.description || '');
|
||||||
setFormIsActive(group.is_active);
|
setFormIsActive(group.is_active);
|
||||||
setEditAccessType((group as any).access_type === 'write' ? 'write' : 'readonly');
|
setReadonlyIds((group.readonly_instances || []).map((i: any) => String(i.id)));
|
||||||
setEditInstanceIds((group.instances || []).map((i: any) => String(i.id)));
|
setWriteIds((group.write_instances || []).map((i: any) => String(i.id)));
|
||||||
|
setSelectedId(group.id);
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = async () => {
|
const handleEdit = async () => {
|
||||||
if (!selectedId || !formName.trim()) return;
|
if (!selectedId || !formName.trim()) return;
|
||||||
await instanceGroupApi.update(selectedId, {
|
await instanceGroupApi.update(selectedId, {
|
||||||
name: formName, description: formDesc || undefined,
|
name: formName.trim(),
|
||||||
access_type: editAccessType, is_active: formIsActive, db_type: 'all',
|
description: formDesc.trim() || undefined,
|
||||||
} as any);
|
is_active: formIsActive,
|
||||||
await instanceGroupApi.assignInstances(selectedId, editInstanceIds.map(Number));
|
readonly_instance_ids: readonlyIds.map(Number),
|
||||||
setShowEditModal(false); fetchGroups();
|
write_instance_ids: writeIds.map(Number),
|
||||||
|
});
|
||||||
|
setShowEditModal(false); resetForm(); fetchGroups();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ── Delete ── */
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (!confirm('确定删除该权限组?')) return;
|
if (!confirm('确定删除该权限组?')) return;
|
||||||
await instanceGroupApi.delete(id);
|
await instanceGroupApi.delete(id);
|
||||||
@@ -220,70 +208,126 @@ export default function InstanceGroupPage() {
|
|||||||
fetchGroups();
|
fetchGroups();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ── Assign Users ── */
|
||||||
const handleAssignUsers = async () => {
|
const handleAssignUsers = async () => {
|
||||||
if (!selectedId || selectedUserIds.length === 0) return;
|
if (!selectedId || selectedUserIds.length === 0) return;
|
||||||
await instanceGroupApi.assignUsers(selectedId, selectedUserIds);
|
await instanceGroupApi.assignUsers(selectedId, selectedUserIds);
|
||||||
setShowUserModal(false); setSelectedUserIds([]); fetchGroups();
|
setShowUserModal(false); setSelectedUserIds([]); fetchGroups();
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAt = (type: string) => ACCESS_TYPES.find(t => t.value === type) || ACCESS_TYPES[0];
|
const selectedGroup = groups.find(g => g.id === selectedId);
|
||||||
|
|
||||||
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>;
|
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>;
|
||||||
|
|
||||||
|
/* ── Instance Row ── */
|
||||||
|
const InstanceList = ({ instances }: { instances: any[] }) => {
|
||||||
|
if (instances.length === 0) return <div className="text-xs text-[var(--dg-text-muted)] py-2">暂无</div>;
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{instances.map(inst => (
|
||||||
|
<div key={inst.id} className="flex items-center justify-between px-3 py-1.5 bg-[var(--dg-bg-tertiary)] rounded-lg text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${inst.status === 'connected' ? 'bg-green-400' : 'bg-[var(--dg-text-muted)]'}`} />
|
||||||
|
<span>{inst.name}</span>
|
||||||
|
<span className="text-[var(--dg-text-muted)]">{inst.host}:{inst.port}</span>
|
||||||
|
</div>
|
||||||
|
<span className={`px-1 py-0.5 text-[10px] rounded ${DB_TYPE_COLORS[inst.db_type] || ''}`}>{inst.db_type}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Form Modal (shared by create & edit) ── */
|
||||||
|
const FormModal = ({ mode }: { mode: 'create' | 'edit' }) => {
|
||||||
|
const show = mode === 'create' ? showCreateModal : showEditModal;
|
||||||
|
const close = () => { setShowCreateModal(false); setShowEditModal(false); resetForm(); };
|
||||||
|
const submit = mode === 'create' ? handleCreate : handleEdit;
|
||||||
|
return show ? (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="w-full max-w-3xl bg-[var(--dg-bg-primary)] rounded-xl p-6 shadow-2xl max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3 className="text-lg font-semibold mb-5">{mode === 'create' ? '新建权限策略' : '编辑权限策略'}</h3>
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1.5">策略名称 <span className="text-red-400">*</span></label>
|
||||||
|
<input 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" value={formName} onChange={e => setFormName(e.target.value)} placeholder="如:生产数据库权限" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1.5">描述</label>
|
||||||
|
<input 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" value={formDesc} onChange={e => setFormDesc(e.target.value)} placeholder="可选" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Readonly Transfer */}
|
||||||
|
<div className="rounded-xl p-4 bg-emerald-500/5 border border-emerald-500/20">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Eye className="w-4 h-4 text-emerald-400" />
|
||||||
|
<span className="text-sm font-medium text-emerald-400">只读查看</span>
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">— 查询数据的实例</span>
|
||||||
|
</div>
|
||||||
|
<Transfer title="只读实例" items={transferItems} selected={readonlyIds} onChange={setReadonlyIds} emptyText="选择用于查询的实例" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Write Transfer */}
|
||||||
|
<div className="rounded-xl p-4 bg-amber-500/5 border border-amber-500/20">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<PenLine className="w-4 h-4 text-amber-400" />
|
||||||
|
<span className="text-sm font-medium text-amber-400">写入变更</span>
|
||||||
|
<span className="text-xs text-[var(--dg-text-muted)]">— 数据变更工单的实例</span>
|
||||||
|
</div>
|
||||||
|
<Transfer title="写入实例" items={transferItems} selected={writeIds} onChange={setWriteIds} emptyText="选择用于变更的实例" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button onClick={close} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors">取消</button>
|
||||||
|
<button onClick={submit} disabled={!formName.trim()} className="px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 transition-colors">
|
||||||
|
{mode === 'create' ? '创建' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">数据库权限组</h1>
|
<h1 className="text-2xl font-semibold">数据库权限组</h1>
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">只读查看 → 查询数据 | 写入变更 → 数据变更工单</p>
|
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">每个策略包含「只读查看」和「写入变更」两类实例</p>
|
||||||
</div>
|
</div>
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<button onClick={() => { resetCreateForm(); setShowCreateModal(true); }} className="flex items-center gap-2 px-4 py-2.5 bg-blue-500 rounded-lg text-sm font-medium text-white hover:bg-blue-600 transition-colors">
|
<button onClick={() => { resetForm(); setShowCreateModal(true); }} className="flex items-center gap-2 px-4 py-2.5 bg-blue-500 rounded-lg text-sm font-medium text-white hover:bg-blue-600 transition-colors">
|
||||||
<Plus className="w-4 h-4" /> 新建权限策略
|
<Plus className="w-4 h-4" /> 新建权限策略
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary cards */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
{ACCESS_TYPES.map(at => {
|
|
||||||
const count = groups.filter(g => (g as any).access_type === at.value).length;
|
|
||||||
const Icon = at.icon;
|
|
||||||
return (
|
|
||||||
<Card key={at.value} className="dg-card">
|
|
||||||
<CardContent className="p-4 flex items-center gap-4">
|
|
||||||
<div className={`w-12 h-12 rounded-xl ${at.bg} flex items-center justify-center`}><Icon className={`w-6 h-6 ${at.color}`} /></div>
|
|
||||||
<div className="flex-1"><div className="text-lg font-bold">{at.label}</div><div className="text-xs text-[var(--dg-text-muted)]">{at.desc}</div></div>
|
|
||||||
<div className="text-3xl font-bold text-[var(--dg-text-muted)]">{count}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-5">
|
<div className="flex gap-5">
|
||||||
{/* Left list */}
|
{/* Left list */}
|
||||||
<div className="w-72 flex-shrink-0 space-y-3">
|
<div className="w-80 flex-shrink-0 space-y-3">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
<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" placeholder="搜索" value={search} onChange={e => setSearch(e.target.value)} />
|
<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" placeholder="搜索" value={search} onChange={e => setSearch(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 max-h-[calc(100vh-420px)] overflow-y-auto">
|
<div className="space-y-2 max-h-[calc(100vh-300px)] overflow-y-auto">
|
||||||
{filteredGroups.length === 0 && <div className="text-center py-10 text-[var(--dg-text-muted)] text-sm">暂无权限组</div>}
|
{filteredGroups.length === 0 && <div className="text-center py-10 text-[var(--dg-text-muted)] text-sm">暂无权限组</div>}
|
||||||
{filteredGroups.map(group => {
|
{filteredGroups.map(group => {
|
||||||
const at = getAt((group as any).access_type || 'readonly');
|
const ro = group.readonly_instance_count || 0;
|
||||||
const Icon = at.icon;
|
const wr = group.write_instance_count || 0;
|
||||||
return (
|
return (
|
||||||
<div key={group.id} onClick={() => setSelectedId(group.id)} className={`p-3 rounded-lg cursor-pointer transition-colors ${selectedId === group.id ? 'bg-blue-500/10 border border-blue-500/30' : 'bg-[var(--dg-bg-secondary)] border border-transparent hover:bg-[var(--dg-bg-hover)]'}`}>
|
<div key={group.id} onClick={() => setSelectedId(group.id)} className={`p-3 rounded-lg cursor-pointer transition-colors ${selectedId === group.id ? 'bg-blue-500/10 border border-blue-500/30' : 'bg-[var(--dg-bg-secondary)] border border-transparent hover:bg-[var(--dg-bg-hover)]'}`}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon className={`w-4 h-4 ${at.color}`} />
|
|
||||||
<span className="font-medium text-sm truncate">{group.name}</span>
|
<span className="font-medium text-sm truncate">{group.name}</span>
|
||||||
|
{!group.is_active && <span className="px-1.5 py-0.5 text-[10px] rounded bg-red-500/15 text-red-400">禁用</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-1.5 text-xs text-[var(--dg-text-muted)]">
|
<div className="flex items-center gap-2 mt-1.5 text-xs text-[var(--dg-text-muted)]">
|
||||||
<span className={`px-1.5 py-0.5 rounded text-[10px] ${at.bg} ${at.color}`}>{at.label}</span>
|
<span className="px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-400 text-[10px]">{ro} 只读</span>
|
||||||
<span><Server className="w-3 h-3 inline mr-0.5" />{group.instances?.length || 0}实例</span>
|
<span className="px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-400 text-[10px]">{wr} 写入</span>
|
||||||
<span><Users className="w-3 h-3 inline mr-0.5" />{group.user_count || 0}</span>
|
<span className="ml-auto"><Users className="w-3 h-3 inline mr-0.5" />{group.user_count || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -295,8 +339,8 @@ export default function InstanceGroupPage() {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{selectedGroup ? (() => {
|
{selectedGroup ? (() => {
|
||||||
const sg = selectedGroup;
|
const sg = selectedGroup;
|
||||||
const at = getAt((sg as any).access_type || 'readonly');
|
const roInst = sg.readonly_instances || [];
|
||||||
const Icon = at.icon;
|
const wrInst = sg.write_instances || [];
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Card className="dg-card">
|
<Card className="dg-card">
|
||||||
@@ -304,12 +348,15 @@ export default function InstanceGroupPage() {
|
|||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Icon className={`w-5 h-5 ${at.color}`} />
|
|
||||||
<h2 className="text-lg font-semibold">{sg.name}</h2>
|
<h2 className="text-lg font-semibold">{sg.name}</h2>
|
||||||
<span className={`px-2 py-0.5 text-xs rounded ${at.bg} ${at.color}`}>{at.label}</span>
|
|
||||||
{!sg.is_active && <span className="px-2 py-0.5 text-xs rounded bg-red-500/15 text-red-400">禁用</span>}
|
{!sg.is_active && <span className="px-2 py-0.5 text-xs rounded bg-red-500/15 text-red-400">禁用</span>}
|
||||||
</div>
|
</div>
|
||||||
{sg.description && <p className="text-sm text-[var(--dg-text-secondary)] mt-1">{sg.description}</p>}
|
{sg.description && <p className="text-sm text-[var(--dg-text-secondary)] mt-1">{sg.description}</p>}
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded bg-emerald-500/15 text-emerald-400">{roInst.length} 只读实例</span>
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded bg-amber-500/15 text-amber-400">{wrInst.length} 写入实例</span>
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded bg-blue-500/15 text-blue-400">{sg.user_count || 0} 用户</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -320,23 +367,28 @@ export default function InstanceGroupPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Readonly instances */}
|
||||||
<Card className="dg-card">
|
<Card className="dg-card">
|
||||||
<CardContent className="p-5">
|
<CardContent className="p-5">
|
||||||
<h3 className="text-sm font-semibold flex items-center gap-2 mb-3"><Server className="w-4 h-4 text-[var(--dg-text-muted)]" />关联实例 ({sg.instances?.length || 0})</h3>
|
<h3 className="text-sm font-semibold flex items-center gap-2 mb-3">
|
||||||
{sg.instances && sg.instances.length > 0 ? (
|
<Eye className="w-4 h-4 text-emerald-400" />只读查看 ({roInst.length})
|
||||||
<div className="space-y-1.5">{sg.instances.map(inst => (
|
</h3>
|
||||||
<div key={inst.id} className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
<InstanceList instances={roInst} />
|
||||||
<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>
|
|
||||||
<span className={`px-1.5 py-0.5 text-[10px] rounded ${DB_TYPE_COLORS[inst.db_type] || ''}`}>{inst.db_type}</span>
|
|
||||||
</div>
|
|
||||||
))}</div>
|
|
||||||
) : <div className="text-sm text-[var(--dg-text-muted)]">暂未关联实例</div>}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Write instances */}
|
||||||
|
<Card className="dg-card">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<h3 className="text-sm font-semibold flex items-center gap-2 mb-3">
|
||||||
|
<PenLine className="w-4 h-4 text-amber-400" />写入变更 ({wrInst.length})
|
||||||
|
</h3>
|
||||||
|
<InstanceList instances={wrInst} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Users */}
|
||||||
<Card className="dg-card">
|
<Card className="dg-card">
|
||||||
<CardContent className="p-5">
|
<CardContent className="p-5">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
@@ -365,87 +417,11 @@ export default function InstanceGroupPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ═══════ Create Modal: dual transfer ═══════ */}
|
{/* Form Modal (create & edit) */}
|
||||||
{showCreateModal && (
|
<FormModal mode="create" />
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<FormModal mode="edit" />
|
||||||
<div className="w-full max-w-3xl bg-[var(--dg-bg-primary)] rounded-xl p-6 shadow-2xl max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
|
||||||
<h3 className="text-lg font-semibold mb-5">新建权限策略</h3>
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1.5">策略名称 <span className="text-red-400">*</span></label>
|
|
||||||
<input 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" value={formName} onChange={e => setFormName(e.target.value)} placeholder="如:生产环境、开发环境" />
|
|
||||||
<p className="text-xs text-[var(--dg-text-muted)] mt-1">自动生成「名称-只读」和「名称-写入」两个权限组</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1.5">描述</label>
|
|
||||||
<input 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" value={formDesc} onChange={e => setFormDesc(e.target.value)} placeholder="可选" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 1: Readonly */}
|
{/* User Modal */}
|
||||||
<div className={`rounded-xl p-4 border ${ACCESS_TYPES[0].bg} border-current ${ACCESS_TYPES[0].color.replace('text-', 'border-')}`}>
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<Eye className="w-5 h-5" />
|
|
||||||
<span className="font-medium">只读查看</span>
|
|
||||||
<span className="text-xs opacity-70">— 查询数据的实例</span>
|
|
||||||
</div>
|
|
||||||
<Transfer title="只读实例" items={transferItems} selected={readonlyInstanceIds} onChange={setReadonlyInstanceIds} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 2: Write */}
|
|
||||||
<div className={`rounded-xl p-4 border ${ACCESS_TYPES[1].bg} border-current ${ACCESS_TYPES[1].color.replace('text-', 'border-')}`}>
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<PenLine className="w-5 h-5" />
|
|
||||||
<span className="font-medium">写入变更</span>
|
|
||||||
<span className="text-xs opacity-70">— 数据变更工单的实例</span>
|
|
||||||
</div>
|
|
||||||
<Transfer title="写入实例" items={transferItems} selected={writeInstanceIds} onChange={setWriteInstanceIds} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-2">
|
|
||||||
<button onClick={() => { setShowCreateModal(false); resetCreateForm(); }} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors">取消</button>
|
|
||||||
<button onClick={handleCreate} disabled={!formName.trim() || (readonlyInstanceIds.length === 0 && writeInstanceIds.length === 0)} className="px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 transition-colors">
|
|
||||||
创建{readonlyInstanceIds.length > 0 && writeInstanceIds.length > 0 ? ' 2 个权限组' : readonlyInstanceIds.length > 0 || writeInstanceIds.length > 0 ? ' 1 个权限组' : ''}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ═══════ Edit Modal: single group ═══════ */}
|
|
||||||
{showEditModal && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
||||||
<div className="w-full max-w-2xl bg-[var(--dg-bg-primary)] rounded-xl p-6 shadow-2xl max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
|
||||||
<h3 className="text-lg font-semibold mb-5">编辑权限组</h3>
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1.5">名称</label>
|
|
||||||
<input 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" value={formName} onChange={e => setFormName(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1.5">类型</label>
|
|
||||||
<div className={`px-3 py-2 rounded-lg text-sm ${getAt(editAccessType).bg} ${getAt(editAccessType).color}`}>
|
|
||||||
{getAt(editAccessType).label}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">关联实例</label>
|
|
||||||
<Transfer title="已关联实例" items={transferItems} selected={editInstanceIds} onChange={setEditInstanceIds} />
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 pt-2">
|
|
||||||
<button onClick={() => setShowEditModal(false)} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors">取消</button>
|
|
||||||
<button onClick={handleEdit} className="px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">保存</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ═══════ User Modal ═══════ */}
|
|
||||||
{showUserModal && (
|
{showUserModal && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<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-primary)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
|
<div className="w-full max-w-md bg-[var(--dg-bg-primary)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { userApi } from '@/api/user';
|
import { userApi } from '@/api/user';
|
||||||
|
import { instanceGroupApi, type InstanceGroup } from '@/api/instanceGroup';
|
||||||
|
|
||||||
/* ───────── Types ───────── */
|
/* ───────── Types ───────── */
|
||||||
|
|
||||||
@@ -636,11 +637,32 @@ function EditUserModal({ user, onClose, onUpdated, setToast }: { user: UserItem;
|
|||||||
const [role, setRole] = useState(user.role);
|
const [role, setRole] = useState(user.role);
|
||||||
const [isActive, setIsActive] = useState(user.is_active);
|
const [isActive, setIsActive] = useState(user.is_active);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [groups, setGroups] = useState<InstanceGroup[]>([]);
|
||||||
|
const [selectedGroupIds, setSelectedGroupIds] = useState<number[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
instanceGroupApi.list().then((data: InstanceGroup[]) => {
|
||||||
|
setGroups(data);
|
||||||
|
// Reverse lookup: find which groups contain this user
|
||||||
|
const matched = data
|
||||||
|
.filter(g => g.users?.some(u => u.id === user.id))
|
||||||
|
.map(g => g.id);
|
||||||
|
setSelectedGroupIds(matched);
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [user.id]);
|
||||||
|
|
||||||
|
const toggleGroup = (gid: number) => {
|
||||||
|
setSelectedGroupIds(prev => prev.includes(gid) ? prev.filter(id => id !== gid) : [...prev, gid]);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await userApi.update(user.id, { display_name: displayName, email: email || undefined, role, is_active: isActive });
|
await userApi.update(user.id, { display_name: displayName, email: email || undefined, role, is_active: isActive });
|
||||||
|
// Update group associations
|
||||||
|
if (selectedGroupIds.length >= 0) {
|
||||||
|
await instanceGroupApi.updateUserGroups(user.id, selectedGroupIds);
|
||||||
|
}
|
||||||
setToast({ message: '用户更新成功', type: 'success' });
|
setToast({ message: '用户更新成功', type: 'success' });
|
||||||
onUpdated();
|
onUpdated();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -684,6 +706,22 @@ function EditUserModal({ user, onClose, onUpdated, setToast }: { user: UserItem;
|
|||||||
<option value="guest">访客</option>
|
<option value="guest">访客</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2">权限组</label>
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] py-2">暂无可选权限组</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5 max-h-36 overflow-y-auto">
|
||||||
|
{groups.map(g => (
|
||||||
|
<label key={g.id} className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-colors text-sm ${selectedGroupIds.includes(g.id) ? 'bg-blue-500/10 text-blue-400' : 'hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]'}`}>
|
||||||
|
<input type="checkbox" checked={selectedGroupIds.includes(g.id)} onChange={() => toggleGroup(g.id)} className="rounded" />
|
||||||
|
<span className="flex-1 truncate">{g.name}</span>
|
||||||
|
<span className="text-[10px] text-[var(--dg-text-muted)]">{g.readonly_instance_count || 0}R / {g.write_instance_count || 0}W</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsActive(!isActive)}
|
onClick={() => setIsActive(!isActive)}
|
||||||
|
|||||||
Reference in new issue
Block a user