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