diff --git a/backend/app/models/instance_group.py b/backend/app/models/instance_group.py index 61da159..61d0119 100644 --- a/backend/app/models/instance_group.py +++ b/backend/app/models/instance_group.py @@ -26,7 +26,7 @@ class InstanceGroup(Base): description = Column(Text, nullable=True) db_type = Column(String(20), nullable=False, default="all") # mysql/redis/pg/mongo/all permissions = Column(Text, nullable=True) # JSON: ["query:select", ...] (legacy, kept for compat) - access_type = Column(String(20), nullable=True, default="readonly") # readonly / datachange / ddl + access_type = Column(String(20), nullable=True, default="readonly") # readonly / write allowed_databases = Column(Text, nullable=True) # JSON array of database names; empty = all databases is_active = Column(Boolean, default=True) created_at = Column(DateTime, server_default=func.now()) @@ -65,4 +65,4 @@ class InstanceGroup(Base): json.dumps(databases, ensure_ascii=False) if databases else None ) - VALID_ACCESS_TYPES = {"readonly", "datachange", "ddl"} + VALID_ACCESS_TYPES = {"readonly", "write"} diff --git a/backend/app/schemas/instance_group.py b/backend/app/schemas/instance_group.py index f763654..ed79b56 100644 --- a/backend/app/schemas/instance_group.py +++ b/backend/app/schemas/instance_group.py @@ -9,8 +9,8 @@ class InstanceGroupCreate(BaseModel): db_type: str = Field(default="all", pattern=r"^(mysql|redis|pg|mongo|all)$") access_type: str = Field( default="readonly", - pattern=r"^(readonly|datachange|ddl)$", - description="权限组类型: readonly(只读查询) / datachange(数据变更) / ddl(DDL变更)", + pattern=r"^(readonly|write)$", + description="权限组类型: readonly(只读查看) / write(写入变更)", ) allowed_databases: Optional[List[str]] = Field( default=None, @@ -27,8 +27,8 @@ class InstanceGroupUpdate(BaseModel): db_type: Optional[str] = Field(default=None, pattern=r"^(mysql|redis|pg|mongo|all)$") access_type: Optional[str] = Field( default=None, - pattern=r"^(readonly|datachange|ddl)$", - description="权限组类型: readonly(只读查询) / datachange(数据变更) / ddl(DDL变更)", + pattern=r"^(readonly|write)$", + description="权限组类型: readonly(只读查看) / write(写入变更)", ) allowed_databases: Optional[List[str]] = Field( default=None, diff --git a/backend/app/services/role_permissions.py b/backend/app/services/role_permissions.py index c203397..ae73a02 100644 --- a/backend/app/services/role_permissions.py +++ b/backend/app/services/role_permissions.py @@ -4,13 +4,17 @@ - admin/dba/auditor 权限由角色硬编码决定,不需要查权限组 - developer 权限由关联的权限组决定 - 工单操作权限完全由角色决定 + +权限组类型 (access_type): +- readonly: 只读查看 — 关联只读库/从库,用于查询数据 +- write: 写入变更 — 关联读写库/主库,用于数据变更工单 """ from __future__ import annotations import json import logging -from typing import Dict, List, Optional, Set +from typing import Dict, List, Optional from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -32,40 +36,30 @@ logger = logging.getLogger(__name__) ROLE_PERMISSIONS: Dict[str, dict] = { "admin": { "readonly": "all", - "datachange": "all", - "ddl": "all", + "write": "all", "ticket": ["create", "approve", "reject", "execute", "cancel"], }, "dba": { "readonly": "all", - "datachange": "all", - "ddl": "all", + "write": "all", "ticket": ["approve", "reject", "execute"], }, "auditor": { "readonly": "all", - "datachange": None, - "ddl": None, + "write": None, "ticket": ["approve", "reject"], }, "developer": { "readonly": "groups", - "datachange": "groups", - "ddl": "groups", + "write": "groups", "ticket": ["create", "cancel_own"], }, - "guest": { - "readonly": None, - "datachange": None, - "ddl": None, - "ticket": [], - }, } def _get_role_config(role: str) -> dict: - """获取角色配置,未知角色按 guest 处理。""" - return ROLE_PERMISSIONS.get(role, ROLE_PERMISSIONS["guest"]) + """获取角色配置,未知角色按最小权限处理。""" + return ROLE_PERMISSIONS.get(role, {"readonly": None, "write": None, "ticket": []}) # ============================================================ @@ -74,48 +68,23 @@ def _get_role_config(role: str) -> dict: async def get_user_permissions(db: AsyncSession, user: User) -> dict: - """返回用户的实际权限概览。 - - Returns: - { - "role": "developer", - "access": { - "readonly": "all" | "groups" | None, - "datachange": "all" | "groups" | None, - "ddl": "all" | "groups" | None, - }, - "ticket": ["create", ...], - "groups": [ # 仅 developer 类型有详情 - { - "group_id": 1, - "group_name": "xxx", - "access_type": "readonly", - "instance_ids": [1, 2], - "allowed_databases": ["db1", "db2"], - }, - ... - ], - } - """ + """返回用户的实际权限概览。""" role_config = _get_role_config(user.role) result = { "role": user.role, "access": { - "readonly": role_config["readonly"], - "datachange": role_config["datachange"], - "ddl": role_config["ddl"], + "readonly": role_config.get("readonly"), + "write": role_config.get("write"), }, - "ticket": role_config["ticket"], + "ticket": role_config.get("ticket", []), "groups": [], } - # 如果角色权限由权限组决定,查询用户关联的权限组 has_group_access = any( - v == "groups" for v in role_config.values() if isinstance(v, str) + v == "groups" for k, v in role_config.items() if k in ("readonly", "write") ) if has_group_access: - groups_detail = await _get_user_groups_detail(db, user) - result["groups"] = groups_detail + result["groups"] = await _get_user_groups_detail(db, user) return result @@ -130,30 +99,18 @@ async def can_access_database( """判断用户是否可以对指定实例的指定库执行指定类型的操作。 Args: - db: 数据库会话 - user: 用户对象 - instance_id: 实例 ID - database: 数据库名 - access_type: "readonly" / "datachange" / "ddl" - - Returns: - True 如果允许 + access_type: "readonly" / "write" """ - if access_type not in ("readonly", "datachange", "ddl"): + if access_type not in ("readonly", "write"): return False role_config = _get_role_config(user.role) access_value = role_config.get(access_type) - # "all" — 全部实例全部库 if access_value == "all": return True - - # None — 无权限 if access_value is None: return False - - # "groups" — 由权限组决定 if access_value == "groups": return await _check_group_access(db, user, instance_id, database, access_type) @@ -161,48 +118,55 @@ async def can_access_database( def can_ticket_action(user: User, action: str) -> bool: - """判断用户是否可以执行指定工单操作(纯角色判断,不需要查 DB)。 - - Args: - user: 用户对象 - action: "create" / "approve" / "reject" / "execute" / "cancel" / "cancel_own" - - Returns: - True 如果允许 - """ + """判断用户是否可以执行指定工单操作。""" role_config = _get_role_config(user.role) - ticket_perms = role_config.get("ticket", []) - - # cancel_own 是一个特殊权限,caller 需要额外验证工单申请人 - return action in ticket_perms + return action in role_config.get("ticket", []) async def get_accessible_instances( db: AsyncSession, user: User, + access_type: str = "readonly", ) -> List[Instance]: - """获取用户可访问的实例列表。 + """获取用户可访问的实例列表(按 access_type 过滤)。 - - admin/dba/auditor(readonly=all): 返回所有实例 + - admin/dba: 返回所有实例 + - auditor: readonly 返回所有,write 返回空 - developer: 返回其权限组关联的实例 """ role_config = _get_role_config(user.role) + access_value = role_config.get(access_type) - # 判断是否有 "all" 权限(至少有一个 access_type 为 all) - has_all = any(v == "all" for v in role_config.values() if isinstance(v, str)) - if has_all: + if access_value == "all": result = await db.execute(select(Instance)) return list(result.scalars().all()) - # developer: 查询权限组关联的实例 + if access_value is None: + return [] + + # "groups" — 查询权限组关联的实例 group_ids = await _get_user_group_ids(db, user) if not group_ids: return [] + # 只返回匹配 access_type 的权限组关联的实例 result = await db.execute( - select(Instance).where(Instance.group_id.in_(group_ids)) + select(InstanceGroup).where(InstanceGroup.id.in_(group_ids)) ) - return list(result.scalars().all()) + groups = result.scalars().all() + matched_instance_ids: set = set() + for g in groups: + if g.access_type == access_type: + for inst in (g.instances or []): + matched_instance_ids.add(inst.id) + + if not matched_instance_ids: + return [] + + inst_result = await db.execute( + select(Instance).where(Instance.id.in_(matched_instance_ids)) + ) + return list(inst_result.scalars().all()) # ============================================================ @@ -250,7 +214,6 @@ async def _check_group_access( access_type: str, ) -> bool: """检查用户通过权限组是否能访问指定实例+库。""" - # 获取实例信息 inst_result = await db.execute( select(Instance).where(Instance.id == instance_id) ) @@ -259,15 +222,12 @@ async def _check_group_access( return False if instance.group_id is None: - # 公开实例,任何已登录用户都可以 readonly return access_type == "readonly" - # 检查用户是否属于该实例所在的权限组 group_ids = await _get_user_group_ids(db, user) if instance.group_id not in group_ids: return False - # 获取权限组 group_result = await db.execute( select(InstanceGroup).where(InstanceGroup.id == instance.group_id) ) @@ -275,14 +235,16 @@ async def _check_group_access( if not group: return False - # 检查 access_type 是否匹配 - if group.access_type != access_type: + # readonly 权限组也可以被 write 请求使用(读+写包含只读) + if access_type == "write" and group.access_type == "readonly": return False - # 检查 allowed_databases + # readonly 请求可以用 readonly 或 write 权限组 + if access_type == "readonly" and group.access_type in ("readonly", "write"): + pass # ok + allowed_dbs = group.get_allowed_databases() if not allowed_dbs: - # 空列表 = 该实例所有库 return True return database in allowed_dbs diff --git a/frontend/src/api/instanceGroup.ts b/frontend/src/api/instanceGroup.ts index cbec4e2..89ff688 100644 --- a/frontend/src/api/instanceGroup.ts +++ b/frontend/src/api/instanceGroup.ts @@ -6,7 +6,7 @@ export interface InstanceGroup { description?: string; db_type: string; permissions?: string[]; - access_type?: 'readonly' | 'datachange' | 'ddl'; + access_type?: 'readonly' | 'write'; allowed_databases?: string[]; is_active: boolean; created_at?: string; diff --git a/frontend/src/components/EditInstanceDialog.tsx b/frontend/src/components/EditInstanceDialog.tsx index 9a28a7e..a63f246 100644 --- a/frontend/src/components/EditInstanceDialog.tsx +++ b/frontend/src/components/EditInstanceDialog.tsx @@ -1,6 +1,7 @@ import { useState, useCallback, useEffect } from 'react'; import { X, Loader2 } from 'lucide-react'; import { instanceApi } from '@/api/instance'; +import { instanceGroupApi } from '@/api/instanceGroup'; /* ------------------------------------------------------------------ */ /* Types */ @@ -15,6 +16,13 @@ interface InstanceData { database: string; env: string; access_mode: string; + group_id?: number | null; +} + +interface PermGroup { + id: number; + name: string; + access_type?: string; } interface Props { diff --git a/frontend/src/pages/InstanceGroupPage.tsx b/frontend/src/pages/InstanceGroupPage.tsx index 335b06e..57fa005 100644 --- a/frontend/src/pages/InstanceGroupPage.tsx +++ b/frontend/src/pages/InstanceGroupPage.tsx @@ -1,7 +1,8 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { Plus, Search, Edit2, Trash2, Server, Users, ChevronRight, X, - Shield, Eye, PenLine, Hammer, Database, + Eye, PenLine, ChevronLeft, ChevronRight as ChevRight, + Database, UserPlus, XCircle, } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; import { instanceGroupApi, type InstanceGroup } from '@/api/instanceGroup'; @@ -14,43 +15,139 @@ import { useActionPermission } from '@/hooks/useAuth'; const ACCESS_TYPES = [ { value: 'readonly', - label: '只读查询', - desc: 'SELECT', - color: 'bg-emerald-500/15 text-emerald-400', + label: '只读查看', + desc: '关联只读/从库 → 用于查询数据', + color: 'text-emerald-400', + bg: 'bg-emerald-500/15', icon: Eye, }, { - value: 'datachange', - label: '数据变更', - desc: 'INSERT / UPDATE / DELETE', - color: 'bg-amber-500/15 text-amber-400', + value: 'write', + label: '写入变更', + desc: '关联读写/主库 → 用于数据变更工单', + color: 'text-amber-400', + bg: 'bg-amber-500/15', icon: PenLine, }, - { - value: 'ddl', - label: 'DDL 变更', - desc: 'CREATE / ALTER / DROP / INDEX', - color: 'bg-red-500/15 text-red-400', - icon: Hammer, - }, ] as const; -const DB_TYPE_OPTIONS = [ - { value: 'all', label: '全部类型' }, - { value: 'mysql', label: 'MySQL' }, - { value: 'pg', label: 'PostgreSQL' }, - { value: 'redis', label: 'Redis' }, - { value: 'mongo', label: 'MongoDB' }, -]; - const DB_TYPE_COLORS: Record = { mysql: 'bg-[rgba(0,117,143,0.15)] text-[#26a69a]', pg: 'bg-[rgba(51,103,145,0.15)] text-[#42a5f5]', redis: 'bg-[rgba(220,50,32,0.15)] text-[#ef5350]', mongo: 'bg-[rgba(71,162,72,0.15)] text-[#66bb6a]', - all: 'bg-[rgba(148,163,184,0.15)] text-[var(--dg-text-secondary)]', }; +/* ───────── Transfer Component ───────── */ + +interface TransferProps { + title: string; + items: { label: string; value: string; extra?: string }[]; + selected: string[]; + onChange: (selected: string[]) => void; +} + +function Transfer({ title, items, selected, onChange }: TransferProps) { + const [search, setSearch] = useState(''); + + const unselected = items.filter( + i => !selected.includes(i.value) && (i.label.toLowerCase().includes(search.toLowerCase()) || i.value.toLowerCase().includes(search.toLowerCase())) + ); + const selectedFiltered = items.filter( + i => selected.includes(i.value) && (i.label.toLowerCase().includes(search.toLowerCase()) || i.value.toLowerCase().includes(search.toLowerCase())) + ); + const [pendingAdd, setPendingAdd] = useState>(new Set()); + const [pendingRemove, setPendingRemove] = useState>(new Set()); + + const addSelected = () => { + const newSelected = [...selected, ...pendingAdd]; + onChange(newSelected); + setPendingAdd(new Set()); + }; + + const removeSelected = () => { + const newSelected = selected.filter(v => !pendingRemove.has(v)); + onChange(newSelected); + setPendingRemove(new Set()); + }; + + const togglePendingAdd = (v: string) => { + setPendingAdd(prev => { + const next = new Set(prev); + if (next.has(v)) next.delete(v); else next.add(v); + return next; + }); + }; + + const togglePendingRemove = (v: string) => { + setPendingRemove(prev => { + const next = new Set(prev); + if (next.has(v)) next.delete(v); else next.add(v); + return next; + }); + }; + + return ( +
+ {/* Left: Available */} +
+
+ 可选数据库 + {unselected.length} 项 +
+
+ setSearch(e.target.value)} + /> +
+
+ {unselected.length === 0 &&
无可用项
} + {unselected.map(item => ( + + ))} +
+
+ + {/* Middle: Arrows */} +
+ + +
+ + {/* Right: Selected */} +
+
+ {title} + {selectedFiltered.length} 项 +
+
+ {selectedFiltered.length === 0 &&
请从左侧选择数据库
} + {selectedFiltered.map(item => ( + + ))} +
+
+
+ ); +} + /* ───────── Main Component ───────── */ export default function InstanceGroupPage() { @@ -60,102 +157,98 @@ export default function InstanceGroupPage() { const [selectedId, setSelectedId] = useState(null); const [showCreateModal, setShowCreateModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); - const [showInstanceModal, setShowInstanceModal] = useState(false); const [showUserModal, setShowUserModal] = useState(false); const canManage = useActionPermission('instance-group.manage'); // Form state const [formName, setFormName] = useState(''); const [formDesc, setFormDesc] = useState(''); - const [formDbType, setFormDbType] = useState('all'); - const [formAccessType, setFormAccessType] = useState('readonly'); - const [formAllowedDbs, setFormAllowedDbs] = useState(''); + const [formAccessType, setFormAccessType] = useState<'readonly' | 'write'>('readonly'); + const [formAllowedDbs, setFormAllowedDbs] = useState([]); const [formIsActive, setFormIsActive] = useState(true); + const [formInstanceIds, setFormInstanceIds] = useState([]); - // Related data - const [allInstances, setAllInstances] = useState([]); + // DB list for transfer + const [allDatabases, setAllDatabases] = useState<{ label: string; value: string; extra?: string }[]>([]); + const [loadingDbs, setLoadingDbs] = useState(false); + + // User assignment const [allUsers, setAllUsers] = useState([]); - const [selectedInstanceIds, setSelectedInstanceIds] = useState([]); const [selectedUserIds, setSelectedUserIds] = useState([]); - const [instanceSearch, setInstanceSearch] = useState(''); const [userSearch, setUserSearch] = useState(''); + // Instance list for form + const [allInstances, setAllInstances] = useState([]); + const selectedGroup = groups.find(g => g.id === selectedId); const fetchGroups = useCallback(async () => { try { const data = await instanceGroupApi.list(); setGroups(data); - } catch { - // silent - } finally { - setLoading(false); - } + } catch { /* */ } finally { setLoading(false); } }, []); useEffect(() => { fetchGroups(); }, [fetchGroups]); + // Load instances when modal opens useEffect(() => { - if (showInstanceModal) { + if (showCreateModal || showEditModal) { instanceApi.list().then(setAllInstances).catch(() => {}); } + }, [showCreateModal, showEditModal]); + + // Load users when user modal opens + useEffect(() => { if (showUserModal) { userApi.list().then((data: any) => { const items = (data as any)?.items || data; setAllUsers(Array.isArray(items) ? items : []); }).catch(() => {}); } - }, [showInstanceModal, showUserModal]); + }, [showUserModal]); - const filteredGroups = useMemo(() => { - return groups.filter(g => !search || g.name.includes(search) || (g.description || '').includes(search)); - }, [groups, search]); + // Load databases for selected instances in form + const loadDatabasesForInstances = useCallback(async (instanceIds: number[]) => { + if (instanceIds.length === 0) { setAllDatabases([]); return; } + setLoadingDbs(true); + try { + const inst = allInstances.find((i: any) => i.id === instanceIds[0]); + if (!inst) { setAllDatabases([]); return; } + const res = await instanceApi.listDatabases(inst.id); + const dbs = (res as any)?.databases || res || []; + setAllDatabases(dbs.map((d: string) => ({ label: d, value: d }))); + } catch { setAllDatabases([]); } finally { setLoadingDbs(false); } + }, [allInstances]); - const filteredInstances = useMemo(() => { - return allInstances.filter(inst => !instanceSearch || inst.name.toLowerCase().includes(instanceSearch.toLowerCase()) || inst.host.includes(instanceSearch)); - }, [allInstances, instanceSearch]); - - const filteredUsers = useMemo(() => { - return allUsers.filter(u => !userSearch || (u.display_name || u.username).toLowerCase().includes(userSearch.toLowerCase())); - }, [allUsers, userSearch]); + const 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(''); - setFormDbType('all'); - setFormAccessType('readonly'); - setFormAllowedDbs(''); - setFormIsActive(true); + setFormName(''); setFormDesc(''); setFormAccessType('readonly'); + setFormAllowedDbs([]); setFormIsActive(true); setFormInstanceIds([]); setAllDatabases([]); }; const handleCreate = async () => { if (!formName.trim()) return; await instanceGroupApi.create({ - name: formName, - description: formDesc || undefined, - db_type: formDbType, + name: formName, description: formDesc || undefined, access_type: formAccessType, - allowed_databases: formAllowedDbs ? formAllowedDbs.split(',').map(s => s.trim()).filter(Boolean) : [], - is_active: formIsActive, + allowed_databases: formAllowedDbs.length > 0 ? formAllowedDbs : undefined, + is_active: formIsActive, db_type: 'all', } as any); - setShowCreateModal(false); - resetForm(); - fetchGroups(); + setShowCreateModal(false); resetForm(); fetchGroups(); }; const handleEdit = async () => { if (!selectedId || !formName.trim()) return; await instanceGroupApi.update(selectedId, { - name: formName, - description: formDesc || undefined, - db_type: formDbType, + name: formName, description: formDesc || undefined, access_type: formAccessType, - allowed_databases: formAllowedDbs ? formAllowedDbs.split(',').map(s => s.trim()).filter(Boolean) : [], - is_active: formIsActive, + allowed_databases: formAllowedDbs.length > 0 ? formAllowedDbs : undefined, + is_active: formIsActive, db_type: 'all', } as any); - setShowEditModal(false); - resetForm(); - fetchGroups(); + setShowEditModal(false); resetForm(); fetchGroups(); }; const handleDelete = async (id: number) => { @@ -168,30 +261,23 @@ export default function InstanceGroupPage() { const openEditModal = (group: InstanceGroup) => { setFormName(group.name); setFormDesc(group.description || ''); - setFormDbType(group.db_type); - setFormAccessType((group as any).access_type || 'readonly'); - setFormAllowedDbs(((group as any).allowed_databases || []).join(', ')); + setFormAccessType(((group as any).access_type === 'write' ? 'write' : 'readonly')); + setFormAllowedDbs((group as any).allowed_databases || []); setFormIsActive(group.is_active); + // Load instance IDs + const instIds = (group.instances || []).map(i => i.id); + setFormInstanceIds(instIds); + if (instIds.length > 0) loadDatabasesForInstances(instIds); setShowEditModal(true); }; - const handleAssignInstances = async () => { - if (!selectedId || selectedInstanceIds.length === 0) return; - await instanceGroupApi.assignInstances(selectedId, selectedInstanceIds); - setShowInstanceModal(false); - setSelectedInstanceIds([]); - fetchGroups(); - }; - const handleAssignUsers = async () => { if (!selectedId || selectedUserIds.length === 0) return; await instanceGroupApi.assignUsers(selectedId, selectedUserIds); - setShowUserModal(false); - setSelectedUserIds([]); - fetchGroups(); + setShowUserModal(false); setSelectedUserIds([]); fetchGroups(); }; - const getAccessTypeConfig = (type: string) => ACCESS_TYPES.find(t => t.value === type) || ACCESS_TYPES[0]; + const getAtConfig = (type: string) => ACCESS_TYPES.find(t => t.value === type) || ACCESS_TYPES[0]; if (loading) return
加载中...
; @@ -201,7 +287,7 @@ export default function InstanceGroupPage() {

数据库权限组

-

按访问类型分组,关联数据源和用户

+

只读查看 → 查询数据 | 写入变更 → 数据变更工单

{canManage && (
- {/* Quick stats */} -
+ {/* 2 permission type cards */} +
{ACCESS_TYPES.map(at => { const count = groups.filter(g => (g as any).access_type === at.value).length; const Icon = at.icon; return ( - -
- + +
+
-
-
{count}
-
{at.label}权限组
+
+
{at.label}
+
{at.desc}
+
{count}
); @@ -233,31 +320,26 @@ export default function InstanceGroupPage() {
{/* Left list */} -
+
- setSearch(e.target.value)} /> + setSearch(e.target.value)} />
-
- {filteredGroups.length === 0 && ( -
暂无权限组
- )} +
+ {filteredGroups.length === 0 &&
暂无权限组
} {filteredGroups.map(group => { - const atConfig = getAccessTypeConfig((group as any).access_type || 'readonly'); - const ATIcon = atConfig.icon; + const at = getAtConfig((group as any).access_type || 'readonly'); + const Icon = at.icon; return ( -
setSelectedId(group.id)} className={`p-3.5 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)]'}`}> -
-
- - {group.name} -
- {!group.is_active && 禁用} +
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)]'}`}> +
+ + {group.name}
-
- {atConfig.label} - {group.instance_count || 0} - {group.user_count || 0} +
+ {at.label} + {((group as any).allowed_databases || []).length || '全部'}库 + {group.user_count || 0}
); @@ -269,32 +351,22 @@ export default function InstanceGroupPage() {
{selectedGroup ? (() => { const sg = selectedGroup; - const atConfig = getAccessTypeConfig((sg as any).access_type || 'readonly'); - const ATIcon = atConfig.icon; + const at = getAtConfig((sg as any).access_type || 'readonly'); + const Icon = at.icon; const allowedDbs: string[] = (sg as any).allowed_databases || []; return (
- {/* Info Card */}
- +

{sg.name}

- {atConfig.label} - {DB_TYPE_OPTIONS.find(o => o.value === sg.db_type)?.label} + {at.label} {!sg.is_active && 禁用}
{sg.description &&

{sg.description}

} -
- 允许操作: {atConfig.desc} -
- {allowedDbs.length > 0 && ( -
- 限定数据库: {allowedDbs.map(d => {d})} -
- )}
{canManage && (
@@ -306,33 +378,40 @@ export default function InstanceGroupPage() { + {/* Authorized databases */} + + +

授权数据库 {allowedDbs.length > 0 ? `(${allowedDbs.length})` : '(全部)'}

+ {allowedDbs.length > 0 ? ( +
+ {allowedDbs.map(db => {db})} +
+ ) : ( +
已授权该数据源下所有数据库
+ )} +
+
+ {/* Related Instances */}

关联数据源 ({sg.instances?.length || 0})

- {canManage && ( - - )}
{sg.instances && sg.instances.length > 0 ? ( -
+
{sg.instances.map(inst => (
-
- - {inst.name} - {inst.host}:{inst.port} +
+ + {inst.name} + {inst.host}:{inst.port}
- {canManage && ( - - )} + {inst.db_type}
))}
- ) : ( -
暂未关联数据源
- )} + ) :
暂未关联数据源
} @@ -341,25 +420,19 @@ export default function InstanceGroupPage() {

关联用户 ({sg.users?.length || 0})

- {canManage && ( - - )} + {canManage && }
{sg.users && sg.users.length > 0 ? (
{sg.users.map(u => ( -
-
-
{(u.display_name || u.username).charAt(0)}
- {u.display_name || u.username} - {u.role} -
+
+
{(u.display_name || u.username).charAt(0)}
+ {u.display_name || u.username} + {u.role}
))}
- ) : ( -
暂未关联用户
- )} + ) :
暂未关联用户
}
@@ -376,23 +449,18 @@ export default function InstanceGroupPage() { {/* Create/Edit Modal */} {(showCreateModal || showEditModal) && (
-
e.stopPropagation()}> -

{showCreateModal ? '新建权限组' : '编辑权限组'}

-
+
e.stopPropagation()}> +

{showCreateModal ? '新建权限组' : '编辑权限组'}

+
+ {/* Name */}
- setFormName(e.target.value)} placeholder="权限组名称" /> -
-
- -