refactor: 权限组简化为2组(readonly/write) + Transfer穿梭框选择数据库

- access_type 从3值(readonly/datachange/ddl)简化为2值(readonly/write)
- readonly=只读查看(关联只读/从库用于查询) write=写入变更(关联主库用于工单)
- 前端InstanceGroupPage重写: 2权限类型卡片+自定义Transfer穿梭框组件
- Transfer组件: 左侧可选数据库→右侧已授权数据库, 搜索/勾选/箭头移动
- role_permissions.py更新: 2值权限映射, get_accessible_instances按access_type过滤
- 后端schema/模型校验更新为readonly|write正则
- 15个测试全部通过
This commit is contained in:
logan committed 2026-05-24 19:27:04 +08:00
1 parent 8161bcf5b5
commit 4111e1eee8
6 files changed
+345 -309

No files matched your search

+2 -2
View File
@@ -26,7 +26,7 @@ class InstanceGroup(Base):
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
db_type = Column(String(20), nullable=False, default="all") # mysql/redis/pg/mongo/all 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) 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 allowed_databases = Column(Text, nullable=True) # JSON array of database names; empty = all databases
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
created_at = Column(DateTime, server_default=func.now()) 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 json.dumps(databases, ensure_ascii=False) if databases else None
) )
VALID_ACCESS_TYPES = {"readonly", "datachange", "ddl"} VALID_ACCESS_TYPES = {"readonly", "write"}
+4 -4
View File
@@ -9,8 +9,8 @@ class InstanceGroupCreate(BaseModel):
db_type: str = Field(default="all", pattern=r"^(mysql|redis|pg|mongo|all)$") db_type: str = Field(default="all", pattern=r"^(mysql|redis|pg|mongo|all)$")
access_type: str = Field( access_type: str = Field(
default="readonly", default="readonly",
pattern=r"^(readonly|datachange|ddl)$", pattern=r"^(readonly|write)$",
description="权限组类型: readonly(只读查询) / datachange(数据变更) / ddl(DDL变更)", description="权限组类型: readonly(只读查看) / write(写入变更)",
) )
allowed_databases: Optional[List[str]] = Field( allowed_databases: Optional[List[str]] = Field(
default=None, default=None,
@@ -27,8 +27,8 @@ class InstanceGroupUpdate(BaseModel):
db_type: Optional[str] = Field(default=None, pattern=r"^(mysql|redis|pg|mongo|all)$") db_type: Optional[str] = Field(default=None, pattern=r"^(mysql|redis|pg|mongo|all)$")
access_type: Optional[str] = Field( access_type: Optional[str] = Field(
default=None, default=None,
pattern=r"^(readonly|datachange|ddl)$", pattern=r"^(readonly|write)$",
description="权限组类型: readonly(只读查询) / datachange(数据变更) / ddl(DDL变更)", description="权限组类型: readonly(只读查看) / write(写入变更)",
) )
allowed_databases: Optional[List[str]] = Field( allowed_databases: Optional[List[str]] = Field(
default=None, default=None,
+53 -91
View File
@@ -4,13 +4,17 @@
- admin/dba/auditor 权限由角色硬编码决定,不需要查权限组 - admin/dba/auditor 权限由角色硬编码决定,不需要查权限组
- developer 权限由关联的权限组决定 - developer 权限由关联的权限组决定
- 工单操作权限完全由角色决定 - 工单操作权限完全由角色决定
权限组类型 (access_type):
- readonly: 只读查看 — 关联只读库/从库,用于查询数据
- write: 写入变更 — 关联读写库/主库,用于数据变更工单
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import logging import logging
from typing import Dict, List, Optional, Set from typing import Dict, List, Optional
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -32,40 +36,30 @@ logger = logging.getLogger(__name__)
ROLE_PERMISSIONS: Dict[str, dict] = { ROLE_PERMISSIONS: Dict[str, dict] = {
"admin": { "admin": {
"readonly": "all", "readonly": "all",
"datachange": "all", "write": "all",
"ddl": "all",
"ticket": ["create", "approve", "reject", "execute", "cancel"], "ticket": ["create", "approve", "reject", "execute", "cancel"],
}, },
"dba": { "dba": {
"readonly": "all", "readonly": "all",
"datachange": "all", "write": "all",
"ddl": "all",
"ticket": ["approve", "reject", "execute"], "ticket": ["approve", "reject", "execute"],
}, },
"auditor": { "auditor": {
"readonly": "all", "readonly": "all",
"datachange": None, "write": None,
"ddl": None,
"ticket": ["approve", "reject"], "ticket": ["approve", "reject"],
}, },
"developer": { "developer": {
"readonly": "groups", "readonly": "groups",
"datachange": "groups", "write": "groups",
"ddl": "groups",
"ticket": ["create", "cancel_own"], "ticket": ["create", "cancel_own"],
}, },
"guest": {
"readonly": None,
"datachange": None,
"ddl": None,
"ticket": [],
},
} }
def _get_role_config(role: str) -> dict: 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: 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) role_config = _get_role_config(user.role)
result = { result = {
"role": user.role, "role": user.role,
"access": { "access": {
"readonly": role_config["readonly"], "readonly": role_config.get("readonly"),
"datachange": role_config["datachange"], "write": role_config.get("write"),
"ddl": role_config["ddl"],
}, },
"ticket": role_config["ticket"], "ticket": role_config.get("ticket", []),
"groups": [], "groups": [],
} }
# 如果角色权限由权限组决定,查询用户关联的权限组
has_group_access = any( 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: if has_group_access:
groups_detail = await _get_user_groups_detail(db, user) result["groups"] = await _get_user_groups_detail(db, user)
result["groups"] = groups_detail
return result return result
@@ -130,30 +99,18 @@ async def can_access_database(
"""判断用户是否可以对指定实例的指定库执行指定类型的操作。 """判断用户是否可以对指定实例的指定库执行指定类型的操作。
Args: Args:
db: 数据库会话 access_type: "readonly" / "write"
user: 用户对象
instance_id: 实例 ID
database: 数据库名
access_type: "readonly" / "datachange" / "ddl"
Returns:
True 如果允许
""" """
if access_type not in ("readonly", "datachange", "ddl"): if access_type not in ("readonly", "write"):
return False return False
role_config = _get_role_config(user.role) role_config = _get_role_config(user.role)
access_value = role_config.get(access_type) access_value = role_config.get(access_type)
# "all" — 全部实例全部库
if access_value == "all": if access_value == "all":
return True return True
# None — 无权限
if access_value is None: if access_value is None:
return False return False
# "groups" — 由权限组决定
if access_value == "groups": if access_value == "groups":
return await _check_group_access(db, user, instance_id, database, access_type) 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: 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) role_config = _get_role_config(user.role)
ticket_perms = role_config.get("ticket", []) return action in role_config.get("ticket", [])
# cancel_own 是一个特殊权限,caller 需要额外验证工单申请人
return action in ticket_perms
async def get_accessible_instances( async def get_accessible_instances(
db: AsyncSession, db: AsyncSession,
user: User, user: User,
access_type: str = "readonly",
) -> List[Instance]: ) -> List[Instance]:
"""获取用户可访问的实例列表。 """获取用户可访问的实例列表(按 access_type 过滤)。
- admin/dba/auditor(readonly=all): 返回所有实例 - admin/dba: 返回所有实例
- auditor: readonly 返回所有,write 返回空
- developer: 返回其权限组关联的实例 - developer: 返回其权限组关联的实例
""" """
role_config = _get_role_config(user.role) role_config = _get_role_config(user.role)
access_value = role_config.get(access_type)
# 判断是否有 "all" 权限(至少有一个 access_type 为 all) if access_value == "all":
has_all = any(v == "all" for v in role_config.values() if isinstance(v, str))
if has_all:
result = await db.execute(select(Instance)) result = await db.execute(select(Instance))
return list(result.scalars().all()) return list(result.scalars().all())
# developer: 查询权限组关联的实例 if access_value is None:
return []
# "groups" — 查询权限组关联的实例
group_ids = await _get_user_group_ids(db, user) group_ids = await _get_user_group_ids(db, user)
if not group_ids: if not group_ids:
return [] return []
# 只返回匹配 access_type 的权限组关联的实例
result = await db.execute( 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, access_type: str,
) -> bool: ) -> bool:
"""检查用户通过权限组是否能访问指定实例+库。""" """检查用户通过权限组是否能访问指定实例+库。"""
# 获取实例信息
inst_result = await db.execute( inst_result = await db.execute(
select(Instance).where(Instance.id == instance_id) select(Instance).where(Instance.id == instance_id)
) )
@@ -259,15 +222,12 @@ async def _check_group_access(
return False return False
if instance.group_id is None: if instance.group_id is None:
# 公开实例,任何已登录用户都可以 readonly
return access_type == "readonly" return access_type == "readonly"
# 检查用户是否属于该实例所在的权限组
group_ids = await _get_user_group_ids(db, user) group_ids = await _get_user_group_ids(db, user)
if instance.group_id not in group_ids: if instance.group_id not in group_ids:
return False return False
# 获取权限组
group_result = await db.execute( group_result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == instance.group_id) select(InstanceGroup).where(InstanceGroup.id == instance.group_id)
) )
@@ -275,14 +235,16 @@ async def _check_group_access(
if not group: if not group:
return False return False
# 检查 access_type 是否匹配 # readonly 权限组也可以被 write 请求使用(读+写包含只读)
if group.access_type != access_type: if access_type == "write" and group.access_type == "readonly":
return False 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() allowed_dbs = group.get_allowed_databases()
if not allowed_dbs: if not allowed_dbs:
# 空列表 = 该实例所有库
return True return True
return database in allowed_dbs return database in allowed_dbs
+1 -1
View File
@@ -6,7 +6,7 @@ export interface InstanceGroup {
description?: string; description?: string;
db_type: string; db_type: string;
permissions?: string[]; permissions?: string[];
access_type?: 'readonly' | 'datachange' | 'ddl'; access_type?: 'readonly' | 'write';
allowed_databases?: string[]; allowed_databases?: string[];
is_active: boolean; is_active: boolean;
created_at?: string; created_at?: string;
@@ -1,6 +1,7 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { X, Loader2 } from 'lucide-react'; import { X, Loader2 } from 'lucide-react';
import { instanceApi } from '@/api/instance'; import { instanceApi } from '@/api/instance';
import { instanceGroupApi } from '@/api/instanceGroup';
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Types */ /* Types */
@@ -15,6 +16,13 @@ interface InstanceData {
database: string; database: string;
env: string; env: string;
access_mode: string; access_mode: string;
group_id?: number | null;
}
interface PermGroup {
id: number;
name: string;
access_type?: string;
} }
interface Props { interface Props {
+277 -211
View File
@@ -1,7 +1,8 @@
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, X,
Shield, Eye, PenLine, Hammer, Database, Eye, PenLine, ChevronLeft, ChevronRight as ChevRight,
Database, UserPlus, XCircle,
} 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';
@@ -14,43 +15,139 @@ import { useActionPermission } from '@/hooks/useAuth';
const ACCESS_TYPES = [ const ACCESS_TYPES = [
{ {
value: 'readonly', value: 'readonly',
label: '只读查询', label: '只读查看',
desc: 'SELECT', desc: '关联只读/从库 → 用于查询数据',
color: 'bg-emerald-500/15 text-emerald-400', color: 'text-emerald-400',
bg: 'bg-emerald-500/15',
icon: Eye, icon: Eye,
}, },
{ {
value: 'datachange', value: 'write',
label: '数据变更', label: '写入变更',
desc: 'INSERT / UPDATE / DELETE', desc: '关联读写/主库 → 用于数据变更工单',
color: 'bg-amber-500/15 text-amber-400', color: 'text-amber-400',
bg: 'bg-amber-500/15',
icon: PenLine, icon: PenLine,
}, },
{
value: 'ddl',
label: 'DDL 变更',
desc: 'CREATE / ALTER / DROP / INDEX',
color: 'bg-red-500/15 text-red-400',
icon: Hammer,
},
] as const; ] 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<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]',
redis: 'bg-[rgba(220,50,32,0.15)] text-[#ef5350]', redis: 'bg-[rgba(220,50,32,0.15)] text-[#ef5350]',
mongo: 'bg-[rgba(71,162,72,0.15)] text-[#66bb6a]', 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<Set<string>>(new Set());
const [pendingRemove, setPendingRemove] = useState<Set<string>>(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 (
<div className="flex gap-3 items-stretch">
{/* Left: Available */}
<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">
<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.5 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-48 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.map(item => (
<label key={item.value} className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer text-xs transition-colors ${pendingAdd.has(item.value) ? 'bg-blue-500/10 text-blue-400' : 'hover:bg-[var(--dg-bg-hover)]'}`}>
<input type="checkbox" checked={pendingAdd.has(item.value)} onChange={() => togglePendingAdd(item.value)} className="rounded" />
<Database className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
<span>{item.label}</span>
{item.extra && <span className="text-[var(--dg-text-muted)] ml-auto">{item.extra}</span>}
</label>
))}
</div>
</div>
{/* Middle: Arrows */}
<div className="flex flex-col justify-center gap-2 py-6">
<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>
{/* Right: Selected */}
<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">
<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-56 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.map(item => (
<label key={item.value} className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer text-xs transition-colors ${pendingRemove.has(item.value) ? 'bg-red-500/10 text-red-400' : 'hover:bg-[var(--dg-bg-hover)]'}`}>
<input type="checkbox" checked={pendingRemove.has(item.value)} onChange={() => togglePendingRemove(item.value)} className="rounded" />
<Database className="w-3 h-3 text-emerald-400 flex-shrink-0" />
<span>{item.label}</span>
{item.extra && <span className="text-[var(--dg-text-muted)] ml-auto">{item.extra}</span>}
</label>
))}
</div>
</div>
</div>
);
}
/* ───────── Main Component ───────── */ /* ───────── Main Component ───────── */
export default function InstanceGroupPage() { export default function InstanceGroupPage() {
@@ -60,102 +157,98 @@ export default function InstanceGroupPage() {
const [selectedId, setSelectedId] = useState<number | null>(null); const [selectedId, setSelectedId] = useState<number | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const [showInstanceModal, setShowInstanceModal] = useState(false);
const [showUserModal, setShowUserModal] = useState(false); const [showUserModal, setShowUserModal] = useState(false);
const canManage = useActionPermission('instance-group.manage'); const canManage = useActionPermission('instance-group.manage');
// Form state // Form state
const [formName, setFormName] = useState(''); const [formName, setFormName] = useState('');
const [formDesc, setFormDesc] = useState(''); const [formDesc, setFormDesc] = useState('');
const [formDbType, setFormDbType] = useState('all'); const [formAccessType, setFormAccessType] = useState<'readonly' | 'write'>('readonly');
const [formAccessType, setFormAccessType] = useState('readonly'); const [formAllowedDbs, setFormAllowedDbs] = useState<string[]>([]);
const [formAllowedDbs, setFormAllowedDbs] = useState('');
const [formIsActive, setFormIsActive] = useState(true); const [formIsActive, setFormIsActive] = useState(true);
const [formInstanceIds, setFormInstanceIds] = useState<number[]>([]);
// Related data // DB list for transfer
const [allInstances, setAllInstances] = useState<any[]>([]); const [allDatabases, setAllDatabases] = useState<{ label: string; value: string; extra?: string }[]>([]);
const [loadingDbs, setLoadingDbs] = useState(false);
// User assignment
const [allUsers, setAllUsers] = useState<any[]>([]); const [allUsers, setAllUsers] = useState<any[]>([]);
const [selectedInstanceIds, setSelectedInstanceIds] = useState<number[]>([]);
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]); const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
const [instanceSearch, setInstanceSearch] = useState('');
const [userSearch, setUserSearch] = useState(''); const [userSearch, setUserSearch] = useState('');
// Instance list for form
const [allInstances, setAllInstances] = useState<any[]>([]);
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 {
const data = await instanceGroupApi.list(); const data = await instanceGroupApi.list();
setGroups(data); setGroups(data);
} catch { } catch { /* */ } finally { setLoading(false); }
// silent
} finally {
setLoading(false);
}
}, []); }, []);
useEffect(() => { fetchGroups(); }, [fetchGroups]); useEffect(() => { fetchGroups(); }, [fetchGroups]);
// Load instances when modal opens
useEffect(() => { useEffect(() => {
if (showInstanceModal) { if (showCreateModal || showEditModal) {
instanceApi.list().then(setAllInstances).catch(() => {}); instanceApi.list().then(setAllInstances).catch(() => {});
} }
}, [showCreateModal, showEditModal]);
// Load users when user modal opens
useEffect(() => {
if (showUserModal) { if (showUserModal) {
userApi.list().then((data: any) => { userApi.list().then((data: any) => {
const items = (data as any)?.items || data; const items = (data as any)?.items || data;
setAllUsers(Array.isArray(items) ? items : []); setAllUsers(Array.isArray(items) ? items : []);
}).catch(() => {}); }).catch(() => {});
} }
}, [showInstanceModal, showUserModal]); }, [showUserModal]);
const filteredGroups = useMemo(() => { // Load databases for selected instances in form
return groups.filter(g => !search || g.name.includes(search) || (g.description || '').includes(search)); const loadDatabasesForInstances = useCallback(async (instanceIds: number[]) => {
}, [groups, search]); 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(() => { const filteredGroups = useMemo(() => groups.filter(g => !search || g.name.includes(search)), [groups, search]);
return allInstances.filter(inst => !instanceSearch || inst.name.toLowerCase().includes(instanceSearch.toLowerCase()) || inst.host.includes(instanceSearch)); const filteredUsers = useMemo(() => allUsers.filter(u => !userSearch || (u.display_name || u.username).toLowerCase().includes(userSearch.toLowerCase())), [allUsers, userSearch]);
}, [allInstances, instanceSearch]);
const filteredUsers = useMemo(() => {
return allUsers.filter(u => !userSearch || (u.display_name || u.username).toLowerCase().includes(userSearch.toLowerCase()));
}, [allUsers, userSearch]);
const resetForm = () => { const resetForm = () => {
setFormName(''); setFormName(''); setFormDesc(''); setFormAccessType('readonly');
setFormDesc(''); setFormAllowedDbs([]); setFormIsActive(true); setFormInstanceIds([]); setAllDatabases([]);
setFormDbType('all');
setFormAccessType('readonly');
setFormAllowedDbs('');
setFormIsActive(true);
}; };
const handleCreate = async () => { const handleCreate = async () => {
if (!formName.trim()) return; if (!formName.trim()) return;
await instanceGroupApi.create({ await instanceGroupApi.create({
name: formName, name: formName, description: formDesc || undefined,
description: formDesc || undefined,
db_type: formDbType,
access_type: formAccessType, access_type: formAccessType,
allowed_databases: formAllowedDbs ? formAllowedDbs.split(',').map(s => s.trim()).filter(Boolean) : [], allowed_databases: formAllowedDbs.length > 0 ? formAllowedDbs : undefined,
is_active: formIsActive, is_active: formIsActive, db_type: 'all',
} as any); } as any);
setShowCreateModal(false); setShowCreateModal(false); resetForm(); fetchGroups();
resetForm();
fetchGroups();
}; };
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,
db_type: formDbType,
access_type: formAccessType, access_type: formAccessType,
allowed_databases: formAllowedDbs ? formAllowedDbs.split(',').map(s => s.trim()).filter(Boolean) : [], allowed_databases: formAllowedDbs.length > 0 ? formAllowedDbs : undefined,
is_active: formIsActive, is_active: formIsActive, db_type: 'all',
} as any); } as any);
setShowEditModal(false); setShowEditModal(false); resetForm(); fetchGroups();
resetForm();
fetchGroups();
}; };
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
@@ -168,30 +261,23 @@ export default function InstanceGroupPage() {
const openEditModal = (group: InstanceGroup) => { const openEditModal = (group: InstanceGroup) => {
setFormName(group.name); setFormName(group.name);
setFormDesc(group.description || ''); setFormDesc(group.description || '');
setFormDbType(group.db_type); setFormAccessType(((group as any).access_type === 'write' ? 'write' : 'readonly'));
setFormAccessType((group as any).access_type || 'readonly'); setFormAllowedDbs((group as any).allowed_databases || []);
setFormAllowedDbs(((group as any).allowed_databases || []).join(', '));
setFormIsActive(group.is_active); 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); setShowEditModal(true);
}; };
const handleAssignInstances = async () => {
if (!selectedId || selectedInstanceIds.length === 0) return;
await instanceGroupApi.assignInstances(selectedId, selectedInstanceIds);
setShowInstanceModal(false);
setSelectedInstanceIds([]);
fetchGroups();
};
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); setShowUserModal(false); setSelectedUserIds([]); fetchGroups();
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 <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>;
@@ -201,7 +287,7 @@ export default function InstanceGroupPage() {
<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={() => { 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={() => { 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">
@@ -210,21 +296,22 @@ export default function InstanceGroupPage() {
)} )}
</div> </div>
{/* Quick stats */} {/* 2 permission type cards */}
<div className="grid grid-cols-3 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;
const Icon = at.icon; const Icon = at.icon;
return ( return (
<Card key={at.value} className="dg-card"> <Card key={at.value} className="dg-card">
<CardContent className="p-4 flex items-center gap-3"> <CardContent className="p-4 flex items-center gap-4">
<div className={`w-10 h-10 rounded-lg ${at.color} flex items-center justify-center`}> <div className={`w-12 h-12 rounded-xl ${at.bg} flex items-center justify-center`}>
<Icon className="w-5 h-5" /> <Icon className={`w-6 h-6 ${at.color}`} />
</div> </div>
<div> <div className="flex-1">
<div className="text-2xl font-bold">{count}</div> <div className="text-lg font-bold">{at.label}</div>
<div className="text-xs text-[var(--dg-text-muted)]">{at.label}权限组</div> <div className="text-xs text-[var(--dg-text-muted)]">{at.desc}</div>
</div> </div>
<div className="text-3xl font-bold text-[var(--dg-text-muted)]">{count}</div>
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -233,31 +320,26 @@ export default function InstanceGroupPage() {
<div className="flex gap-5"> <div className="flex gap-5">
{/* Left list */} {/* Left list */}
<div className="w-80 flex-shrink-0 space-y-3"> <div className="w-72 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-380px)] overflow-y-auto"> <div className="space-y-2 max-h-[calc(100vh-420px)] overflow-y-auto">
{filteredGroups.length === 0 && ( {filteredGroups.length === 0 && <div className="text-center py-10 text-[var(--dg-text-muted)] text-sm">暂无权限组</div>}
<div className="text-center py-10 text-[var(--dg-text-muted)] text-sm">暂无权限组</div>
)}
{filteredGroups.map(group => { {filteredGroups.map(group => {
const atConfig = getAccessTypeConfig((group as any).access_type || 'readonly'); const at = getAtConfig((group as any).access_type || 'readonly');
const ATIcon = atConfig.icon; const Icon = at.icon;
return ( return (
<div key={group.id} onClick={() => 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)]'}`}> <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 justify-between"> <div className="flex items-center gap-2">
<div className="flex items-center gap-2.5"> <Icon className={`w-4 h-4 ${at.color}`} />
<ATIcon className={`w-4 h-4 ${atConfig.color.includes('emerald') ? 'text-emerald-400' : atConfig.color.includes('amber') ? 'text-amber-400' : 'text-red-400'}`} />
<span className="font-medium text-sm">{group.name}</span> <span className="font-medium text-sm">{group.name}</span>
</div> </div>
{!group.is_active && <span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/15 text-red-400">禁用</span>} <div className="flex items-center gap-2 mt-1.5 text-xs text-[var(--dg-text-muted)]">
</div> <span className={`px-1.5 py-0.5 rounded text-[10px] ${at.bg} ${at.color}`}>{at.label}</span>
<div className="flex items-center gap-3 mt-2 text-xs text-[var(--dg-text-muted)]"> <span><Database className="w-3 h-3 inline mr-0.5" />{((group as any).allowed_databases || []).length || '全部'}库</span>
<span className={`px-1.5 py-0.5 rounded text-[10px] ${atConfig.color}`}>{atConfig.label}</span> <span><Users className="w-3 h-3 inline mr-0.5" />{group.user_count || 0}</span>
<span className="flex items-center gap-1"><Server className="w-3 h-3" />{group.instance_count || 0}</span>
<span className="flex items-center gap-1"><Users className="w-3 h-3" />{group.user_count || 0}</span>
</div> </div>
</div> </div>
); );
@@ -269,32 +351,22 @@ 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 atConfig = getAccessTypeConfig((sg as any).access_type || 'readonly'); const at = getAtConfig((sg as any).access_type || 'readonly');
const ATIcon = atConfig.icon; const Icon = at.icon;
const allowedDbs: string[] = (sg as any).allowed_databases || []; const allowedDbs: string[] = (sg as any).allowed_databases || [];
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Info Card */}
<Card className="dg-card"> <Card className="dg-card">
<CardContent className="p-5"> <CardContent className="p-5">
<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">
<ATIcon className={`w-5 h-5 ${atConfig.color.includes('emerald') ? 'text-emerald-400' : atConfig.color.includes('amber') ? 'text-amber-400' : 'text-red-400'}`} /> <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 ${atConfig.color}`}>{atConfig.label}</span> <span className={`px-2 py-0.5 text-xs rounded ${at.bg} ${at.color}`}>{at.label}</span>
<span className={`px-2 py-0.5 text-xs rounded ${DB_TYPE_COLORS[sg.db_type] || DB_TYPE_COLORS.all}`}>{DB_TYPE_OPTIONS.find(o => o.value === sg.db_type)?.label}</span>
{!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="text-xs text-[var(--dg-text-muted)] mt-2">
允许操作: <code className="px-1.5 py-0.5 bg-[var(--dg-bg-tertiary)] rounded text-[var(--dg-text-primary)]">{atConfig.desc}</code>
</div>
{allowedDbs.length > 0 && (
<div className="text-xs text-[var(--dg-text-muted)] mt-1.5">
限定数据库: {allowedDbs.map(d => <span key={d} className="inline-block px-1.5 py-0.5 bg-[var(--dg-bg-tertiary)] rounded mr-1">{d}</span>)}
</div>
)}
</div> </div>
{canManage && ( {canManage && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -306,33 +378,40 @@ export default function InstanceGroupPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Authorized databases */}
<Card className="dg-card">
<CardContent className="p-5">
<h3 className="text-sm font-semibold flex items-center gap-2 mb-3"><Database className="w-4 h-4 text-[var(--dg-text-muted)]" />授权数据库 {allowedDbs.length > 0 ? `(${allowedDbs.length})` : '(全部)'}</h3>
{allowedDbs.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{allowedDbs.map(db => <span key={db} className="px-2.5 py-1 text-xs bg-[var(--dg-bg-tertiary)] rounded-md">{db}</span>)}
</div>
) : (
<div className="text-sm text-[var(--dg-text-muted)]">已授权该数据源下所有数据库</div>
)}
</CardContent>
</Card>
{/* Related Instances */} {/* Related Instances */}
<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">
<h3 className="text-sm font-semibold flex items-center gap-2"><Server className="w-4 h-4 text-[var(--dg-text-muted)]" />关联数据源 ({sg.instances?.length || 0})</h3> <h3 className="text-sm font-semibold flex items-center gap-2"><Server className="w-4 h-4 text-[var(--dg-text-muted)]" />关联数据源 ({sg.instances?.length || 0})</h3>
{canManage && (
<button onClick={() => { setSelectedInstanceIds([]); setShowInstanceModal(true); }} className="text-xs text-blue-400 hover:text-blue-300 transition-colors">添加</button>
)}
</div> </div>
{sg.instances && sg.instances.length > 0 ? ( {sg.instances && sg.instances.length > 0 ? (
<div className="grid grid-cols-2 gap-2"> <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 min-w-0"> <div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${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 truncate">{inst.name}</span> <span className="text-sm">{inst.name}</span>
<span className="text-xs text-[var(--dg-text-muted)] flex-shrink-0">{inst.host}:{inst.port}</span> <span className="text-xs text-[var(--dg-text-muted)]">{inst.host}:{inst.port}</span>
</div> </div>
{canManage && ( <span className={`px-1.5 py-0.5 text-[10px] rounded ${DB_TYPE_COLORS[inst.db_type] || ''}`}>{inst.db_type}</span>
<button onClick={async () => { await instanceGroupApi.removeInstance(sg.id, inst.id); fetchGroups(); }} className="text-[var(--dg-text-muted)] hover:text-red-400 transition-colors flex-shrink-0"><X className="w-3.5 h-3.5" /></button>
)}
</div> </div>
))} ))}
</div> </div>
) : ( ) : <div className="text-sm text-[var(--dg-text-muted)]">暂未关联数据源</div>}
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂未关联数据源</div>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -341,25 +420,19 @@ export default function InstanceGroupPage() {
<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">
<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> <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 && ( {canManage && <button onClick={() => { setSelectedUserIds([]); setShowUserModal(true); }} className="text-xs text-blue-400 hover:text-blue-300">添加用户</button>}
<button onClick={() => { setSelectedUserIds([]); setShowUserModal(true); }} className="text-xs text-blue-400 hover:text-blue-300 transition-colors">添加</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 justify-between 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="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">{(u.display_name || u.username).charAt(0)}</div> <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>
) : ( ) : <div className="text-sm text-[var(--dg-text-muted)]">暂未关联用户</div>}
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂未关联用户</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -376,23 +449,18 @@ export default function InstanceGroupPage() {
{/* Create/Edit Modal */} {/* Create/Edit Modal */}
{(showCreateModal || showEditModal) && ( {(showCreateModal || showEditModal) && (
<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-lg bg-[var(--dg-bg-primary)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}> <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-4">{showCreateModal ? '新建权限组' : '编辑权限组'}</h3> <h3 className="text-lg font-semibold mb-5">{showCreateModal ? '新建权限组' : '编辑权限组'}</h3>
<div className="space-y-4"> <div className="space-y-5">
{/* Name */}
<div> <div>
<label className="block text-sm font-medium mb-1.5">名称 <span className="text-red-400">*</span></label> <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="权限组名称" /> <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>
<textarea 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 resize-none" rows={2} value={formDesc} onChange={e => setFormDesc(e.target.value)} placeholder="可选描述" />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium mb-1.5">数据库类型</label> <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={formDbType} onChange={e => setFormDbType(e.target.value)}> <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="可选" />
{DB_TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div> </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>
@@ -402,66 +470,67 @@ export default function InstanceGroupPage() {
</select> </select>
</div> </div>
</div> </div>
{/* Access Type — 3 cards */}
{/* Access Type — 2 cards */}
<div> <div>
<label className="block text-sm font-medium mb-2">权限类型</label> <label className="block text-sm font-medium mb-2">权限类型</label>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-2 gap-3">
{ACCESS_TYPES.map(at => { {ACCESS_TYPES.map(at => {
const Icon = at.icon; const Icon = at.icon;
return ( return (
<button <button key={at.value} type="button" onClick={() => setFormAccessType(at.value as 'readonly' | 'write')}
key={at.value} 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)]'}`}>
type="button" <Icon className="w-5 h-5 mb-2" />
onClick={() => setFormAccessType(at.value)} <div className="text-sm font-medium">{at.label}</div>
className={`p-3 rounded-lg text-center transition-colors ${formAccessType === at.value ? at.color + ' ring-1 ring-current' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'}`} <div className="text-[11px] mt-1 opacity-70">{at.desc}</div>
>
<Icon className="w-5 h-5 mx-auto mb-1" />
<div className="text-xs font-medium">{at.label}</div>
<div className="text-[10px] mt-0.5 opacity-70">{at.desc}</div>
</button> </button>
); );
})} })}
</div> </div>
</div> </div>
{/* Allowed databases */}
{/* Select instance first */}
<div> <div>
<label className="block text-sm font-medium mb-1.5">限定数据库 <span className="text-[var(--dg-text-muted)] font-normal">(逗号分隔,留空=全部库)</span></label> <label className="block text-sm font-medium mb-2">关联数据源</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={formAllowedDbs} onChange={e => setFormAllowedDbs(e.target.value)} placeholder="shop_db, order_db, user_db" /> <div className="flex flex-wrap gap-2">
</div> {allInstances.map(inst => (
<div className="flex justify-end gap-3 pt-2"> <button key={inst.id} type="button" onClick={() => {
<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> const newIds = formInstanceIds.includes(inst.id)
<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 disabled:cursor-not-allowed transition-colors">{showCreateModal ? '创建' : '保存'}</button> ? formInstanceIds.filter(id => id !== inst.id)
</div> : [...formInstanceIds, inst.id];
setFormInstanceIds(newIds);
if (newIds.length > 0) loadDatabasesForInstances(newIds);
else { setAllDatabases([]); setFormAllowedDbs([]); }
}}
className={`px-3 py-1.5 text-xs rounded-lg border transition-all ${formInstanceIds.includes(inst.id) ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : 'bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]'}`}>
{inst.name}
</button>
))}
{allInstances.length === 0 && <span className="text-xs text-[var(--dg-text-muted)]">暂无数据源</span>}
</div> </div>
</div> </div>
{/* Transfer: database selection */}
{formInstanceIds.length > 0 && (
<div>
<label className="block text-sm font-medium mb-2">授权数据库 <span className="text-[var(--dg-text-muted)] font-normal text-xs">(留空=全部库)</span></label>
{loadingDbs ? (
<div className="flex items-center justify-center py-8 text-sm text-[var(--dg-text-muted)]"><div className="w-4 h-4 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mr-2" />加载数据库列表...</div>
) : (
<Transfer
title="已授权数据库"
items={allDatabases}
selected={formAllowedDbs}
onChange={setFormAllowedDbs}
/>
)}
</div> </div>
)} )}
{/* Instance Modal */} <div className="flex justify-end gap-3 pt-2">
{showInstanceModal && ( <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>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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>
<div className="w-full max-w-md bg-[var(--dg-bg-primary)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-semibold mb-4">关联数据源</h3>
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
<input value={instanceSearch} onChange={e => setInstanceSearch(e.target.value)} placeholder="搜索实例..." className="w-full pl-10 pr-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" />
</div> </div>
<div className="space-y-2 max-h-72 overflow-y-auto mb-4">
{filteredInstances.length === 0 && <div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无可选实例</div>}
{filteredInstances.map(inst => (
<label key={inst.id} className={`flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors ${selectedInstanceIds.includes(inst.id) ? 'bg-blue-500/10' : 'hover:bg-[var(--dg-bg-hover)]'}`}>
<input type="checkbox" checked={selectedInstanceIds.includes(inst.id)} onChange={e => setSelectedInstanceIds(e.target.checked ? [...selectedInstanceIds, inst.id] : selectedInstanceIds.filter(id => id !== inst.id))} className="rounded" />
<div className="flex-1">
<span className="text-sm">{inst.name}</span>
<span className="text-xs text-[var(--dg-text-muted)] ml-2">{inst.host}:{inst.port}</span>
</div>
<span className={`px-1.5 py-0.5 text-[10px] rounded ${DB_TYPE_COLORS[inst.db_type] || DB_TYPE_COLORS.all}`}>{inst.db_type}</span>
</label>
))}
</div>
<div className="flex justify-end gap-3">
<button onClick={() => setShowInstanceModal(false)} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors">取消</button>
<button onClick={handleAssignInstances} disabled={selectedInstanceIds.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">确认关联 ({selectedInstanceIds.length})</button>
</div> </div>
</div> </div>
</div> </div>
@@ -477,21 +546,18 @@ export default function InstanceGroupPage() {
<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" /> <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>
<div className="space-y-2 max-h-72 overflow-y-auto mb-4"> <div className="space-y-2 max-h-72 overflow-y-auto mb-4">
{filteredUsers.length === 0 && <div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无可选用户</div>}
{filteredUsers.map(u => ( {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)]'}`}> <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" /> <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> <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="flex-1">
<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)] ml-2">{u.role}</span> <span className="text-xs text-[var(--dg-text-muted)]">{u.role}</span>
</div>
</label> </label>
))} ))}
</div> </div>
<div className="flex justify-end gap-3"> <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">取消</button> <button onClick={() => setShowUserModal(false)} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)]">取消</button>
<button onClick={handleAssignUsers} disabled={selectedUserIds.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">确认关联 ({selectedUserIds.length})</button> <button onClick={handleAssignUsers} disabled={selectedUserIds.length === 0} className="px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50">确认 ({selectedUserIds.length})</button>
</div> </div>
</div> </div>
</div> </div>