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:
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": "用户权限组已更新"}
|
||||
@@ -4,6 +4,7 @@ from app.api.v1.endpoints import (
|
||||
auth, user, instance, ticket, query, dashboard, audit, audit_rule,
|
||||
announcement, notification, ldap_config,
|
||||
instance_detail, backup, redis, dictionary,
|
||||
instance_group,
|
||||
)
|
||||
|
||||
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(redis.router)
|
||||
api_router.include_router(dictionary.router, prefix="/dictionary", tags=["dictionary"])
|
||||
api_router.include_router(instance_group.router)
|
||||
@@ -53,6 +53,7 @@ async def lifespan(app: FastAPI):
|
||||
import app.models.notification # noqa
|
||||
import app.models.ldap_config # noqa
|
||||
import app.models.backup # noqa
|
||||
import app.models.instance_group # noqa
|
||||
|
||||
await init_db()
|
||||
await seed_data()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from app.models.instance_group import InstanceGroup, user_groups # noqa: F401
|
||||
@@ -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
|
||||
|
||||
@@ -25,5 +26,11 @@ class Instance(Base):
|
||||
excluded_databases = Column(Text, nullable=True) # JSON: ["sys", "information_schema", ...]
|
||||
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())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# 关系
|
||||
group = relationship("InstanceGroup", back_populates="instances")
|
||||
@@ -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
|
||||
@@ -1,4 +1,5 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
@@ -17,3 +18,6 @@ class User(Base):
|
||||
last_login = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, server_default=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")
|
||||
@@ -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)
|
||||
@@ -9,6 +9,7 @@ from app.core.database import Base, get_db
|
||||
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, user_groups
|
||||
from app.models.ticket import Ticket
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.audit_rule import AuditRule
|
||||
|
||||
@@ -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
|
||||
@@ -20,6 +20,7 @@ import InstanceDetailPage from '@/pages/InstanceDetailPage';
|
||||
import RedisDetailPage from '@/pages/RedisDetailPage';
|
||||
import TicketCreatePage from '@/pages/TicketCreatePage';
|
||||
import TicketDetailPage from '@/pages/TicketDetailPage';
|
||||
import InstanceGroupPage from '@/pages/InstanceGroupPage';
|
||||
|
||||
// 路由守卫:未登录跳转登录页,无权限跳转仪表盘
|
||||
function RouteGuard() {
|
||||
@@ -56,6 +57,7 @@ function AppRoutes() {
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="instances" element={<InstancePage />} />
|
||||
<Route path="instance-groups" element={<InstanceGroupPage />} />
|
||||
<Route path="instances/:id" element={<InstanceDetailPage />} />
|
||||
<Route path="redis/:id" element={<RedisDetailPage />} />
|
||||
<Route path="data-dictionary" element={<DataDictionaryPage />} />
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -37,6 +37,7 @@ const roleNameMap: Record<UserRole, string> = {
|
||||
const routePermissionMap: Record<string, UserRole[]> = {
|
||||
dashboard: ['admin', 'dba', 'developer', 'guest'],
|
||||
instances: ['admin', 'dba'],
|
||||
'instance-groups': ['admin'],
|
||||
'data-dictionary': ['admin', 'dba', 'developer'],
|
||||
tickets: ['admin', 'dba', 'developer'],
|
||||
queries: ['admin', 'dba', 'developer', 'guest'],
|
||||
@@ -67,6 +68,7 @@ export const actionPermissionMap: Record<string, UserRole[]> = {
|
||||
'config.manage': ['admin'],
|
||||
'announcement.manage': ['admin'],
|
||||
'auto-task.manage': ['admin'],
|
||||
'instance-group.manage': ['admin'],
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
@@ -23,6 +23,7 @@ export const menuSections = [
|
||||
export const menuItems = [
|
||||
{ id: 'dashboard', label: '仪表盘', icon: 'LayoutDashboard', section: 'overview', roles: ['admin', 'dba', 'developer', 'guest'] },
|
||||
{ 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: 'queries', label: '查询审计', icon: 'Search', section: 'ticket', roles: ['admin', 'dba', 'developer', 'guest'] },
|
||||
{ id: 'roles', label: '角色权限', icon: 'UserCog', section: 'permission', roles: ['admin'] },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user