From 128de645920bafa1446e09f4a5683992becd57b7 Mon Sep 17 00:00:00 2001 From: logan Date: Sun, 24 May 2026 19:36:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=95=B0=E6=8D=AE=E6=BA=90=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=8A=A0=E6=8E=92=E9=99=A4=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?+=E8=84=B1=E6=95=8F=E5=AD=97=E6=AE=B5+=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E6=B5=81=E5=85=B3=E8=81=94;=20=E6=9D=83=E9=99=90=E7=BB=84Trans?= =?UTF-8?q?fer=E6=94=B9=E4=B8=BA=E9=80=89=E6=8B=A9=E5=AE=9E=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EditInstanceDialog: 新增排除数据库(逗号分隔)、脱敏字段(动态表单+表/字段/规则)、审批流程关联 - Instance: workflow JSON字段改为workflow_id FK关联workflow_definitions表 - InstanceGroupPage: Transfer穿梭框从选数据库改为选实例(数据源) - 后端instance API返回workflow_name, _get_workflow_names批量查询 - DB迁移: instances表添加workflow_id列 - 37个测试全部通过 --- backend/app/api/v1/endpoints/instance.py | 35 ++- backend/app/models/instance.py | 3 +- backend/app/schemas/instance.py | 7 +- backend/tests/test_instance_fields.py | 76 +++--- .../src/components/EditInstanceDialog.tsx | 125 +++++++--- frontend/src/pages/InstanceGroupPage.tsx | 233 +++++++----------- 6 files changed, 268 insertions(+), 211 deletions(-) diff --git a/backend/app/api/v1/endpoints/instance.py b/backend/app/api/v1/endpoints/instance.py index c8b27b9..3b33766 100644 --- a/backend/app/api/v1/endpoints/instance.py +++ b/backend/app/api/v1/endpoints/instance.py @@ -19,7 +19,7 @@ from app.services.audit_log_service import AuditLogService router = APIRouter(prefix="/instances", tags=["数据源管理"]) -def _serialize_json_fields(instance: Instance) -> dict: +def _serialize_json_fields(instance: Instance, workflow_name: str = None) -> dict: """将 Instance 的 JSON text 字段反序列化为 dict/list,返回 Response dict""" d = { "id": instance.id, @@ -34,7 +34,8 @@ def _serialize_json_fields(instance: Instance) -> dict: "status": instance.status, "version": instance.version, "access_mode": instance.access_mode or "readwrite", - "workflow": json.loads(instance.workflow) if instance.workflow else None, + "workflow_id": instance.workflow_id, + "workflow_name": workflow_name, "excluded_databases": json.loads(instance.excluded_databases) if instance.excluded_databases else None, "mask_fields": json.loads(instance.mask_fields) if instance.mask_fields else None, "created_at": instance.created_at, @@ -42,6 +43,16 @@ def _serialize_json_fields(instance: Instance) -> dict: return d +async def _get_workflow_names(db: AsyncSession, instances: list) -> dict: + """批量获取 workflow_id -> name 映射""" + from app.models.workflow import WorkflowDefinition + wids = list({i.workflow_id for i in instances if i.workflow_id}) + if not wids: + return {} + result = await db.execute(select(WorkflowDefinition).where(WorkflowDefinition.id.in_(wids))) + return {w.id: w.name for w in result.scalars().all()} + + @router.get("", response_model=List[InstanceResponse]) async def list_instances( db_type: Optional[str] = None, @@ -71,7 +82,8 @@ async def list_instances( result = await db.execute(stmt) instances = result.scalars().all() - return [_serialize_json_fields(i) for i in instances] + wnames = await _get_workflow_names(db, instances) + return [_serialize_json_fields(i, wnames.get(i.workflow_id)) for i in instances] @router.post("", response_model=InstanceResponse, status_code=201) @@ -91,7 +103,7 @@ async def create_instance( env=data.env, description=data.description, access_mode=data.access_mode, - workflow=json.dumps([s.model_dump() for s in data.workflow], ensure_ascii=False) if data.workflow else None, + workflow_id=data.workflow_id, excluded_databases=json.dumps(data.excluded_databases, ensure_ascii=False) if data.excluded_databases else None, mask_fields=json.dumps([m.model_dump() for m in data.mask_fields], ensure_ascii=False) if data.mask_fields else None, ) @@ -104,7 +116,8 @@ async def create_instance( ) await db.commit() await db.refresh(instance) - return _serialize_json_fields(instance) + wnames = await _get_workflow_names(db, [instance]) + return _serialize_json_fields(instance, wnames.get(instance.workflow_id)) @router.post("/test-connection", response_model=ConnectionTestResult) @@ -133,7 +146,8 @@ async def get_instance( raise HTTPException(status_code=404, detail="数据源不存在") # P0: 检查访问权限 await check_instance_access(current_user, instance, db) - return _serialize_json_fields(instance) + wnames = await _get_workflow_names(db, [instance]) + return _serialize_json_fields(instance, wnames.get(instance.workflow_id)) @router.put("/{instance_id}", response_model=InstanceResponse) @@ -151,9 +165,9 @@ async def update_instance( update_data = data.model_dump(exclude_unset=True) if "password" in update_data: update_data["password"] = encrypt_password(update_data["password"]) - # JSON fields - if "workflow" in update_data: - update_data["workflow"] = json.dumps([s if isinstance(s, dict) else s for s in update_data["workflow"]], ensure_ascii=False) if update_data["workflow"] else None + # workflow_id: 0 means clear + if "workflow_id" in update_data and update_data["workflow_id"] == 0: + update_data["workflow_id"] = None if "excluded_databases" in update_data: update_data["excluded_databases"] = json.dumps(update_data["excluded_databases"], ensure_ascii=False) if update_data["excluded_databases"] else None if "mask_fields" in update_data: @@ -163,7 +177,8 @@ async def update_instance( setattr(instance, key, value) await db.commit() await db.refresh(instance) - return _serialize_json_fields(instance) + wnames = await _get_workflow_names(db, [instance]) + return _serialize_json_fields(instance, wnames.get(instance.workflow_id)) @router.delete("/{instance_id}") diff --git a/backend/app/models/instance.py b/backend/app/models/instance.py index b669fe2..e3c4d43 100644 --- a/backend/app/models/instance.py +++ b/backend/app/models/instance.py @@ -22,7 +22,7 @@ class Instance(Base): # 新增字段 access_mode = Column(String(20), default="readwrite") # readwrite / read / write - workflow = Column(Text, nullable=True) # JSON: [{stage, role, assignee}] + workflow_id = Column(Integer, ForeignKey("workflow_definitions.id", ondelete="SET NULL"), nullable=True) excluded_databases = Column(Text, nullable=True) # JSON: ["sys", "information_schema", ...] mask_fields = Column(Text, nullable=True) # JSON: [{table, field, rule}] @@ -34,3 +34,4 @@ class Instance(Base): # 关系 group = relationship("InstanceGroup", back_populates="instances") + workflow_def = relationship("WorkflowDefinition") diff --git a/backend/app/schemas/instance.py b/backend/app/schemas/instance.py index 1cb32b2..292a680 100644 --- a/backend/app/schemas/instance.py +++ b/backend/app/schemas/instance.py @@ -26,7 +26,7 @@ class InstanceCreate(BaseModel): env: Literal["production", "staging", "development"] = "development" description: Optional[str] = None access_mode: Literal["readwrite", "read", "write"] = "readwrite" - workflow: Optional[List[WorkflowStage]] = None + workflow_id: Optional[int] = Field(None, description="关联的审批流程ID") excluded_databases: Optional[List[str]] = None mask_fields: Optional[List[MaskFieldRule]] = None @@ -41,7 +41,7 @@ class InstanceUpdate(BaseModel): env: Optional[Literal["production", "staging", "development"]] = None description: Optional[str] = None access_mode: Optional[Literal["readwrite", "read", "write"]] = None - workflow: Optional[List[WorkflowStage]] = None + workflow_id: Optional[int] = Field(default=None, description="关联的审批流程ID,设为0清除关联") excluded_databases: Optional[List[str]] = None mask_fields: Optional[List[MaskFieldRule]] = None @@ -59,7 +59,8 @@ class InstanceResponse(BaseModel): status: str version: Optional[str] = None access_mode: str = "readwrite" - workflow: Optional[List[dict]] = None + workflow_id: Optional[int] = None + workflow_name: Optional[str] = None excluded_databases: Optional[List[str]] = None mask_fields: Optional[List[dict]] = None created_at: datetime diff --git a/backend/tests/test_instance_fields.py b/backend/tests/test_instance_fields.py index e30cdab..ee1a200 100644 --- a/backend/tests/test_instance_fields.py +++ b/backend/tests/test_instance_fields.py @@ -1,4 +1,4 @@ -"""测试 Instance 新增字段:access_mode, workflow, excluded_databases, mask_fields""" +"""测试 Instance 新增字段:access_mode, workflow_id, excluded_databases, mask_fields""" import pytest from tests.conftest import auth_header @@ -12,14 +12,9 @@ async def test_create_instance_with_full_fields(client, admin_token): "host": "mysql.example.com", "port": 3306, "username": "admin", - "password": "secret123", + "password": "***", "env": "production", "access_mode": "read", - "workflow": [ - {"stage": "提交阶段", "role": "提交人", "assignee": ""}, - {"stage": "开发负责人", "role": "开发负责人", "assignee": "lairdlandonloose"}, - {"stage": "执行人", "role": "执行人", "assignee": "zachlogan"}, - ], "excluded_databases": ["sys", "information_schema", "mysql"], "mask_fields": [ {"table": "users", "field": "phone", "rule": "mask"}, @@ -29,9 +24,7 @@ async def test_create_instance_with_full_fields(client, admin_token): assert res.status_code == 201 data = res.json() assert data["access_mode"] == "read" - assert data["workflow"] is not None - assert len(data["workflow"]) == 3 - assert data["workflow"][1]["assignee"] == "lairdlandonloose" + assert data["workflow_id"] is None assert data["excluded_databases"] == ["sys", "information_schema", "mysql"] assert len(data["mask_fields"]) == 2 assert data["mask_fields"][0]["table"] == "users" @@ -46,7 +39,7 @@ async def test_create_instance_default_access_mode(client, admin_token): "host": "127.0.0.1", "port": 3306, "username": "root", - "password": "test123", + "password": "***", }, headers=auth_header(admin_token)) assert res.status_code == 201 assert res.json()["access_mode"] == "readwrite" @@ -55,16 +48,14 @@ async def test_create_instance_default_access_mode(client, admin_token): @pytest.mark.asyncio async def test_list_instance_includes_new_fields(client, admin_token): """列表接口返回新字段""" - # 创建 await client.post("/api/v1/instances", json={ "name": "list-test", "db_type": "postgres", "host": "10.0.0.1", "port": 5432, "username": "postgres", - "password": "pwd", + "password": "***", "access_mode": "write", - "workflow": [{"stage": "审批", "role": "DBA", "assignee": "alice"}], }, headers=auth_header(admin_token)) res = await client.get("/api/v1/instances", headers=auth_header(admin_token)) @@ -73,35 +64,28 @@ async def test_list_instance_includes_new_fields(client, admin_token): found = [i for i in items if i["name"] == "list-test"] assert len(found) == 1 assert found[0]["access_mode"] == "write" - assert found[0]["workflow"] is not None - assert found[0]["workflow"][0]["assignee"] == "alice" + assert "workflow_id" in found[0] @pytest.mark.asyncio -async def test_update_instance_workflow(client, admin_token): - """更新流程配置""" +async def test_update_instance_fields(client, admin_token): + """更新字段""" create = await client.post("/api/v1/instances", json={ - "name": "update-wf", + "name": "update-fields", "db_type": "mysql", "host": "127.0.0.1", "port": 3306, "username": "root", - "password": "test", + "password": "***", }, headers=auth_header(admin_token)) iid = create.json()["id"] res = await client.put(f"/api/v1/instances/{iid}", json={ - "workflow": [ - {"stage": "提交", "role": "提交人", "assignee": "bob"}, - {"stage": "审核", "role": "DBA", "assignee": "alice"}, - {"stage": "执行", "role": "DBA", "assignee": "charlie"}, - ], "access_mode": "read", "excluded_databases": ["test_db"], }, headers=auth_header(admin_token)) assert res.status_code == 200 data = res.json() - assert len(data["workflow"]) == 3 assert data["access_mode"] == "read" assert data["excluded_databases"] == ["test_db"] @@ -115,10 +99,46 @@ async def test_instance_null_json_fields(client, admin_token): "host": "127.0.0.1", "port": 3306, "username": "root", - "password": "test", + "password": "***", }, headers=auth_header(admin_token)) assert res.status_code == 201 data = res.json() - assert data["workflow"] is None + assert data["workflow_id"] is None assert data["excluded_databases"] is None assert data["mask_fields"] is None + + +@pytest.mark.asyncio +async def test_instance_workflow_id_association(client, admin_token): + """数据源关联审批流程""" + # 创建 workflow + wf_res = await client.post("/api/v1/workflows", json={ + "name": "通用生产审批流程", + "description": "测试用", + "category": "ticket", + }, headers=auth_header(admin_token)) + if wf_res.status_code == 201: + wf_id = wf_res.json()["id"] + else: + # workflow may already exist, try to get one + wf_list = await client.get("/api/v1/workflows", headers=auth_header(admin_token)) + workflows = wf_list.json() + wf_id = workflows[0]["id"] if workflows else None + + if not wf_id: + pytest.skip("No workflow available") + + # 创建带 workflow_id 的实例 + res = await client.post("/api/v1/instances", json={ + "name": "wf-instance", + "db_type": "mysql", + "host": "127.0.0.1", + "port": 3306, + "username": "root", + "password": "***", + "workflow_id": wf_id, + }, headers=auth_header(admin_token)) + assert res.status_code == 201 + data = res.json() + assert data["workflow_id"] == wf_id + assert data["workflow_name"] is not None diff --git a/frontend/src/components/EditInstanceDialog.tsx b/frontend/src/components/EditInstanceDialog.tsx index a63f246..213d99c 100644 --- a/frontend/src/components/EditInstanceDialog.tsx +++ b/frontend/src/components/EditInstanceDialog.tsx @@ -1,7 +1,7 @@ import { useState, useCallback, useEffect } from 'react'; -import { X, Loader2 } from 'lucide-react'; +import { X, Loader2, Plus, Trash2 } from 'lucide-react'; import { instanceApi } from '@/api/instance'; -import { instanceGroupApi } from '@/api/instanceGroup'; +import { workflowApi } from '@/api/workflow'; /* ------------------------------------------------------------------ */ /* Types */ @@ -16,13 +16,17 @@ interface InstanceData { database: string; env: string; access_mode: string; + workflow_id?: number | null; + workflow_name?: string | null; + excluded_databases?: string[] | null; + mask_fields?: { table: string; field: string; rule: string }[] | null; group_id?: number | null; } -interface PermGroup { +interface WorkflowItem { id: number; name: string; - access_type?: string; + is_active?: boolean; } interface Props { @@ -44,6 +48,14 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved }: const [database, setDatabase] = useState(instance.database || ''); const [env, setEnv] = useState(instance.env || 'development'); const [accessMode, setAccessMode] = useState(instance.access_mode || 'readwrite'); + const [workflowId, setWorkflowId] = useState(instance.workflow_id || null); + const [workflows, setWorkflows] = useState([]); + // excluded_databases: comma-separated input + const [excludedDbs, setExcludedDbs] = useState((instance.excluded_databases || []).join(', ')); + // mask_fields + const [maskFields, setMaskFields] = useState<{ table: string; field: string; rule: string }[]>( + instance.mask_fields && instance.mask_fields.length > 0 ? instance.mask_fields : [] + ); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(''); @@ -57,14 +69,42 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved }: setDatabase(instance.database || ''); setEnv(instance.env || 'development'); setAccessMode(instance.access_mode || 'readwrite'); + setWorkflowId(instance.workflow_id || null); + setExcludedDbs((instance.excluded_databases || []).join(', ')); + setMaskFields(instance.mask_fields && instance.mask_fields.length > 0 ? instance.mask_fields : []); setError(''); }, [instance]); + /* Load workflows when modal opens */ + useEffect(() => { + if (open) { + workflowApi.list().then((data: any) => { + const items = Array.isArray(data) ? data : (data?.items || []); + setWorkflows(items.filter((w: WorkflowItem) => w.is_active !== false)); + }).catch(() => setWorkflows([])); + } + }, [open]); + + const addMaskField = () => setMaskFields([...maskFields, { table: '', field: '', rule: 'mask' }]); + const removeMaskField = (idx: number) => setMaskFields(maskFields.filter((_, i) => i !== idx)); + const updateMaskField = (idx: number, key: string, value: string) => { + const next = [...maskFields]; + next[idx] = { ...next[idx], [key]: value }; + setMaskFields(next); + }; + const handleSubmit = useCallback(async () => { if (!name.trim()) { setError('请填写名称'); return; } if (!host.trim()) { setError('请填写地址'); return; } setSubmitting(true); setError(''); try { + const parsedExcluded = excludedDbs.trim() + ? excludedDbs.split(',').map(s => s.trim()).filter(Boolean) + : undefined; + const parsedMask = maskFields.some(m => m.table && m.field) + ? maskFields.filter(m => m.table && m.field) + : undefined; + await instanceApi.update(instance.id, { name: name.trim(), host: host.trim(), @@ -74,6 +114,9 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved }: database: database.trim() || undefined, env, access_mode: accessMode, + workflow_id: workflowId, + excluded_databases: parsedExcluded, + mask_fields: parsedMask, }); onSaved(); } catch (e: unknown) { @@ -82,7 +125,7 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved }: } finally { setSubmitting(false); } - }, [name, host, port, username, password, database, env, accessMode, instance.id, onSaved]); + }, [name, host, port, username, password, database, env, accessMode, workflowId, excludedDbs, maskFields, instance.id, onSaved]); if (!open) return null; @@ -98,27 +141,20 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved }: {/* Header */}

编辑数据源

-
{/* Body */}
- {error && ( -
{error}
- )} + {error &&
{error}
} - {/* Name */}
setName(e.target.value)} placeholder="实例名称" />
- {/* Host + Port */}
@@ -130,7 +166,6 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved }:
- {/* Username + Password */}
@@ -142,13 +177,11 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved }:
- {/* Database */}
setDatabase(e.target.value)} placeholder="可选" />
- {/* Env + Access Mode */}
@@ -169,21 +202,57 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved }:
+ + {/* 审批流程 */} +
+ + + {workflows.length === 0 &&

暂无可用审批流程

} +
+ + {/* 排除数据库 */} +
+ + setExcludedDbs(e.target.value)} placeholder="sys, information_schema, mysql" /> +
+ + {/* 脱敏字段 */} +
+
+ + +
+ {maskFields.length === 0 && ( +
未配置脱敏规则
+ )} +
+ {maskFields.map((mf, idx) => ( +
+ updateMaskField(idx, 'table', e.target.value)} placeholder="表名" /> + updateMaskField(idx, 'field', e.target.value)} placeholder="字段名" /> + + +
+ ))} +
+
{/* Footer */}
- - + diff --git a/frontend/src/pages/InstanceGroupPage.tsx b/frontend/src/pages/InstanceGroupPage.tsx index 57fa005..7b1dc4b 100644 --- a/frontend/src/pages/InstanceGroupPage.tsx +++ b/frontend/src/pages/InstanceGroupPage.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { Plus, Search, Edit2, Trash2, Server, Users, ChevronRight, X, Eye, PenLine, ChevronLeft, ChevronRight as ChevRight, - Database, UserPlus, XCircle, + Database, UserPlus, } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; import { instanceGroupApi, type InstanceGroup } from '@/api/instanceGroup'; @@ -16,7 +16,7 @@ const ACCESS_TYPES = [ { value: 'readonly', label: '只读查看', - desc: '关联只读/从库 → 用于查询数据', + desc: '关联只读/从库,用于查询数据', color: 'text-emerald-400', bg: 'bg-emerald-500/15', icon: Eye, @@ -24,7 +24,7 @@ const ACCESS_TYPES = [ { value: 'write', label: '写入变更', - desc: '关联读写/主库 → 用于数据变更工单', + desc: '关联读写/主库,用于数据变更工单', color: 'text-amber-400', bg: 'bg-amber-500/15', icon: PenLine, @@ -34,89 +34,81 @@ const ACCESS_TYPES = [ const DB_TYPE_COLORS: Record = { mysql: 'bg-[rgba(0,117,143,0.15)] text-[#26a69a]', pg: 'bg-[rgba(51,103,145,0.15)] text-[#42a5f5]', + postgres: '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]', }; /* ───────── Transfer Component ───────── */ +interface TransferItem { + label: string; + value: string; + tag?: string; + tagColor?: string; +} + interface TransferProps { title: string; - items: { label: string; value: string; extra?: string }[]; + items: TransferItem[]; selected: string[]; onChange: (selected: string[]) => void; } function Transfer({ title, items, selected, onChange }: TransferProps) { const [search, setSearch] = useState(''); - - const unselected = items.filter( - i => !selected.includes(i.value) && (i.label.toLowerCase().includes(search.toLowerCase()) || i.value.toLowerCase().includes(search.toLowerCase())) - ); - const selectedFiltered = items.filter( - i => selected.includes(i.value) && (i.label.toLowerCase().includes(search.toLowerCase()) || i.value.toLowerCase().includes(search.toLowerCase())) - ); const [pendingAdd, setPendingAdd] = useState>(new Set()); const [pendingRemove, setPendingRemove] = useState>(new Set()); + const 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 addSelected = () => { - const newSelected = [...selected, ...pendingAdd]; - onChange(newSelected); + onChange([...selected, ...pendingAdd]); setPendingAdd(new Set()); }; - const removeSelected = () => { - const newSelected = selected.filter(v => !pendingRemove.has(v)); - onChange(newSelected); + onChange(selected.filter(v => !pendingRemove.has(v))); setPendingRemove(new Set()); }; + const toggleAdd = (v: string) => setPendingAdd(prev => { const n = new Set(prev); n.has(v) ? n.delete(v) : n.add(v); return n; }); + const toggleRemove = (v: string) => setPendingRemove(prev => { const n = new Set(prev); n.has(v) ? n.delete(v) : n.add(v); return n; }); - 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; - }); - }; + const rowCls = (active: boolean, color: string) => + `flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer text-xs transition-colors ${active ? color : 'hover:bg-[var(--dg-bg-hover)]'}`; return (
{/* Left: Available */}
- 可选数据库 + 可选实例 {unselected.length} 项
- setSearch(e.target.value)} - /> + setSearch(e.target.value)} />
{unselected.length === 0 &&
无可用项
} {unselected.map(item => ( -
- {/* Middle: Arrows */} + {/* Arrows */}
- {selectedFiltered.length === 0 &&
请从左侧选择数据库
} + {selectedFiltered.length === 0 &&
请从左侧选择实例
} {selectedFiltered.map(item => ( -
@@ -164,22 +156,18 @@ export default function InstanceGroupPage() { const [formName, setFormName] = useState(''); const [formDesc, setFormDesc] = useState(''); const [formAccessType, setFormAccessType] = useState<'readonly' | 'write'>('readonly'); - const [formAllowedDbs, setFormAllowedDbs] = useState([]); const [formIsActive, setFormIsActive] = useState(true); - const [formInstanceIds, setFormInstanceIds] = useState([]); + // Transfer: instance IDs as strings + const [formInstanceIds, setFormInstanceIds] = useState([]); - // DB list for transfer - const [allDatabases, setAllDatabases] = useState<{ label: string; value: string; extra?: string }[]>([]); - const [loadingDbs, setLoadingDbs] = useState(false); + // All instances for transfer + const [allInstances, setAllInstances] = useState([]); // User assignment const [allUsers, setAllUsers] = useState([]); const [selectedUserIds, setSelectedUserIds] = useState([]); const [userSearch, setUserSearch] = useState(''); - // Instance list for form - const [allInstances, setAllInstances] = useState([]); - const selectedGroup = groups.find(g => g.id === selectedId); const fetchGroups = useCallback(async () => { @@ -194,7 +182,10 @@ export default function InstanceGroupPage() { // Load instances when modal opens useEffect(() => { if (showCreateModal || showEditModal) { - instanceApi.list().then(setAllInstances).catch(() => {}); + instanceApi.list().then((res: any) => { + const data = Array.isArray(res) ? res : (res?.items || []); + setAllInstances(data); + }).catch(() => {}); } }, [showCreateModal, showEditModal]); @@ -208,46 +199,55 @@ export default function InstanceGroupPage() { } }, [showUserModal]); - // Load databases for selected instances in form - const loadDatabasesForInstances = useCallback(async (instanceIds: number[]) => { - if (instanceIds.length === 0) { setAllDatabases([]); return; } - setLoadingDbs(true); - try { - const inst = allInstances.find((i: any) => i.id === instanceIds[0]); - if (!inst) { setAllDatabases([]); return; } - const res = await instanceApi.listDatabases(inst.id); - const dbs = (res as any)?.databases || res || []; - setAllDatabases(dbs.map((d: string) => ({ label: d, value: d }))); - } catch { setAllDatabases([]); } finally { setLoadingDbs(false); } - }, [allInstances]); + // Build transfer items from allInstances + const transferItems = useMemo(() => + allInstances.map((inst: any) => ({ + label: `${inst.name} (${inst.host}:${inst.port})`, + value: String(inst.id), + tag: inst.db_type, + tagColor: DB_TYPE_COLORS[inst.db_type] || '', + })), + [allInstances] + ); const filteredGroups = useMemo(() => groups.filter(g => !search || g.name.includes(search)), [groups, search]); const filteredUsers = useMemo(() => allUsers.filter(u => !userSearch || (u.display_name || u.username).toLowerCase().includes(userSearch.toLowerCase())), [allUsers, userSearch]); const resetForm = () => { setFormName(''); setFormDesc(''); setFormAccessType('readonly'); - setFormAllowedDbs([]); setFormIsActive(true); setFormInstanceIds([]); setAllDatabases([]); + setFormIsActive(true); setFormInstanceIds([]); }; + // Convert instance id strings to actual group assignment const handleCreate = async () => { if (!formName.trim()) return; - await instanceGroupApi.create({ - name: formName, description: formDesc || undefined, + const group = await instanceGroupApi.create({ + name: formName, + description: formDesc || undefined, access_type: formAccessType, - allowed_databases: formAllowedDbs.length > 0 ? formAllowedDbs : undefined, - is_active: formIsActive, db_type: 'all', + is_active: formIsActive, + db_type: 'all', } as any); + // Assign instances to this group + const ids = formInstanceIds.map(Number); + if (ids.length > 0 && group.id) { + await instanceGroupApi.assignInstances(group.id, ids); + } setShowCreateModal(false); resetForm(); fetchGroups(); }; const handleEdit = async () => { if (!selectedId || !formName.trim()) return; await instanceGroupApi.update(selectedId, { - name: formName, description: formDesc || undefined, + name: formName, + description: formDesc || undefined, access_type: formAccessType, - allowed_databases: formAllowedDbs.length > 0 ? formAllowedDbs : undefined, - is_active: formIsActive, db_type: 'all', + is_active: formIsActive, + db_type: 'all', } as any); + // Reassign instances + const ids = formInstanceIds.map(Number); + await instanceGroupApi.assignInstances(selectedId, ids); setShowEditModal(false); resetForm(); fetchGroups(); }; @@ -262,12 +262,8 @@ export default function InstanceGroupPage() { setFormName(group.name); setFormDesc(group.description || ''); setFormAccessType(((group as any).access_type === 'write' ? 'write' : 'readonly')); - setFormAllowedDbs((group as any).allowed_databases || []); setFormIsActive(group.is_active); - // Load instance IDs - const instIds = (group.instances || []).map(i => i.id); - setFormInstanceIds(instIds); - if (instIds.length > 0) loadDatabasesForInstances(instIds); + setFormInstanceIds((group.instances || []).map((i: any) => String(i.id))); setShowEditModal(true); }; @@ -338,7 +334,7 @@ export default function InstanceGroupPage() {
{at.label} - {((group as any).allowed_databases || []).length || '全部'}库 + {group.instances?.length || 0}实例 {group.user_count || 0}
@@ -353,7 +349,6 @@ export default function InstanceGroupPage() { const sg = selectedGroup; const at = getAtConfig((sg as any).access_type || 'readonly'); const Icon = at.icon; - const allowedDbs: string[] = (sg as any).allowed_databases || []; return (
@@ -378,26 +373,10 @@ export default function InstanceGroupPage() { - {/* Authorized databases */} - - -

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

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

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

-
+

关联实例 ({sg.instances?.length || 0})

{sg.instances && sg.instances.length > 0 ? (
{sg.instances.map(inst => ( @@ -411,7 +390,7 @@ export default function InstanceGroupPage() {
))}
- ) :
暂未关联数据源
} + ) :
暂未关联实例
} @@ -452,7 +431,6 @@ export default function InstanceGroupPage() {
e.stopPropagation()}>

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

- {/* Name */}
setFormName(e.target.value)} placeholder="如:生产只读、开发写入" /> @@ -471,7 +449,7 @@ export default function InstanceGroupPage() {
- {/* Access Type — 2 cards */} + {/* Access Type */}
@@ -489,44 +467,17 @@ export default function InstanceGroupPage() {
- {/* Select instance first */} + {/* Transfer: select instances */}
- -
- {allInstances.map(inst => ( - - ))} - {allInstances.length === 0 && 暂无数据源} -
+ +
- {/* Transfer: database selection */} - {formInstanceIds.length > 0 && ( -
- - {loadingDbs ? ( -
加载数据库列表...
- ) : ( - - )} -
- )} -