- ModalOverlay移除遮罩层onClick(不响应外部点击关闭) - 移除body.style.overflow=hidden(修复白条问题) - max-h从90vh改为85vh(与EditInstanceDialog一致) - 10个页面手写fixed弹框替换为ModalOverlay组件 - InstanceGroupPage/WorkflowPage/UsersPage/RedisDetailPage/TicketsPage已替换 - dialog.tsx添加onPointerDownOutside/onInteractOutside阻止外部点击 - index.css添加body[data-scroll-locked]覆盖Radix滚动锁定 - Layout.tsx main添加relative定位上下文
483 lines
27 KiB
TypeScript
483 lines
27 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
import {
|
|
Plus, Search, Edit2, Trash2, Server, Users, ChevronRight,
|
|
Eye, PenLine, ChevronLeft, ChevronRight as ChevRight, X,
|
|
} 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';
|
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
|
import { ModalOverlay } from '@/components/ModalOverlay';
|
|
|
|
/* ───────── Constants ───────── */
|
|
|
|
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]',
|
|
postgres: 'bg-[rgba(51,103,145,0.15)] text-[#42a5f5]',
|
|
redis: 'bg-[rgba(220,50,32,0.15)] text-[#ef5350]',
|
|
mongo: 'bg-[rgba(71,162,72,0.15)] text-[#66bb6a]',
|
|
};
|
|
|
|
/* ───────── Transfer Component ───────── */
|
|
|
|
interface TransferItem { label: string; value: string; tag?: string; tagColor?: string; accessMode?: string }
|
|
|
|
interface TransferProps {
|
|
title: string;
|
|
items: TransferItem[];
|
|
selected: string[];
|
|
onChange: (v: string[]) => void;
|
|
emptyText?: string;
|
|
}
|
|
|
|
function Transfer({ title, items, selected, onChange, emptyText = '请从左侧选择实例' }: TransferProps) {
|
|
const [search, setSearch] = useState('');
|
|
const [pendingAdd, setPendingAdd] = useState<Set<string>>(new Set());
|
|
const [pendingRemove, setPendingRemove] = useState<Set<string>>(new Set());
|
|
|
|
const unselected = items.filter(i =>
|
|
!selected.includes(i.value) && (i.label.toLowerCase().includes(search.toLowerCase()) || i.value.includes(search))
|
|
);
|
|
const selectedFiltered = items.filter(i =>
|
|
selected.includes(i.value) && (i.label.toLowerCase().includes(search.toLowerCase()) || i.value.includes(search))
|
|
);
|
|
|
|
const addSelected = () => { onChange([...selected, ...pendingAdd]); setPendingAdd(new Set()); };
|
|
const removeSelected = () => { onChange(selected.filter(v => !pendingRemove.has(v))); setPendingRemove(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 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)]'}`;
|
|
|
|
return (
|
|
<div className="flex gap-3 items-stretch">
|
|
<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">
|
|
<span className="text-xs font-medium">可选实例</span>
|
|
<span className="text-xs text-[var(--dg-text-muted)]">{unselected.length}</span>
|
|
</div>
|
|
<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"
|
|
placeholder="搜索..." value={search} onChange={e => setSearch(e.target.value)} />
|
|
</div>
|
|
<div className="max-h-40 overflow-y-auto px-2 pb-2 space-y-0.5">
|
|
{unselected.length === 0 && <div className="text-center py-3 text-xs text-[var(--dg-text-muted)]">无可用项</div>}
|
|
{unselected.map(item => (
|
|
<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" />
|
|
<Server className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
|
|
<span className="truncate">{item.label}</span>
|
|
{item.tag && <span className={`ml-auto text-[10px] px-1 py-0.5 rounded ${item.tagColor || ''}`}>{item.tag}</span>}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col justify-center gap-2">
|
|
<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 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">
|
|
<span className="text-xs font-medium">{title}</span>
|
|
<span className="text-xs text-[var(--dg-text-muted)]">{selectedFiltered.length}</span>
|
|
</div>
|
|
<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)]">{emptyText}</div>}
|
|
{selectedFiltered.map(item => (
|
|
<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" />
|
|
<Server className="w-3 h-3 text-emerald-400 flex-shrink-0" />
|
|
<span className="truncate">{item.label}</span>
|
|
{item.tag && <span className={`ml-auto text-[10px] px-1 py-0.5 rounded ${item.tagColor || ''}`}>{item.tag}</span>}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ───────── Main ───────── */
|
|
|
|
export default function InstanceGroupPage() {
|
|
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 [showUserModal, setShowUserModal] = useState(false);
|
|
const [deleteConfirm, setDeleteConfirm] = useState<{msg: string; onConfirm: () => void} | null>(null);
|
|
const canManage = useActionPermission('instance-group.manage');
|
|
|
|
// Form state — shared by create & edit
|
|
const [formName, setFormName] = useState('');
|
|
const [formDesc, setFormDesc] = useState('');
|
|
const [formIsActive, setFormIsActive] = useState(true);
|
|
const [readonlyIds, setReadonlyIds] = useState<string[]>([]);
|
|
const [writeIds, setWriteIds] = useState<string[]>([]);
|
|
|
|
const [allInstances, setAllInstances] = useState<any[]>([]);
|
|
const [allUsers, setAllUsers] = useState<any[]>([]);
|
|
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
|
|
const [userSearch, setUserSearch] = useState('');
|
|
|
|
const fetchGroups = useCallback(async () => {
|
|
try { setGroups(await instanceGroupApi.list()); } catch {} finally { setLoading(false); }
|
|
}, []);
|
|
|
|
useEffect(() => { fetchGroups(); }, [fetchGroups]);
|
|
|
|
useEffect(() => {
|
|
if (showCreateModal || showEditModal) {
|
|
instanceApi.list().then((res: any) => {
|
|
setAllInstances(Array.isArray(res) ? res : (res?.items || []));
|
|
}).catch(() => {});
|
|
}
|
|
}, [showCreateModal, showEditModal]);
|
|
|
|
useEffect(() => {
|
|
if (showUserModal) {
|
|
userApi.list().then((d: any) => {
|
|
setAllUsers(Array.isArray(d?.items || d) ? (d?.items || d) : []);
|
|
}).catch(() => {});
|
|
}
|
|
}, [showUserModal]);
|
|
|
|
const transferItems = useMemo(() =>
|
|
allInstances.map((i: any) => ({
|
|
label: `${i.name} (${i.host}:${i.port})`,
|
|
value: String(i.id),
|
|
tag: i.db_type,
|
|
tagColor: DB_TYPE_COLORS[i.db_type] || '',
|
|
accessMode: i.access_mode || 'readwrite',
|
|
})),
|
|
[allInstances]
|
|
);
|
|
|
|
// 查询授权:只匹配 read + readwrite 实例
|
|
const readonlyTransferItems = useMemo(() =>
|
|
transferItems.filter(i => i.accessMode === 'read' || i.accessMode === 'readwrite'),
|
|
[transferItems]
|
|
);
|
|
|
|
// 变更授权:只匹配 write + readwrite 实例
|
|
const writeTransferItems = useMemo(() =>
|
|
transferItems.filter(i => i.accessMode === 'write' || i.accessMode === 'readwrite'),
|
|
[transferItems]
|
|
);
|
|
|
|
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 resetForm = () => {
|
|
setFormName(''); setFormDesc(''); setFormIsActive(true);
|
|
setReadonlyIds([]); setWriteIds([]);
|
|
};
|
|
|
|
/* ── Create ── */
|
|
const handleCreate = async () => {
|
|
if (!formName.trim()) return;
|
|
await instanceGroupApi.create({
|
|
name: formName.trim(),
|
|
description: formDesc.trim() || undefined,
|
|
db_type: 'all',
|
|
is_active: formIsActive,
|
|
readonly_instance_ids: readonlyIds.map(Number),
|
|
write_instance_ids: writeIds.map(Number),
|
|
});
|
|
setShowCreateModal(false); resetForm(); fetchGroups();
|
|
};
|
|
|
|
/* ── Edit ── */
|
|
const openEditModal = (group: InstanceGroup) => {
|
|
setFormName(group.name);
|
|
setFormDesc(group.description || '');
|
|
setFormIsActive(group.is_active);
|
|
setReadonlyIds((group.readonly_instance_ids || []).map((i: any) => String(typeof i === 'object' ? i.id : i)));
|
|
setWriteIds((group.write_instance_ids || []).map((i: any) => String(typeof i === 'object' ? i.id : i)));
|
|
setSelectedId(group.id);
|
|
setShowEditModal(true);
|
|
};
|
|
|
|
const handleEdit = async () => {
|
|
if (!selectedId || !formName.trim()) return;
|
|
await instanceGroupApi.update(selectedId, {
|
|
name: formName.trim(),
|
|
description: formDesc.trim() || undefined,
|
|
is_active: formIsActive,
|
|
readonly_instance_ids: readonlyIds.map(Number),
|
|
write_instance_ids: writeIds.map(Number),
|
|
});
|
|
setShowEditModal(false); resetForm(); fetchGroups();
|
|
};
|
|
|
|
/* ── Delete ── */
|
|
const handleDelete = async (id: number) => {
|
|
setDeleteConfirm({msg: '确定删除该权限组?', onConfirm: async () => {
|
|
await instanceGroupApi.delete(id);
|
|
if (selectedId === id) setSelectedId(null);
|
|
fetchGroups();
|
|
}});
|
|
};
|
|
|
|
/* ── Assign Users ── */
|
|
const handleAssignUsers = async () => {
|
|
if (!selectedId || selectedUserIds.length === 0) return;
|
|
await instanceGroupApi.assignUsers(selectedId, selectedUserIds);
|
|
setShowUserModal(false); setSelectedUserIds([]); fetchGroups();
|
|
};
|
|
|
|
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>;
|
|
|
|
/* ── 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 close/submit ── */
|
|
const formClose = () => { setShowCreateModal(false); setShowEditModal(false); resetForm(); };
|
|
const formMode = showCreateModal ? 'create' as const : showEditModal ? 'edit' as const : null;
|
|
const formSubmit = formMode === 'create' ? handleCreate : handleEdit;
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold">数据库权限组</h1>
|
|
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">每个策略包含「查询授权」和「变更授权」两类实例</p>
|
|
</div>
|
|
{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">
|
|
<Plus className="w-4 h-4" /> 新建权限策略
|
|
</button>
|
|
)}
|
|
</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)]" />
|
|
<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 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.map(group => {
|
|
const ro = group.readonly_instance_count || 0;
|
|
const wr = group.write_instance_count || 0;
|
|
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 className="flex items-center gap-2">
|
|
<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 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 bg-emerald-500/10 text-emerald-400 text-[10px]">{ro} 只读</span>
|
|
<span className="px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-400 text-[10px]">{wr} 写入</span>
|
|
<span className="ml-auto"><Users className="w-3 h-3 inline mr-0.5" />{group.user_count || 0}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right detail */}
|
|
<div className="flex-1 min-w-0">
|
|
{selectedGroup ? (() => {
|
|
const sg = selectedGroup;
|
|
const roInst = sg.readonly_instances || [];
|
|
const wrInst = sg.write_instances || [];
|
|
const roCount = sg.readonly_instance_count ?? (sg.readonly_instance_ids || []).length;
|
|
const wrCount = sg.write_instance_count ?? (sg.write_instance_ids || []).length;
|
|
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>
|
|
{!sg.is_active && <span className="px-2 py-0.5 text-xs rounded bg-red-500/15 text-red-400">禁用</span>}
|
|
</div>
|
|
{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">{roCount} 只读实例</span>
|
|
<span className="px-2 py-0.5 text-xs rounded bg-amber-500/15 text-amber-400">{wrCount} 写入实例</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>
|
|
{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>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Readonly instances */}
|
|
<Card className="dg-card">
|
|
<CardContent className="p-5">
|
|
<h3 className="text-sm font-semibold flex items-center gap-2 mb-3">
|
|
<Eye className="w-4 h-4 text-emerald-400" />查询授权 ({roInst.length})
|
|
</h3>
|
|
<InstanceList instances={roInst} />
|
|
</CardContent>
|
|
</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">
|
|
<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">添加用户</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 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>
|
|
<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 className="text-sm text-[var(--dg-text-muted)]">暂未关联用户</div>}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
})() : (
|
|
<div className="flex flex-col items-center justify-center h-96 text-[var(--dg-text-muted)]">
|
|
<ChevronRight className="w-8 h-8 mb-2 rotate-90 opacity-50" />
|
|
<p className="text-sm">选择左侧权限组查看详情</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Form Modal (create & edit) */}
|
|
{formMode && (
|
|
<ModalOverlay open={!!formMode} onClose={formClose} width="max-w-3xl">
|
|
<div className="w-full max-w-3xl bg-[var(--dg-bg-primary)] rounded-xl shadow-2xl flex flex-col max-h-[90vh] border border-[var(--dg-border)]">
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--dg-border)]">
|
|
<h3 className="text-lg font-semibold">{formMode === 'create' ? '新建权限策略' : '编辑权限策略'}</h3>
|
|
<button onClick={formClose} className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"><X className="w-4 h-4" /></button>
|
|
</div>
|
|
<div className="overflow-y-auto px-6 py-5 space-y-5 flex-1">
|
|
<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>
|
|
|
|
<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={readonlyTransferItems} selected={readonlyIds} onChange={setReadonlyIds} emptyText="选择用于查询的实例" />
|
|
</div>
|
|
|
|
<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={writeTransferItems} selected={writeIds} onChange={setWriteIds} emptyText="选择用于变更的实例" />
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 pt-2">
|
|
<button onClick={formClose} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors rounded-lg hover:bg-[var(--dg-bg-hover)]">取消</button>
|
|
<button onClick={formSubmit} disabled={!formName.trim()} className="dg-btn dg-btn-primary disabled:opacity-50">
|
|
{formMode === 'create' ? '创建' : '保存'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ModalOverlay>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
open={!!deleteConfirm}
|
|
title={deleteConfirm?.msg || '确认操作'}
|
|
variant="danger"
|
|
confirmText="确定"
|
|
onConfirm={() => { deleteConfirm?.onConfirm(); setDeleteConfirm(null); }}
|
|
onCancel={() => setDeleteConfirm(null)}
|
|
/>
|
|
|
|
{/* User Modal */}
|
|
{showUserModal && (
|
|
<ModalOverlay open={showUserModal} onClose={() => setShowUserModal(false)} width="max-w-md">
|
|
<div className="w-full max-w-md bg-[var(--dg-bg-primary)] rounded-xl shadow-2xl border border-[var(--dg-border)]">
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--dg-border)]">
|
|
<h3 className="text-lg font-semibold">关联用户</h3>
|
|
<button onClick={() => setShowUserModal(false)} className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"><X className="w-4 h-4" /></button>
|
|
</div>
|
|
<div className="px-6 py-4">
|
|
<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">
|
|
{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>
|
|
<span className="text-sm">{u.display_name || u.username}</span>
|
|
<span className="text-xs text-[var(--dg-text-muted)]">{u.role}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
<div className="flex justify-end gap-3">
|
|
<button onClick={() => setShowUserModal(false)} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors rounded-lg hover:bg-[var(--dg-bg-hover)]">取消</button>
|
|
<button onClick={handleAssignUsers} disabled={selectedUserIds.length === 0} className="dg-btn dg-btn-primary disabled:opacity-50">确认 ({selectedUserIds.length})</button>
|
|
</div>
|
|
</div>{/* end px-6 py-4 */}
|
|
</div>
|
|
</ModalOverlay>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|