feat: 权限组创建改为双Transfer同时配置只读+写入

- 新建时不再二选一,上下两列Transfer同时配置查询权限和写入权限
- 自动生成「名称-只读」「名称-写入」两个权限组
- 编辑模式保持单组编辑不变
- 按钮显示将创建几个权限组
This commit is contained in:
logan committed 2026-05-24 19:43:53 +08:00
1 parent 128de64592
commit 40868c61bf
1 file changed
+158 -200
+158 -200
View File
@@ -2,7 +2,6 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
import { import {
Plus, Search, Edit2, Trash2, Server, Users, ChevronRight, X, Plus, Search, Edit2, Trash2, Server, Users, ChevronRight, X,
Eye, PenLine, ChevronLeft, ChevronRight as ChevRight, Eye, PenLine, ChevronLeft, ChevronRight as ChevRight,
Database, UserPlus,
} 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';
@@ -13,22 +12,8 @@ import { useActionPermission } from '@/hooks/useAuth';
/* ───────── Constants ───────── */ /* ───────── Constants ───────── */
const ACCESS_TYPES = [ const ACCESS_TYPES = [
{ { value: 'readonly', label: '只读查看', desc: '关联只读/从库,用于查询数据', color: 'text-emerald-400', bg: 'bg-emerald-500/15', icon: Eye },
value: 'readonly', { value: 'write', label: '写入变更', desc: '关联读写/主库,用于数据变更工单', color: 'text-amber-400', bg: 'bg-amber-500/15', icon: PenLine },
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; ] as const;
const DB_TYPE_COLORS: Record<string, string> = { const DB_TYPE_COLORS: Record<string, string> = {
@@ -41,18 +26,13 @@ const DB_TYPE_COLORS: Record<string, string> = {
/* ───────── Transfer Component ───────── */ /* ───────── Transfer Component ───────── */
interface TransferItem { interface TransferItem { label: string; value: string; tag?: string; tagColor?: string }
label: string;
value: string;
tag?: string;
tagColor?: string;
}
interface TransferProps { interface TransferProps {
title: string; title: string;
items: TransferItem[]; items: TransferItem[];
selected: string[]; selected: string[];
onChange: (selected: string[]) => void; onChange: (v: string[]) => void;
} }
function Transfer({ title, items, selected, onChange }: TransferProps) { function Transfer({ title, items, selected, onChange }: TransferProps) {
@@ -60,45 +40,36 @@ function Transfer({ title, items, selected, onChange }: TransferProps) {
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());
const unselected = items.filter( const unselected = items.filter(i =>
i => !selected.includes(i.value) && !selected.includes(i.value) && (i.label.toLowerCase().includes(search.toLowerCase()) || i.value.includes(search))
(i.label.toLowerCase().includes(search.toLowerCase()) || i.value.toLowerCase().includes(search.toLowerCase()))
); );
const selectedFiltered = items.filter( const selectedFiltered = items.filter(i =>
i => selected.includes(i.value) && selected.includes(i.value) && (i.label.toLowerCase().includes(search.toLowerCase()) || i.value.includes(search))
(i.label.toLowerCase().includes(search.toLowerCase()) || i.value.toLowerCase().includes(search.toLowerCase()))
); );
const addSelected = () => { const addSelected = () => { onChange([...selected, ...pendingAdd]); setPendingAdd(new Set()); };
onChange([...selected, ...pendingAdd]); const removeSelected = () => { onChange(selected.filter(v => !pendingRemove.has(v))); setPendingRemove(new Set()); };
setPendingAdd(new Set()); const toggleAdd = (v: string) => setPendingAdd(p => { const n = new Set(p); n.has(v) ? n.delete(v) : n.add(v); return n; });
}; const toggleRemove = (v: string) => setPendingRemove(p => { const n = new Set(p); n.has(v) ? n.delete(v) : n.add(v); return n; });
const removeSelected = () => {
onChange(selected.filter(v => !pendingRemove.has(v)));
setPendingRemove(new Set());
};
const toggleAdd = (v: string) => setPendingAdd(prev => { const n = new Set(prev); n.has(v) ? n.delete(v) : n.add(v); return n; });
const toggleRemove = (v: string) => setPendingRemove(prev => { const n = new Set(prev); n.has(v) ? n.delete(v) : n.add(v); return n; });
const rowCls = (active: boolean, color: string) => const row = (active: boolean, color: string) =>
`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer text-xs transition-colors ${active ? color : 'hover:bg-[var(--dg-bg-hover)]'}`; `flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer text-xs transition-colors ${active ? color : 'hover:bg-[var(--dg-bg-hover)]'}`;
return ( return (
<div className="flex gap-3 items-stretch"> <div className="flex gap-3 items-stretch">
{/* Left: Available */}
<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-2 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.5 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"
placeholder="搜索..." value={search} onChange={e => setSearch(e.target.value)} /> placeholder="搜索..." value={search} onChange={e => setSearch(e.target.value)} />
</div> </div>
<div className="max-h-48 overflow-y-auto px-2 pb-2 space-y-0.5"> <div className="max-h-40 overflow-y-auto px-2 pb-2 space-y-0.5">
{unselected.length === 0 && <div className="text-center py-4 text-xs text-[var(--dg-text-muted)]">无可用项</div>} {unselected.length === 0 && <div className="text-center py-3 text-xs text-[var(--dg-text-muted)]">无可用项</div>}
{unselected.map(item => ( {unselected.map(item => (
<label key={item.value} className={rowCls(pendingAdd.has(item.value), 'bg-blue-500/10 text-blue-400')}> <label key={item.value} className={row(pendingAdd.has(item.value), 'bg-blue-500/10 text-blue-400')}>
<input type="checkbox" checked={pendingAdd.has(item.value)} onChange={() => toggleAdd(item.value)} className="rounded" /> <input type="checkbox" checked={pendingAdd.has(item.value)} onChange={() => toggleAdd(item.value)} className="rounded" />
<Server className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" /> <Server className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
<span className="truncate">{item.label}</span> <span className="truncate">{item.label}</span>
@@ -107,27 +78,19 @@ function Transfer({ title, items, selected, onChange }: TransferProps) {
))} ))}
</div> </div>
</div> </div>
<div className="flex flex-col justify-center gap-2">
{/* Arrows */} <button onClick={addSelected} disabled={pendingAdd.size === 0} className="p-1.5 rounded border border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 transition-all"><ChevRight className="w-4 h-4" /></button>
<div className="flex flex-col justify-center gap-2 py-6"> <button onClick={removeSelected} disabled={pendingRemove.size === 0} className="p-1.5 rounded border border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 transition-all"><ChevronLeft className="w-4 h-4" /></button>
<button onClick={addSelected} disabled={pendingAdd.size === 0} className="p-1.5 rounded border border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 transition-all">
<ChevRight className="w-4 h-4" />
</button>
<button onClick={removeSelected} disabled={pendingRemove.size === 0} className="p-1.5 rounded border border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 transition-all">
<ChevronLeft className="w-4 h-4" />
</button>
</div> </div>
{/* Right: Selected */}
<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-2 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-56 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-8 text-xs text-[var(--dg-text-muted)]">请从左侧选择实例</div>} {selectedFiltered.length === 0 && <div className="text-center py-6 text-xs text-[var(--dg-text-muted)]">请从左侧选择实例</div>}
{selectedFiltered.map(item => ( {selectedFiltered.map(item => (
<label key={item.value} className={rowCls(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" />
<Server className="w-3 h-3 text-emerald-400 flex-shrink-0" /> <Server className="w-3 h-3 text-emerald-400 flex-shrink-0" />
<span className="truncate">{item.label}</span> <span className="truncate">{item.label}</span>
@@ -140,7 +103,7 @@ function Transfer({ title, items, selected, onChange }: TransferProps) {
); );
} }
/* ───────── Main Component ───────── */ /* ───────── Main ───────── */
export default function InstanceGroupPage() { export default function InstanceGroupPage() {
const [groups, setGroups] = useState<InstanceGroup[]>([]); const [groups, setGroups] = useState<InstanceGroup[]>([]);
@@ -152,18 +115,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');
// Form state // Create form — dual transfer
const [formName, setFormName] = useState(''); const [formName, setFormName] = useState('');
const [formDesc, setFormDesc] = useState(''); const [formDesc, setFormDesc] = useState('');
const [formAccessType, setFormAccessType] = useState<'readonly' | 'write'>('readonly');
const [formIsActive, setFormIsActive] = useState(true); const [formIsActive, setFormIsActive] = useState(true);
// Transfer: instance IDs as strings const [readonlyInstanceIds, setReadonlyInstanceIds] = useState<string[]>([]);
const [formInstanceIds, setFormInstanceIds] = useState<string[]>([]); const [writeInstanceIds, setWriteInstanceIds] = useState<string[]>([]);
// Edit form — single group
const [editAccessType, setEditAccessType] = useState<'readonly' | 'write'>('readonly');
const [editInstanceIds, setEditInstanceIds] = useState<string[]>([]);
// All instances for transfer
const [allInstances, setAllInstances] = useState<any[]>([]); const [allInstances, setAllInstances] = useState<any[]>([]);
// User assignment
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('');
@@ -171,41 +134,34 @@ export default function InstanceGroupPage() {
const selectedGroup = groups.find(g => g.id === selectedId); const selectedGroup = groups.find(g => g.id === selectedId);
const fetchGroups = useCallback(async () => { const fetchGroups = useCallback(async () => {
try { try { setGroups(await instanceGroupApi.list()); } catch {} finally { setLoading(false); }
const data = await instanceGroupApi.list();
setGroups(data);
} catch { /* */ } finally { setLoading(false); }
}, []); }, []);
useEffect(() => { fetchGroups(); }, [fetchGroups]); useEffect(() => { fetchGroups(); }, [fetchGroups]);
// Load instances when modal opens
useEffect(() => { useEffect(() => {
if (showCreateModal || showEditModal) { if (showCreateModal || showEditModal) {
instanceApi.list().then((res: any) => { instanceApi.list().then((res: any) => {
const data = Array.isArray(res) ? res : (res?.items || []); setAllInstances(Array.isArray(res) ? res : (res?.items || []));
setAllInstances(data);
}).catch(() => {}); }).catch(() => {});
} }
}, [showCreateModal, showEditModal]); }, [showCreateModal, showEditModal]);
// Load users when user modal opens
useEffect(() => { useEffect(() => {
if (showUserModal) { if (showUserModal) {
userApi.list().then((data: any) => { userApi.list().then((d: any) => {
const items = (data as any)?.items || data; const items = d?.items || d;
setAllUsers(Array.isArray(items) ? items : []); setAllUsers(Array.isArray(items) ? items : []);
}).catch(() => {}); }).catch(() => {});
} }
}, [showUserModal]); }, [showUserModal]);
// Build transfer items from allInstances
const transferItems = useMemo(() => const transferItems = useMemo(() =>
allInstances.map((inst: any) => ({ allInstances.map((i: any) => ({
label: `${inst.name} (${inst.host}:${inst.port})`, label: `${i.name} (${i.host}:${i.port})`,
value: String(inst.id), value: String(i.id),
tag: inst.db_type, tag: i.db_type,
tagColor: DB_TYPE_COLORS[inst.db_type] || '', tagColor: DB_TYPE_COLORS[i.db_type] || '',
})), })),
[allInstances] [allInstances]
); );
@@ -213,42 +169,48 @@ 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 resetForm = () => { const resetCreateForm = () => {
setFormName(''); setFormDesc(''); setFormAccessType('readonly'); setFormName(''); setFormDesc(''); setFormIsActive(true);
setFormIsActive(true); setFormInstanceIds([]); setReadonlyInstanceIds([]); setWriteInstanceIds([]);
}; };
// Convert instance id strings to actual group assignment /* ── Create: one shot, two groups ── */
const handleCreate = async () => { const handleCreate = async () => {
if (!formName.trim()) return; if (!formName.trim()) return;
const group = await instanceGroupApi.create({ const base = formName.trim();
name: formName, const desc = formDesc.trim() || undefined;
description: formDesc || undefined,
access_type: formAccessType, // Create readonly group
is_active: formIsActive, if (readonlyInstanceIds.length > 0) {
db_type: 'all', const g = await instanceGroupApi.create({ name: `${base}-只读`, description: desc, access_type: 'readonly', is_active: formIsActive, db_type: 'all' } as any);
} as any); await instanceGroupApi.assignInstances(g.id, readonlyInstanceIds.map(Number));
// Assign instances to this group
const ids = formInstanceIds.map(Number);
if (ids.length > 0 && group.id) {
await instanceGroupApi.assignInstances(group.id, ids);
} }
setShowCreateModal(false); resetForm(); fetchGroups(); // Create write group
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 ── */
const openEditModal = (group: InstanceGroup) => {
setFormName(group.name.replace(/-只读$/, '').replace(/-写入$/, ''));
setFormDesc(group.description || '');
setFormIsActive(group.is_active);
setEditAccessType((group as any).access_type === 'write' ? 'write' : 'readonly');
setEditInstanceIds((group.instances || []).map((i: any) => String(i.id)));
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, name: formName, description: formDesc || undefined,
description: formDesc || undefined, access_type: editAccessType, is_active: formIsActive, db_type: 'all',
access_type: formAccessType,
is_active: formIsActive,
db_type: 'all',
} as any); } as any);
// Reassign instances await instanceGroupApi.assignInstances(selectedId, editInstanceIds.map(Number));
const ids = formInstanceIds.map(Number); setShowEditModal(false); fetchGroups();
await instanceGroupApi.assignInstances(selectedId, ids);
setShowEditModal(false); resetForm(); fetchGroups();
}; };
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
@@ -258,22 +220,13 @@ export default function InstanceGroupPage() {
fetchGroups(); fetchGroups();
}; };
const openEditModal = (group: InstanceGroup) => {
setFormName(group.name);
setFormDesc(group.description || '');
setFormAccessType(((group as any).access_type === 'write' ? 'write' : 'readonly'));
setFormIsActive(group.is_active);
setFormInstanceIds((group.instances || []).map((i: any) => String(i.id)));
setShowEditModal(true);
};
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 getAtConfig = (type: string) => ACCESS_TYPES.find(t => t.value === type) || ACCESS_TYPES[0]; const getAt = (type: string) => ACCESS_TYPES.find(t => t.value === type) || ACCESS_TYPES[0];
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>;
@@ -286,13 +239,13 @@ export default function InstanceGroupPage() {
<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={() => { 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"> <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">
<Plus className="w-4 h-4" /> 新建权限组 <Plus className="w-4 h-4" /> 新建权限策略
</button> </button>
)} )}
</div> </div>
{/* 2 permission type cards */} {/* Summary cards */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{ACCESS_TYPES.map(at => { {ACCESS_TYPES.map(at => {
const count = groups.filter(g => (g as any).access_type === at.value).length; const count = groups.filter(g => (g as any).access_type === at.value).length;
@@ -300,13 +253,8 @@ export default function InstanceGroupPage() {
return ( return (
<Card key={at.value} className="dg-card"> <Card key={at.value} className="dg-card">
<CardContent className="p-4 flex items-center gap-4"> <CardContent className="p-4 flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl ${at.bg} flex items-center justify-center`}> <div className={`w-12 h-12 rounded-xl ${at.bg} flex items-center justify-center`}><Icon className={`w-6 h-6 ${at.color}`} /></div>
<Icon className={`w-6 h-6 ${at.color}`} /> <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>
<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> <div className="text-3xl font-bold text-[var(--dg-text-muted)]">{count}</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -324,13 +272,13 @@ export default function InstanceGroupPage() {
<div className="space-y-2 max-h-[calc(100vh-420px)] overflow-y-auto"> <div className="space-y-2 max-h-[calc(100vh-420px)] 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 = getAtConfig((group as any).access_type || 'readonly'); const at = getAt((group as any).access_type || 'readonly');
const Icon = at.icon; const Icon = at.icon;
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}`} /> <Icon className={`w-4 h-4 ${at.color}`} />
<span className="font-medium text-sm">{group.name}</span> <span className="font-medium text-sm truncate">{group.name}</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 text-[10px] ${at.bg} ${at.color}`}>{at.label}</span>
@@ -347,7 +295,7 @@ 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 = getAtConfig((sg as any).access_type || 'readonly'); const at = getAt((sg as any).access_type || 'readonly');
const Icon = at.icon; const Icon = at.icon;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -372,29 +320,23 @@ export default function InstanceGroupPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Related 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"><Server className="w-4 h-4 text-[var(--dg-text-muted)]" />关联实例 ({sg.instances?.length || 0})</h3>
{sg.instances && sg.instances.length > 0 ? ( {sg.instances && sg.instances.length > 0 ? (
<div className="space-y-1.5"> <div className="space-y-1.5">{sg.instances.map(inst => (
{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 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">
<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={`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-sm">{inst.name}</span> <span className="text-xs text-[var(--dg-text-muted)]">{inst.host}:{inst.port}</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>
))} <span className={`px-1.5 py-0.5 text-[10px] rounded ${DB_TYPE_COLORS[inst.db_type] || ''}`}>{inst.db_type}</span>
</div> </div>
))}</div>
) : <div className="text-sm text-[var(--dg-text-muted)]">暂未关联实例</div>} ) : <div className="text-sm text-[var(--dg-text-muted)]">暂未关联实例</div>}
</CardContent> </CardContent>
</Card> </Card>
{/* Related 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">
@@ -402,15 +344,13 @@ export default function InstanceGroupPage() {
{canManage && <button onClick={() => { setSelectedUserIds([]); setShowUserModal(true); }} className="text-xs text-blue-400 hover:text-blue-300">添加用户</button>} {canManage && <button onClick={() => { setSelectedUserIds([]); setShowUserModal(true); }} className="text-xs text-blue-400 hover:text-blue-300">添加用户</button>}
</div> </div>
{sg.users && sg.users.length > 0 ? ( {sg.users && sg.users.length > 0 ? (
<div className="space-y-1.5"> <div className="space-y-1.5">{sg.users.map(u => (
{sg.users.map(u => ( <div key={u.id} className="flex items-center gap-2.5 px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div key={u.id} className="flex items-center gap-2.5 px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg"> <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>
<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-sm">{u.display_name || u.username}</span> <span className="text-xs text-[var(--dg-text-muted)]">{u.role}</span>
<span className="text-xs text-[var(--dg-text-muted)]">{u.role}</span> </div>
</div> ))}</div>
))}
</div>
) : <div className="text-sm text-[var(--dg-text-muted)]">暂未关联用户</div>} ) : <div className="text-sm text-[var(--dg-text-muted)]">暂未关联用户</div>}
</CardContent> </CardContent>
</Card> </Card>
@@ -425,69 +365,87 @@ export default function InstanceGroupPage() {
</div> </div>
</div> </div>
{/* Create/Edit Modal */} {/* ═══════ Create Modal: dual transfer ═══════ */}
{(showCreateModal || showEditModal) && ( {showCreateModal && (
<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-2xl bg-[var(--dg-bg-primary)] rounded-xl p-6 shadow-2xl max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}> <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">{showCreateModal ? '新建权限组' : '编辑权限组'}</h3> <h3 className="text-lg font-semibold mb-5">新建权限策略</h3>
<div className="space-y-5"> <div className="space-y-5">
<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 className="grid grid-cols-2 gap-4"> <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> <div>
<label className="block text-sm font-medium mb-1.5">描述</label> <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="可选" /> <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>
<div>
<label className="block text-sm font-medium mb-1.5">状态</label>
<select 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={formIsActive ? 'true' : 'false'} onChange={e => setFormIsActive(e.target.value === 'true')}>
<option value="true">启用</option>
<option value="false">禁用</option>
</select>
</div>
</div> </div>
{/* Access Type */} {/* Row 1: Readonly */}
<div> <div className={`rounded-xl p-4 border ${ACCESS_TYPES[0].bg} border-current ${ACCESS_TYPES[0].color.replace('text-', 'border-')}`}>
<label className="block text-sm font-medium mb-2">权限类型</label> <div className="flex items-center gap-2 mb-3">
<div className="grid grid-cols-2 gap-3"> <Eye className="w-5 h-5" />
{ACCESS_TYPES.map(at => { <span className="font-medium">只读查看</span>
const Icon = at.icon; <span className="text-xs opacity-70">— 查询数据的实例</span>
return (
<button key={at.value} type="button" onClick={() => setFormAccessType(at.value as 'readonly' | 'write')}
className={`p-4 rounded-lg text-left transition-all ${formAccessType === at.value ? at.bg + ' ring-1 ring-current ' + at.color : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'}`}>
<Icon className="w-5 h-5 mb-2" />
<div className="text-sm font-medium">{at.label}</div>
<div className="text-[11px] mt-1 opacity-70">{at.desc}</div>
</button>
);
})}
</div> </div>
<Transfer title="只读实例" items={transferItems} selected={readonlyInstanceIds} onChange={setReadonlyInstanceIds} />
</div> </div>
{/* Transfer: select instances */} {/* Row 2: Write */}
<div> <div className={`rounded-xl p-4 border ${ACCESS_TYPES[1].bg} border-current ${ACCESS_TYPES[1].color.replace('text-', 'border-')}`}>
<label className="block text-sm font-medium mb-2">关联实例</label> <div className="flex items-center gap-2 mb-3">
<Transfer <PenLine className="w-5 h-5" />
title="已关联实例" <span className="font-medium">写入变更</span>
items={transferItems} <span className="text-xs opacity-70">— 数据变更工单的实例</span>
selected={formInstanceIds} </div>
onChange={setFormInstanceIds} <Transfer title="写入实例" items={transferItems} selected={writeInstanceIds} onChange={setWriteInstanceIds} />
/>
</div> </div>
<div className="flex justify-end gap-3 pt-2"> <div className="flex justify-end gap-3 pt-2">
<button onClick={() => { setShowCreateModal(false); setShowEditModal(false); resetForm(); }} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors">取消</button> <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={showCreateModal ? handleCreate : handleEdit} 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">{showCreateModal ? '创建' : '保存'}</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>
</div> </div>
</div> </div>
)} )}
{/* User Modal */} {/* ═══════ 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()}>