feat: 实例权限组系统 — 完整CRUD + 实例/用户关联 + 前端管理页面

后端:
- InstanceGroup 模型 (name/db_type/permissions JSON/is_active)
- user_groups 关联表 (用户-权限组 M2M + 权限覆盖)
- Instance 模型增加 group_id FK
- User 模型增加 groups 关系
- API: CRUD + 实例关联 + 用户分配 + 权限组过滤
- 15 个新测试 (CRUD/关联/权限检查/参数校验) — 217/217 passing

前端:
- InstanceGroupPage: 左右布局(列表+详情)
- 权限配置: 5类17项权限勾选 (查询/DDL/工单/数据操作/管理)
- 实例关联/用户关联弹框
- 侧边栏增加权限组菜单 (数据源分组下)
- 路由 + 权限注册
This commit is contained in:
logan committed 2026-05-23 00:09:34 +08:00
1 parent 836ea97ee5
commit 765d27f1e3
15 files changed
+1283 -1

No files matched your search

@@ -0,0 +1,330 @@
import json
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, func as sa_func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user, require_role
from app.models.user import User
from app.models.instance import Instance
from app.models.instance_group import InstanceGroup, user_groups
from app.schemas.instance_group import (
InstanceGroupCreate, InstanceGroupUpdate,
InstanceGroupResponse, InstanceGroupDetailResponse,
InstanceAssignRequest, UserAssignRequest, UserGroupUpdateRequest,
)
router = APIRouter(prefix="/instance-groups", tags=["权限组管理"])
def _serialize_group(group: InstanceGroup, instance_count: int = 0, user_count: int = 0) -> dict:
return {
"id": group.id,
"name": group.name,
"description": group.description,
"db_type": group.db_type,
"permissions": group.get_permissions(),
"is_active": group.is_active,
"created_at": group.created_at,
"updated_at": group.updated_at,
"instance_count": instance_count,
"user_count": user_count,
}
def _serialize_group_detail(group: InstanceGroup) -> dict:
d = _serialize_group(
group,
instance_count=len(group.instances) if group.instances else 0,
user_count=len(group.users) if group.users else 0,
)
d["instances"] = [
{
"id": inst.id,
"name": inst.name,
"db_type": inst.db_type,
"host": inst.host,
"port": inst.port,
"env": inst.env,
"status": inst.status,
}
for inst in (group.instances or [])
]
d["users"] = [
{
"id": u.id,
"username": u.username,
"display_name": u.display_name,
"role": u.role,
}
for u in (group.users or [])
]
return d
@router.get("", response_model=List[InstanceGroupResponse])
async def list_instance_groups(
db_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
stmt = select(InstanceGroup).order_by(InstanceGroup.id)
if db_type:
stmt = stmt.where(InstanceGroup.db_type == db_type)
result = await db.execute(stmt)
groups = result.scalars().all()
return [_serialize_group_detail(g) for g in groups]
@router.post("", response_model=InstanceGroupResponse, status_code=201)
async def create_instance_group(
data: InstanceGroupCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
# Check name uniqueness
existing = await db.execute(
select(InstanceGroup).where(InstanceGroup.name == data.name)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="权限组名称已存在")
group = InstanceGroup(
name=data.name,
description=data.description,
db_type=data.db_type,
permissions=json.dumps(data.permissions, ensure_ascii=False) if data.permissions else None,
is_active=data.is_active,
)
db.add(group)
await db.commit()
await db.refresh(group)
return _serialize_group(group)
@router.get("/{group_id}", response_model=InstanceGroupDetailResponse)
async def get_instance_group(
group_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
return _serialize_group_detail(group)
@router.put("/{group_id}", response_model=InstanceGroupResponse)
async def update_instance_group(
group_id: int,
data: InstanceGroupUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
update_data = data.model_dump(exclude_unset=True)
if "name" in update_data:
# Check uniqueness
existing = await db.execute(
select(InstanceGroup).where(
InstanceGroup.name == update_data["name"],
InstanceGroup.id != group_id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="权限组名称已存在")
if "permissions" in update_data:
update_data["permissions"] = (
json.dumps(update_data["permissions"], ensure_ascii=False)
if update_data["permissions"]
else None
)
for key, value in update_data.items():
setattr(group, key, value)
await db.commit()
await db.refresh(group)
return _serialize_group(group)
@router.delete("/{group_id}")
async def delete_instance_group(
group_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
# Detach instances
inst_result = await db.execute(
select(Instance).where(Instance.group_id == group_id)
)
for inst in inst_result.scalars().all():
inst.group_id = None
await db.delete(group)
await db.commit()
return {"message": "权限组已删除"}
@router.post("/{group_id}/instances")
async def assign_instances(
group_id: int,
data: InstanceAssignRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
for inst_id in data.instance_ids:
inst_result = await db.execute(
select(Instance).where(Instance.id == inst_id)
)
inst = inst_result.scalar_one_or_none()
if inst:
inst.group_id = group_id
await db.commit()
return {"message": "实例已关联"}
@router.delete("/{group_id}/instances/{instance_id}")
async def remove_instance(
group_id: int,
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
inst_result = await db.execute(
select(Instance).where(Instance.id == instance_id, Instance.group_id == group_id)
)
inst = inst_result.scalar_one_or_none()
if not inst:
raise HTTPException(status_code=404, detail="实例未关联该权限组")
inst.group_id = None
await db.commit()
return {"message": "实例已取消关联"}
@router.get("/{group_id}/users")
async def list_group_users(
group_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
# Query user_groups for permissions override
ug_result = await db.execute(
select(user_groups).where(user_groups.c.group_id == group_id)
)
ug_rows = ug_result.all()
perm_map = {}
for row in ug_rows:
perms = json.loads(row.permissions) if row.permissions else None
perm_map[row.user_id] = perms
users_data = []
for u in (group.users or []):
users_data.append({
"id": u.id,
"username": u.username,
"display_name": u.display_name,
"role": u.role,
"permissions": perm_map.get(u.id),
})
return users_data
@router.post("/{group_id}/users")
async def assign_users(
group_id: int,
data: UserAssignRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
perm_json = json.dumps(data.permissions, ensure_ascii=False) if data.permissions else None
for user_id in data.user_ids:
# Check if already assigned
check = await db.execute(
select(user_groups).where(
user_groups.c.user_id == user_id,
user_groups.c.group_id == group_id,
)
)
if not check.first():
await db.execute(
user_groups.insert().values(
user_id=user_id,
group_id=group_id,
permissions=perm_json,
)
)
await db.commit()
return {"message": "用户已分配"}
@router.put("/users/{user_id}/groups")
async def update_user_groups(
user_id: int,
data: UserGroupUpdateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
user_result = await db.execute(
select(User).where(User.id == user_id)
)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# Remove existing group associations
await db.execute(
user_groups.delete().where(user_groups.c.user_id == user_id)
)
# Add new ones
for gid in data.group_ids:
group_result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == gid)
)
if group_result.scalar_one_or_none():
await db.execute(
user_groups.insert().values(user_id=user_id, group_id=gid)
)
await db.commit()
return {"message": "用户权限组已更新"}
+2
View File
@@ -4,6 +4,7 @@ from app.api.v1.endpoints import (
auth, user, instance, ticket, query, dashboard, audit, audit_rule, auth, user, instance, ticket, query, dashboard, audit, audit_rule,
announcement, notification, ldap_config, announcement, notification, ldap_config,
instance_detail, backup, redis, dictionary, instance_detail, backup, redis, dictionary,
instance_group,
) )
api_router = APIRouter(prefix="/api/v1") api_router = APIRouter(prefix="/api/v1")
@@ -23,3 +24,4 @@ api_router.include_router(instance_detail.router)
api_router.include_router(backup.router) api_router.include_router(backup.router)
api_router.include_router(redis.router) api_router.include_router(redis.router)
api_router.include_router(dictionary.router, prefix="/dictionary", tags=["dictionary"]) api_router.include_router(dictionary.router, prefix="/dictionary", tags=["dictionary"])
api_router.include_router(instance_group.router)
+1
View File
@@ -53,6 +53,7 @@ async def lifespan(app: FastAPI):
import app.models.notification # noqa import app.models.notification # noqa
import app.models.ldap_config # noqa import app.models.ldap_config # noqa
import app.models.backup # noqa import app.models.backup # noqa
import app.models.instance_group # noqa
await init_db() await init_db()
await seed_data() await seed_data()
+1
View File
@@ -0,0 +1 @@
from app.models.instance_group import InstanceGroup, user_groups # noqa: F401
+8 -1
View File
@@ -1,4 +1,5 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, func from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, func
from sqlalchemy.orm import relationship
from app.core.database import Base from app.core.database import Base
@@ -25,5 +26,11 @@ class Instance(Base):
excluded_databases = Column(Text, nullable=True) # JSON: ["sys", "information_schema", ...] excluded_databases = Column(Text, nullable=True) # JSON: ["sys", "information_schema", ...]
mask_fields = Column(Text, nullable=True) # JSON: [{table, field, rule}] mask_fields = Column(Text, nullable=True) # JSON: [{table, field, rule}]
# 权限组关联
group_id = Column(Integer, ForeignKey("instance_groups.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# 关系
group = relationship("InstanceGroup", back_populates="instances")
+42
View File
@@ -0,0 +1,42 @@
import json
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Table, func
from sqlalchemy.orm import relationship
from app.core.database import Base
# 用户-权限组关联表
user_groups = Table(
"user_groups",
Base.metadata,
Column("user_id", Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
Column("group_id", Integer, ForeignKey("instance_groups.id", ondelete="CASCADE"), primary_key=True),
Column("permissions", Text, nullable=True), # JSON: 覆盖组默认权限
)
class InstanceGroup(Base):
__tablename__ = "instance_groups"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False, unique=True)
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", ...]
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# 关系
instances = relationship("Instance", back_populates="group", lazy="selectin")
users = relationship("User", secondary=user_groups, back_populates="groups", lazy="selectin")
def get_permissions(self) -> list:
if self.permissions:
try:
return json.loads(self.permissions)
except (json.JSONDecodeError, TypeError):
return []
return []
def set_permissions(self, perms: list):
self.permissions = json.dumps(perms, ensure_ascii=False) if perms else None
+4
View File
@@ -1,4 +1,5 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, func from sqlalchemy import Column, Integer, String, Boolean, DateTime, func
from sqlalchemy.orm import relationship
from app.core.database import Base from app.core.database import Base
@@ -17,3 +18,6 @@ class User(Base):
last_login = Column(DateTime, nullable=True) last_login = Column(DateTime, nullable=True)
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Many-to-Many relationship with InstanceGroup
groups = relationship("InstanceGroup", secondary="user_groups", back_populates="users", lazy="selectin")
+53
View File
@@ -0,0 +1,53 @@
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, Field
class InstanceGroupCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = None
db_type: str = Field(default="all", pattern=r"^(mysql|redis|pg|mongo|all)$")
permissions: Optional[List[str]] = None
is_active: bool = True
class InstanceGroupUpdate(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=100)
description: Optional[str] = None
db_type: Optional[str] = Field(default=None, pattern=r"^(mysql|redis|pg|mongo|all)$")
permissions: Optional[List[str]] = None
is_active: Optional[bool] = None
class InstanceGroupResponse(BaseModel):
id: int
name: str
description: Optional[str] = None
db_type: str
permissions: Optional[List[str]] = None
is_active: bool
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
instance_count: int = 0
user_count: int = 0
class Config:
from_attributes = True
class InstanceGroupDetailResponse(InstanceGroupResponse):
instances: List[dict] = []
users: List[dict] = []
class InstanceAssignRequest(BaseModel):
instance_ids: List[int] = Field(..., min_length=1)
class UserAssignRequest(BaseModel):
user_ids: List[int] = Field(..., min_length=1)
permissions: Optional[List[str]] = None
class UserGroupUpdateRequest(BaseModel):
group_ids: List[int] = Field(default_factory=list)
+1
View File
@@ -9,6 +9,7 @@ from app.core.database import Base, get_db
from app.core.security import hash_password, create_access_token from app.core.security import hash_password, create_access_token
from app.models.user import User from app.models.user import User
from app.models.instance import Instance from app.models.instance import Instance
from app.models.instance_group import InstanceGroup, user_groups
from app.models.ticket import Ticket from app.models.ticket import Ticket
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.audit_rule import AuditRule from app.models.audit_rule import AuditRule
+291
View File
@@ -0,0 +1,291 @@
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from app.core.security import hash_password, create_access_token
from app.models.user import User
from app.models.instance import Instance
from app.models.instance_group import InstanceGroup
from tests.conftest import auth_header
@pytest.mark.asyncio
async def test_create_instance_group(client: AsyncClient, admin_token: str):
resp = await client.post(
"/api/v1/instance-groups",
json={
"name": "生产MySQL组",
"description": "生产环境MySQL实例",
"db_type": "mysql",
"permissions": ["query:select", "query:insert", "ddl:create_table"],
"is_active": True,
},
headers=auth_header(admin_token),
)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "生产MySQL组"
assert data["db_type"] == "mysql"
assert "query:select" in data["permissions"]
@pytest.mark.asyncio
async def test_create_instance_group_duplicate_name(client: AsyncClient, admin_token: str):
payload = {"name": "重复组", "db_type": "mysql"}
resp1 = await client.post("/api/v1/instance-groups", json=payload, headers=auth_header(admin_token))
assert resp1.status_code == 201
resp2 = await client.post("/api/v1/instance-groups", json=payload, headers=auth_header(admin_token))
assert resp2.status_code == 400
@pytest.mark.asyncio
async def test_list_instance_groups(client: AsyncClient, admin_token: str):
# Create two groups
await client.post(
"/api/v1/instance-groups",
json={"name": "组A", "db_type": "mysql"},
headers=auth_header(admin_token),
)
await client.post(
"/api/v1/instance-groups",
json={"name": "组B", "db_type": "redis"},
headers=auth_header(admin_token),
)
resp = await client.get("/api/v1/instance-groups", headers=auth_header(admin_token))
assert resp.status_code == 200
data = resp.json()
assert len(data) >= 2
@pytest.mark.asyncio
async def test_list_instance_groups_filter_db_type(client: AsyncClient, admin_token: str):
await client.post(
"/api/v1/instance-groups",
json={"name": "过滤MySQL", "db_type": "mysql"},
headers=auth_header(admin_token),
)
resp = await client.get(
"/api/v1/instance-groups",
params={"db_type": "mysql"},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert all(g["db_type"] == "mysql" for g in data)
@pytest.mark.asyncio
async def test_get_instance_group(client: AsyncClient, admin_token: str):
create_resp = await client.post(
"/api/v1/instance-groups",
json={"name": "详情组", "db_type": "pg", "permissions": ["query:select"]},
headers=auth_header(admin_token),
)
group_id = create_resp.json()["id"]
resp = await client.get(
f"/api/v1/instance-groups/{group_id}",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "详情组"
assert data["permissions"] == ["query:select"]
@pytest.mark.asyncio
async def test_get_instance_group_not_found(client: AsyncClient, admin_token: str):
resp = await client.get("/api/v1/instance-groups/9999", headers=auth_header(admin_token))
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_update_instance_group(client: AsyncClient, admin_token: str):
create_resp = await client.post(
"/api/v1/instance-groups",
json={"name": "更新前", "db_type": "mysql"},
headers=auth_header(admin_token),
)
group_id = create_resp.json()["id"]
resp = await client.put(
f"/api/v1/instance-groups/{group_id}",
json={"name": "更新后", "description": "已更新"},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "更新后"
assert data["description"] == "已更新"
@pytest.mark.asyncio
async def test_delete_instance_group(client: AsyncClient, admin_token: str):
create_resp = await client.post(
"/api/v1/instance-groups",
json={"name": "删除组", "db_type": "redis"},
headers=auth_header(admin_token),
)
group_id = create_resp.json()["id"]
resp = await client.delete(
f"/api/v1/instance-groups/{group_id}",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
# Verify deleted
get_resp = await client.get(
f"/api/v1/instance-groups/{group_id}",
headers=auth_header(admin_token),
)
assert get_resp.status_code == 404
@pytest.mark.asyncio
async def test_assign_instances_to_group(client: AsyncClient, admin_token: str, db):
# Create group
create_resp = await client.post(
"/api/v1/instance-groups",
json={"name": "实例关联组", "db_type": "mysql"},
headers=auth_header(admin_token),
)
group_id = create_resp.json()["id"]
# Create instance directly
inst = Instance(
name="测试实例1", db_type="mysql", host="127.0.0.1",
port=3306, username="root", password="test", database="test",
)
db.add(inst)
await db.commit()
await db.refresh(inst)
resp = await client.post(
f"/api/v1/instance-groups/{group_id}/instances",
json={"instance_ids": [inst.id]},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
# Verify via group detail
detail_resp = await client.get(
f"/api/v1/instance-groups/{group_id}",
headers=auth_header(admin_token),
)
data = detail_resp.json()
assert data["instance_count"] >= 1
assert any(i["id"] == inst.id for i in data["instances"])
@pytest.mark.asyncio
async def test_remove_instance_from_group(client: AsyncClient, admin_token: str, db):
# Create group + instance
create_resp = await client.post(
"/api/v1/instance-groups",
json={"name": "移除实例组", "db_type": "mysql"},
headers=auth_header(admin_token),
)
group_id = create_resp.json()["id"]
inst = Instance(
name="移除实例", db_type="mysql", host="127.0.0.1",
port=3306, username="root", password="test", database="test",
)
db.add(inst)
await db.commit()
await db.refresh(inst)
# Assign first
await client.post(
f"/api/v1/instance-groups/{group_id}/instances",
json={"instance_ids": [inst.id]},
headers=auth_header(admin_token),
)
# Remove
resp = await client.delete(
f"/api/v1/instance-groups/{group_id}/instances/{inst.id}",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_assign_users_to_group(client: AsyncClient, admin_token: str, admin_user):
create_resp = await client.post(
"/api/v1/instance-groups",
json={"name": "用户关联组", "db_type": "all"},
headers=auth_header(admin_token),
)
group_id = create_resp.json()["id"]
resp = await client.post(
f"/api/v1/instance-groups/{group_id}/users",
json={"user_ids": [admin_user.id], "permissions": ["query:select"]},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
# Verify via group users
users_resp = await client.get(
f"/api/v1/instance-groups/{group_id}/users",
headers=auth_header(admin_token),
)
assert users_resp.status_code == 200
users_data = users_resp.json()
assert any(u["id"] == admin_user.id for u in users_data)
@pytest.mark.asyncio
async def test_update_user_groups(client: AsyncClient, admin_token: str, admin_user):
# Create two groups
g1 = await client.post(
"/api/v1/instance-groups",
json={"name": "用户组1", "db_type": "mysql"},
headers=auth_header(admin_token),
)
g2 = await client.post(
"/api/v1/instance-groups",
json={"name": "用户组2", "db_type": "redis"},
headers=auth_header(admin_token),
)
g1_id = g1.json()["id"]
g2_id = g2.json()["id"]
resp = await client.put(
f"/api/v1/instance-groups/users/{admin_user.id}/groups",
json={"group_ids": [g1_id, g2_id]},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_non_admin_cannot_create_group(client: AsyncClient, developer_token: str):
resp = await client.post(
"/api/v1/instance-groups",
json={"name": "非管理员组", "db_type": "mysql"},
headers=auth_header(developer_token),
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_non_admin_cannot_delete_group(client: AsyncClient, admin_token: str, developer_token: str):
create_resp = await client.post(
"/api/v1/instance-groups",
json={"name": "禁止删除组", "db_type": "mysql"},
headers=auth_header(admin_token),
)
group_id = create_resp.json()["id"]
resp = await client.delete(
f"/api/v1/instance-groups/{group_id}",
headers=auth_header(developer_token),
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_group_db_type_validation(client: AsyncClient, admin_token: str):
resp = await client.post(
"/api/v1/instance-groups",
json={"name": "无效类型", "db_type": "oracle"},
headers=auth_header(admin_token),
)
assert resp.status_code == 422
+2
View File
@@ -20,6 +20,7 @@ import InstanceDetailPage from '@/pages/InstanceDetailPage';
import RedisDetailPage from '@/pages/RedisDetailPage'; import RedisDetailPage from '@/pages/RedisDetailPage';
import TicketCreatePage from '@/pages/TicketCreatePage'; import TicketCreatePage from '@/pages/TicketCreatePage';
import TicketDetailPage from '@/pages/TicketDetailPage'; import TicketDetailPage from '@/pages/TicketDetailPage';
import InstanceGroupPage from '@/pages/InstanceGroupPage';
// 路由守卫:未登录跳转登录页,无权限跳转仪表盘 // 路由守卫:未登录跳转登录页,无权限跳转仪表盘
function RouteGuard() { function RouteGuard() {
@@ -56,6 +57,7 @@ function AppRoutes() {
<Route index element={<Navigate to="/dashboard" replace />} /> <Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} /> <Route path="dashboard" element={<Dashboard />} />
<Route path="instances" element={<InstancePage />} /> <Route path="instances" element={<InstancePage />} />
<Route path="instance-groups" element={<InstanceGroupPage />} />
<Route path="instances/:id" element={<InstanceDetailPage />} /> <Route path="instances/:id" element={<InstanceDetailPage />} />
<Route path="redis/:id" element={<RedisDetailPage />} /> <Route path="redis/:id" element={<RedisDetailPage />} />
<Route path="data-dictionary" element={<DataDictionaryPage />} /> <Route path="data-dictionary" element={<DataDictionaryPage />} />
+33
View File
@@ -0,0 +1,33 @@
import request from './request';
export interface InstanceGroup {
id: number;
name: string;
description?: string;
db_type: string;
permissions?: string[];
is_active: boolean;
created_at?: string;
updated_at?: string;
instance_count?: number;
user_count?: number;
instances?: { id: number; name: string; db_type: string; host: string; port: number; env: string; status: string }[];
users?: { id: number; username: string; display_name: string; role: string }[];
}
export const instanceGroupApi = {
list: (params?: any) => request.get<any, InstanceGroup[]>('/instance-groups', { params }),
get: (id: number) => request.get<any, InstanceGroup>(`/instance-groups/${id}`),
create: (data: any) => request.post<any, InstanceGroup>('/instance-groups', data),
update: (id: number, data: any) => request.put<any, InstanceGroup>(`/instance-groups/${id}`, data),
delete: (id: number) => request.delete(`/instance-groups/${id}`),
assignInstances: (groupId: number, instanceIds: number[]) =>
request.post(`/instance-groups/${groupId}/instances`, { instance_ids: instanceIds }),
removeInstance: (groupId: number, instanceId: number) =>
request.delete(`/instance-groups/${groupId}/instances/${instanceId}`),
listUsers: (groupId: number) => request.get<any, any[]>(`/instance-groups/${groupId}/users`),
assignUsers: (groupId: number, userIds: number[], permissions?: string[]) =>
request.post(`/instance-groups/${groupId}/users`, { user_ids: userIds, permissions }),
updateUserGroups: (userId: number, groupIds: number[]) =>
request.put(`/instance-groups/users/${userId}/groups`, { group_ids: groupIds }),
};
+2
View File
@@ -37,6 +37,7 @@ const roleNameMap: Record<UserRole, string> = {
const routePermissionMap: Record<string, UserRole[]> = { const routePermissionMap: Record<string, UserRole[]> = {
dashboard: ['admin', 'dba', 'developer', 'guest'], dashboard: ['admin', 'dba', 'developer', 'guest'],
instances: ['admin', 'dba'], instances: ['admin', 'dba'],
'instance-groups': ['admin'],
'data-dictionary': ['admin', 'dba', 'developer'], 'data-dictionary': ['admin', 'dba', 'developer'],
tickets: ['admin', 'dba', 'developer'], tickets: ['admin', 'dba', 'developer'],
queries: ['admin', 'dba', 'developer', 'guest'], queries: ['admin', 'dba', 'developer', 'guest'],
@@ -67,6 +68,7 @@ export const actionPermissionMap: Record<string, UserRole[]> = {
'config.manage': ['admin'], 'config.manage': ['admin'],
'announcement.manage': ['admin'], 'announcement.manage': ['admin'],
'auto-task.manage': ['admin'], 'auto-task.manage': ['admin'],
'instance-group.manage': ['admin'],
}; };
const AuthContext = createContext<AuthContextType | null>(null); const AuthContext = createContext<AuthContextType | null>(null);
+1
View File
@@ -23,6 +23,7 @@ export const menuSections = [
export const menuItems = [ export const menuItems = [
{ id: 'dashboard', label: '仪表盘', icon: 'LayoutDashboard', section: 'overview', roles: ['admin', 'dba', 'developer', 'guest'] }, { id: 'dashboard', label: '仪表盘', icon: 'LayoutDashboard', section: 'overview', roles: ['admin', 'dba', 'developer', 'guest'] },
{ id: 'instances', label: '数据源', icon: 'Database', section: 'datasource', roles: ['admin', 'dba'] }, { id: 'instances', label: '数据源', icon: 'Database', section: 'datasource', roles: ['admin', 'dba'] },
{ id: 'instance-groups', label: '权限组', icon: 'Shield', section: 'datasource', roles: ['admin'] },
{ id: 'tickets', label: '数据变更', icon: 'ClipboardList', section: 'ticket', roles: ['admin', 'dba', 'developer'] }, { id: 'tickets', label: '数据变更', icon: 'ClipboardList', section: 'ticket', roles: ['admin', 'dba', 'developer'] },
{ id: 'queries', label: '查询审计', icon: 'Search', section: 'ticket', roles: ['admin', 'dba', 'developer', 'guest'] }, { id: 'queries', label: '查询审计', icon: 'Search', section: 'ticket', roles: ['admin', 'dba', 'developer', 'guest'] },
{ id: 'roles', label: '角色权限', icon: 'UserCog', section: 'permission', roles: ['admin'] }, { id: 'roles', label: '角色权限', icon: 'UserCog', section: 'permission', roles: ['admin'] },
+512
View File
@@ -0,0 +1,512 @@
import { useState, useEffect, useCallback } from 'react';
import { Plus, Search, Edit2, Trash2, Server, Users, ChevronRight, 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';
// 权限预设
var PERMISSION_CATEGORIES = [
{
label: '查询权限',
items: [
{ code: 'query:select', name: 'SELECT 查询' },
{ code: 'query:insert', name: 'INSERT 插入' },
{ code: 'query:update', name: 'UPDATE 更新' },
{ code: 'query:delete', name: 'DELETE 删除' },
],
},
{
label: 'DDL 权限',
items: [
{ code: 'ddl:create_table', name: '创建表' },
{ code: 'ddl:alter_table', name: '修改表' },
{ code: 'ddl:drop_table', name: '删除表' },
{ code: 'ddl:create_index', name: '创建索引' },
{ code: 'ddl:drop_index', name: '删除索引' },
],
},
{
label: '工单权限',
items: [
{ code: 'ticket:create', name: '创建工单' },
{ code: 'ticket:approve', name: '审批工单' },
{ code: 'ticket:reject', name: '拒绝工单' },
{ code: 'ticket:execute', name: '执行工单' },
],
},
{
label: '数据操作',
items: [
{ code: 'export:data', name: '导出数据' },
{ code: 'import:data', name: '导入数据' },
],
},
{
label: '管理权限',
items: [
{ code: 'instance:connect', name: '连接实例' },
{ code: 'instance:view_slow_query', name: '查看慢查询' },
{ code: 'instance:kill_process', name: '终止进程' },
],
},
];
var DB_TYPE_OPTIONS = [
{ value: 'all', label: '全部类型' },
{ value: 'mysql', label: 'MySQL' },
{ value: 'pg', label: 'PostgreSQL' },
{ value: 'redis', label: 'Redis' },
{ value: 'mongo', label: 'MongoDB' },
];
var 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]',
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)]',
};
export default function InstanceGroupPage() {
var [groups, setGroups] = useState<InstanceGroup[]>([]);
var [loading, setLoading] = useState(true);
var [search, setSearch] = useState('');
var [selectedId, setSelectedId] = useState<number | null>(null);
var [showCreateModal, setShowCreateModal] = useState(false);
var [showEditModal, setShowEditModal] = useState(false);
var [showInstanceModal, setShowInstanceModal] = useState(false);
var [showUserModal, setShowUserModal] = useState(false);
var canManage = useActionPermission('instance-group.manage');
// 表单状态
var [formName, setFormName] = useState('');
var [formDesc, setFormDesc] = useState('');
var [formDbType, setFormDbType] = useState('all');
var [formPermissions, setFormPermissions] = useState<string[]>([]);
var [formIsActive, setFormIsActive] = useState(true);
// 关联数据
var [allInstances, setAllInstances] = useState<any[]>([]);
var [allUsers, setAllUsers] = useState<any[]>([]);
var [selectedInstanceIds, setSelectedInstanceIds] = useState<number[]>([]);
var [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
var selectedGroup = groups.find(g => g.id === selectedId);
var fetchGroups = useCallback(async () => {
try {
var data = await instanceGroupApi.list();
setGroups(data);
setLoading(false);
} catch {
setLoading(false);
}
}, []);
useEffect(() => { fetchGroups(); }, [fetchGroups]);
// 加载关联数据
useEffect(() => {
if (showInstanceModal) {
instanceApi.list().then(setAllInstances).catch(() => {});
}
if (showUserModal) {
userApi.list().then(setAllUsers).catch(() => {});
}
}, [showInstanceModal, showUserModal]);
var filteredGroups = groups.filter(g =>
!search || g.name.includes(search) || (g.description || '').includes(search)
);
var resetForm = () => {
setFormName('');
setFormDesc('');
setFormDbType('all');
setFormPermissions([]);
setFormIsActive(true);
};
var handleCreate = async () => {
if (!formName.trim()) return;
await instanceGroupApi.create({
name: formName,
description: formDesc || undefined,
db_type: formDbType,
permissions: formPermissions.length > 0 ? formPermissions : undefined,
is_active: formIsActive,
});
setShowCreateModal(false);
resetForm();
fetchGroups();
};
var handleEdit = async () => {
if (!selectedId || !formName.trim()) return;
await instanceGroupApi.update(selectedId, {
name: formName,
description: formDesc || undefined,
db_type: formDbType,
permissions: formPermissions.length > 0 ? formPermissions : undefined,
is_active: formIsActive,
});
setShowEditModal(false);
resetForm();
fetchGroups();
};
var handleDelete = async (id: number) => {
if (!confirm('确定删除该权限组?')) return;
await instanceGroupApi.delete(id);
if (selectedId === id) setSelectedId(null);
fetchGroups();
};
var openEditModal = (group: InstanceGroup) => {
setFormName(group.name);
setFormDesc(group.description || '');
setFormDbType(group.db_type);
setFormPermissions(group.permissions || []);
setFormIsActive(group.is_active);
setShowEditModal(true);
};
var handleAssignInstances = async () => {
if (!selectedId || selectedInstanceIds.length === 0) return;
await instanceGroupApi.assignInstances(selectedId, selectedInstanceIds);
setShowInstanceModal(false);
setSelectedInstanceIds([]);
fetchGroups();
};
var handleAssignUsers = async () => {
if (!selectedId || selectedUserIds.length === 0) return;
await instanceGroupApi.assignUsers(selectedId, selectedUserIds);
setShowUserModal(false);
setSelectedUserIds([]);
fetchGroups();
};
var togglePermission = (code: string) => {
setFormPermissions(prev =>
prev.includes(code) ? prev.filter(p => p !== code) : [...prev, code]
);
};
var toggleCategoryAll = (items: { code: string }[], checked: boolean) => {
var codes = items.map(i => i.code);
if (checked) {
setFormPermissions(prev => [...new Set([...prev, ...codes])]);
} else {
setFormPermissions(prev => prev.filter(p => !codes.includes(p)));
}
};
// 渲染权限组表单
var renderGroupForm = (onSubmit: () => void, submitLabel: string) => (
<div className="space-y-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>
<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 className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1.5">数据库类型</label>
<select className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={formDbType} onChange={e => setFormDbType(e.target.value)}>
{DB_TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">状态</label>
<select className="w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={formIsActive ? 'true' : 'false'} onChange={e => setFormIsActive(e.target.value === 'true')}>
<option value="true">启用</option>
<option value="false">禁用</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">权限配置</label>
<div className="space-y-3 max-h-60 overflow-y-auto pr-2">
{PERMISSION_CATEGORIES.map(cat => {
var allChecked = cat.items.every(i => formPermissions.includes(i.code));
return (
<div key={cat.label}>
<label className="flex items-center gap-2 text-xs font-semibold text-[var(--dg-text-secondary)] mb-1.5 cursor-pointer">
<input type="checkbox" checked={allChecked} onChange={e => toggleCategoryAll(cat.items, e.target.checked)} className="rounded border-[var(--dg-border)]" />
{cat.label}
</label>
<div className="flex flex-wrap gap-1.5 ml-5">
{cat.items.map(item => (
<label key={item.code} className={`flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs cursor-pointer transition-colors ${formPermissions.includes(item.code) ? 'bg-blue-500/15 text-blue-400' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'}`}>
<input type="checkbox" checked={formPermissions.includes(item.code)} onChange={() => togglePermission(item.code)} className="hidden" />
{item.name}
</label>
))}
</div>
</div>
);
})}
</div>
</div>
<div className="flex justify-end gap-3 pt-2">
<button onClick={() => { setShowCreateModal(false); setShowEditModal(false); resetForm(); }} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors">取消</button>
<button onClick={onSubmit} 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">{submitLabel}</button>
</div>
</div>
);
if (loading) return <div className="flex items-center justify-center h-64 text-[var(--dg-text-muted)]">加载中...</div>;
return (
<div className="space-y-5">
<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="grid grid-cols-4 gap-4">
{[
{ label: '权限组总数', value: groups.length, color: 'text-blue-400', bg: 'bg-blue-400/10' },
{ label: '活跃权限组', value: groups.filter(g => g.is_active).length, color: 'text-green-400', bg: 'bg-green-400/10' },
{ label: '关联实例数', value: groups.reduce((sum, g) => sum + (g.instance_count || 0), 0), color: 'text-purple-400', bg: 'bg-purple-400/10' },
{ label: '关联用户数', value: groups.reduce((sum, g) => sum + (g.user_count || 0), 0), color: 'text-orange-400', bg: 'bg-orange-400/10' },
].map((s, i) => (
<Card key={i} className="dg-card">
<CardContent className="p-4 flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}>
{[Server, Server, Users, Users][i] && (() => { var Icon = [Server, Server, Users, Users][i]; return <Icon className={`w-5 h-5 ${s.color}`} />; })()}
</div>
<div>
<div className="text-2xl font-bold">{s.value}</div>
<div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div>
</div>
</CardContent>
</Card>
))}
</div>
<div className="flex gap-5">
{/* 左侧列表 */}
<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-380px)] overflow-y-auto">
{filteredGroups.length === 0 && (
<div className="text-center py-10 text-[var(--dg-text-muted)] text-sm">暂无权限组</div>
)}
{filteredGroups.map(group => (
<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 className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<span className="font-medium text-sm">{group.name}</span>
<span className={`px-1.5 py-0.5 text-[10px] rounded ${DB_TYPE_COLORS[group.db_type] || DB_TYPE_COLORS.all}`}>{DB_TYPE_OPTIONS.find(o => o.value === group.db_type)?.label || group.db_type}</span>
</div>
{!group.is_active && <span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/15 text-red-400">禁用</span>}
</div>
{group.description && <p className="text-xs text-[var(--dg-text-muted)] mt-1 line-clamp-1">{group.description}</p>}
<div className="flex items-center gap-3 mt-2 text-xs text-[var(--dg-text-muted)]">
<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>
<span>{(group.permissions || []).length} 权限</span>
</div>
</div>
))}
</div>
</div>
{/* 右侧详情 */}
<div className="flex-1 min-w-0">
{selectedGroup ? (() => {
var sg = selectedGroup!;
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>
<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>
<span className={`px-2 py-0.5 text-xs rounded ${sg.is_active ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>{sg.is_active ? '启用' : '禁用'}</span>
</div>
{sg.description && <p className="text-sm text-[var(--dg-text-secondary)] mt-1">{sg.description}</p>}
</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>
{/* 权限标签 */}
{sg.permissions && sg.permissions.length > 0 && (
<div className="mt-4">
<div className="text-xs font-medium text-[var(--dg-text-secondary)] mb-2">已配置权限</div>
<div className="flex flex-wrap gap-1.5">
{sg.permissions.map(p => {
var parts = p.split(':');
return <span key={p} className="px-2 py-1 text-xs bg-blue-500/10 text-blue-400 rounded-md">{parts[0]} · {parts[1]}</span>;
})}
</div>
</div>
)}
</CardContent>
</Card>
{/* 关联实例 */}
<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"><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>
{sg.instances && sg.instances.length > 0 ? (
<div className="grid grid-cols-2 gap-2">
{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 className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${inst.status === 'connected' ? 'bg-green-400' : 'bg-[var(--dg-text-muted)]'}`} />
<span className="text-sm">{inst.name}</span>
<span className="text-xs text-[var(--dg-text-muted)]">{inst.host}:{inst.port}</span>
</div>
{canManage && (
<button onClick={async () => { await instanceGroupApi.removeInstance(sg.id, inst.id); fetchGroups(); }} className="text-[var(--dg-text-muted)] hover:text-red-400 transition-colors"><X className="w-3.5 h-3.5" /></button>
)}
</div>
))}
</div>
) : (
<div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂未关联实例</div>
)}
</CardContent>
</Card>
{/* 关联用户 */}
<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 transition-colors">添加用户</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 justify-between 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>
<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>
) : (
<div className="text-center py-6 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>
{/* 创建弹框 */}
{showCreateModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => { setShowCreateModal(false); resetForm(); }}>
<div className="w-full max-w-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-semibold mb-4">新建权限组</h3>
{renderGroupForm(handleCreate, '创建')}
</div>
</div>
)}
{/* 编辑弹框 */}
{showEditModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => { setShowEditModal(false); resetForm(); }}>
<div className="w-full max-w-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-semibold mb-4">编辑权限组</h3>
{renderGroupForm(handleEdit, '保存')}
</div>
</div>
)}
{/* 关联实例弹框 */}
{showInstanceModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowInstanceModal(false)}>
<div className="w-full max-w-md bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-semibold mb-4">关联实例</h3>
<div className="space-y-2 max-h-72 overflow-y-auto mb-4">
{allInstances.length === 0 && <div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无可选实例</div>}
{allInstances.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>
)}
{/* 关联用户弹框 */}
{showUserModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowUserModal(false)}>
<div className="w-full max-w-md bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-2xl" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-semibold mb-4">关联用户</h3>
<div className="space-y-2 max-h-72 overflow-y-auto mb-4">
{allUsers.length === 0 && <div className="text-center py-6 text-sm text-[var(--dg-text-muted)]">暂无可选用户</div>}
{allUsers.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>
<div className="flex-1">
<span className="text-sm">{u.display_name || u.username}</span>
<span className="text-xs text-[var(--dg-text-muted)] ml-2">{u.role}</span>
</div>
</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">取消</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>
</div>
</div>
</div>
)}
</div>
);
}