diff --git a/.zcode/plans/plan-sess_bc7da99a-6d01-4954-8e96-069e6ebb2857.md b/.zcode/plans/plan-sess_bc7da99a-6d01-4954-8e96-069e6ebb2857.md index 09a969f..f3a850b 100644 --- a/.zcode/plans/plan-sess_bc7da99a-6d01-4954-8e96-069e6ebb2857.md +++ b/.zcode/plans/plan-sess_bc7da99a-6d01-4954-8e96-069e6ebb2857.md @@ -1,21 +1,29 @@ -# LDAP:借鉴 aiops 的 AD 兼容与配置门控 + 连不上根因说明 +# 统一全部弹框(对照指南 06/07 章) -研究结论:LDAP 参数本身没问题(本机对 8.217.205.92:389 实测连接/导入/登录全通,aiops 种子配置与 opsxsql 同源)。「连不了」的根因在**你正在跑的旧后端实例**:① 其 sqlite 只读(实测报 `attempt to write a readonly database`,LDAP 配置根本存不进去);② 旧二进制还有 CORS 白名单 bug(非 5173 端口的前端所有写请求空 403)。需重启到新构建后端+可写 DB。本次按 aiops 补两块真差距: +探索结论:「奇特」的可解释根因——① ModalOverlay 系 14 处全部「弹窗里再套一张卡片」(双边框/双阴影/双圆角/双倍内边距);② 双滚动条;③ **侧栏/顶栏 z-200 浮在弹窗 z-100 之上**(弹窗像陷进布局里、导航不被遮罩仍可点);④ 关闭钮 5 种形态、footer 按钮 4 套体系、主色 5 种、标题字号 4 档、宽度 7 档;⑤ ModalOverlay 系无动画/无 ESC 之外的可达性;⑥ TicketDetailDialog 内嵌弹窗 ESC 会连锁关闭外框;⑦ EditInstanceDialog 独立第四体系(无 ESC/动画/portal);⑧ LDAP 三弹窗疑似孤儿 + BatchImportModal 残留死代码。 -## 1. 后端借鉴 aiops(controller/ldap.go)——AD/OpenLDAP 双兼容 -opsxsql 只支持 OpenLDAP(objectClass=inetOrgPerson + uid),AD 域(sAMAccountName)会搜不到。移植 aiops 的: -- **多轮过滤器**(ldapUserFilter/ldapADUserFilter/ldapUserListFilters):配置过滤器优先 → `(objectClass=inetOrgPerson)` → AD `(&(objectClass=user)(|(sAMAccountName=*)...))` 兜底;登录时 OpenLDAP 过滤器 + AD 过滤器两轮尝试 -- **属性回退链**(ldapEntryAttr):uid→sAMAccountName→userPrincipalName、cn→displayName、mail、telephoneNumber→mobile,映射解析按回退取值 -- 应用于 tryLDAPAuth(handler/auth.go)、ListLDAPUsers、BatchImportLDAP -- 保留 opsxsql 的 AES 密码加密(aiops 是明文,不倒退) +## 1. 基座统一(改 3 个基础组件,全站生效) +- `ui/dialog.tsx`:遮罩/面板 z-index 提到 **300/301**(压过侧栏 200,指南 modal=300 档);遮罩 `bg-black/45 + backdrop-blur-[2px]`;保留 fade+zoom 200ms 动画 +- `FormDialog` 规范化:统一头(标题 text-base font-semibold + 可选描述)+ **右上角统一关闭钮**(absolute X 16px,替换现在 5 种形态)、内容区单滚动(flex-col,header/footer 固定,max-h-85vh)、footer 右对齐 `gap-2 pt-4`;类名用 cn() 合并 +- `ConfirmDialog`:标题加 font-semibold,与 FormDialog 对齐(shadcn Button 保留) -## 2. 前端配置门控(aiops `{ldapConfig &&
}` 模式) -- **LdapConfigPage**:GetLDAPConfig 返回 null(未配置)时,隐藏「LDAP 账号管理」(LdapManagePanel/LdapUserTable/建 OU/建用户/账号测试),显示"未配置,先保存基础连接"引导态;保存成功后管理区出现 -- **BatchImportModal**(UsersPage):未配置 LDAP 时隐藏「导入到 LDAP / 从 LDAP 导入」两个 tab,仅保留本地 CSV 导入 +## 2. ModalOverlay 重写为 Radix 内核(同 props,14 处零改调用签名) +`open/onClose/width/className` 接口不变,内部改渲染 Radix Dialog → 免费获得 portal/焦点陷阱/滚动锁定/ESC(含嵌套正确分层,修复连锁关闭)/200ms 动画/z-300;面板策略改「header 固定 + 内容区滚动」。**修 ESC 连锁关闭 bug** -## 3. 实测验证(测试后端,独立 DB) -- 全新库未配置态:LdapConfigPage 管理区隐藏 + 导入弹窗无 LDAP tab -- 写入配置(8.217.205.92:389/dc=opsx,dc=vip/admin):管理区出现 → OU(devops/test)/用户列表(OpenLDAP 过滤器)→ 批量导入 → logan LDAP 登录复测 -- 全量门禁(tsc/build/28 测试)后提交 +## 3. 消灭「框中框」与双滚动(14 + 1 处调用点机械清理) +- 各 ModalOverlay 调用点删除内层 `bg/border/rounded/shadow/p-6` 卡片包装,内容直挂(保留各自布局) +- 删除内层第二个 `max-h/overflow-y-auto`(BatchImportModal/TicketDetailDialog/InstanceGroupPage/UserDetailModal)→ 单滚动条 +- `EditInstanceDialog` 迁到 ModalOverlay(同体系,获得 ESC/动画/portal) -不改 aiops 项目;测试数据仍在独立临时库,结束清理。 \ No newline at end of file +## 4. 视觉统一(按指南 06 数值层) +- 弹窗圆角统一 rounded-xl(12px);阴影单层 shadow-2xl、去边框(描边/阴影二选一) +- 标题统一 text-base font-semibold;关闭钮统一形态 +- footer 按钮统一 **dg-btn 体系**(`dg-btn-sm dg-btn-secondary` 取消 / `dg-btn-primary` 主操作 / `dg-btn-danger` 危险),替换裸 button 四种写法;主色统一 var(--dg-blue)(ResetPasswordModal 的 yellow-500、LDAP 弹窗的 emerald-500 一并收敛);宽度归档 sm/md/lg/xl 四档(内容宽度语义保留,消除随意值) + +## 5. 死代码清理 +- 验证 LdapCreateOuDialog/LdapAddUserDialog/LdapAccountTestDialog 无引用后删除;BatchImportModal 残留的建 OU 嵌套状态/函数清除 + +## 6. 验证 +- tsc/build/audit:pages 全绿 +- 浏览器实测 6 类弹框:用户新建/编辑(rbac)、批量导入、实例编辑(EditInstanceDialog)、工单详情+内嵌快捷审批(验证 ESC 不再连锁、z-index 压过侧栏)、Redis 行编辑、各页删除确认——确认单卡片/单滚动/统一关闭钮与按钮/动画一致 +- 提交并推送远程 \ No newline at end of file diff --git a/frontend/src/components/EditInstanceDialog.tsx b/frontend/src/components/EditInstanceDialog.tsx index cf50da1..d9cbb60 100644 --- a/frontend/src/components/EditInstanceDialog.tsx +++ b/frontend/src/components/EditInstanceDialog.tsx @@ -1,5 +1,6 @@ import { useState, useCallback, useEffect } from 'react'; -import { X, Loader2, Zap, CheckCircle, XCircle } from 'lucide-react'; +import { Loader2, Zap, CheckCircle, XCircle } from 'lucide-react'; +import { ModalOverlay } from '@/components/ModalOverlay'; import { instanceApi } from '@/api/instance'; import { workflowApi } from '@/api/workflow'; @@ -59,13 +60,20 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m const [workflows, setWorkflows] = useState([]); // excluded_databases: comma-separated input const [excludedDbs, setExcludedDbs] = useState( - Array.isArray(instance.excluded_databases) ? (instance.excluded_databases as string[]).join(', ') : (instance.excluded_databases || '') + Array.isArray(instance.excluded_databases) + ? (instance.excluded_databases as string[]).join(', ') + : instance.excluded_databases || '', ); // mask_fields: comma-separated field names const [maskFields, setMaskFields] = useState(instance.mask_fields || ''); const [submitting, setSubmitting] = useState(false); const [testing, setTesting] = useState(false); - const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string; cluster_mode?: boolean } | null>(null); + const [testResult, setTestResult] = useState<{ + success: boolean; + message: string; + version?: string; + cluster_mode?: boolean; + } | null>(null); const [error, setError] = useState(''); /* Sync when instance changes */ @@ -81,7 +89,9 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m setClusterMode(toBool(instance.cluster_mode)); setWorkflowId(instance.workflow_id || null); setExcludedDbs( - Array.isArray(instance.excluded_databases) ? (instance.excluded_databases as string[]).join(', ') : (instance.excluded_databases || '') + Array.isArray(instance.excluded_databases) + ? (instance.excluded_databases as string[]).join(', ') + : instance.excluded_databases || '', ); setMaskFields(instance.mask_fields || ''); setTestResult(null); @@ -91,20 +101,33 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m /* 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([])); + 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 handleSubmit = useCallback(async () => { - if (!name.trim()) { setError('请填写名称'); return; } - if (!host.trim()) { setError('请填写地址'); return; } - setSubmitting(true); setError(''); + 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) + ? excludedDbs + .split(',') + .map((s) => s.trim()) + .filter(Boolean) : undefined; const payload: Record = { @@ -140,7 +163,23 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m } finally { setSubmitting(false); } - }, [name, host, port, username, password, database, env, accessMode, clusterMode, workflowId, excludedDbs, maskFields, instance.id, instance.db_type, onSaved]); + }, [ + name, + host, + port, + username, + password, + database, + env, + accessMode, + clusterMode, + workflowId, + excludedDbs, + maskFields, + instance.id, + instance.db_type, + onSaved, + ]); const handleTest = useCallback(async () => { setTesting(true); @@ -156,18 +195,20 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m database: database.trim() || undefined, cluster_mode: instance.db_type === 'redis' ? clusterMode : undefined, }; - const res = mode === 'edit' && !password - ? await instanceApi.testConnection(instance.id) - : await instanceApi.testNewConnection(payload); + const res = + mode === 'edit' && !password + ? await instanceApi.testConnection(instance.id) + : await instanceApi.testNewConnection(payload); const result = res as { success: boolean; message: string; version?: string; cluster_mode?: boolean }; if (instance.db_type === 'redis' && result.success) { setClusterMode(toBool(result.cluster_mode)); } setTestResult(result); } catch (e: unknown) { - const msg = (e as { response?: { data?: { detail?: string; message?: string } } })?.response?.data?.detail - || (e as { response?: { data?: { message?: string } } })?.response?.data?.message - || '连接失败'; + const msg = + (e as { response?: { data?: { detail?: string; message?: string } } })?.response?.data?.detail || + (e as { response?: { data?: { message?: string } } })?.response?.data?.message || + '连接失败'; setTestResult({ success: false, message: msg }); } finally { setTesting(false); @@ -176,43 +217,43 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m if (!open) return null; - const inputCls = '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 transition-colors'; + const inputCls = + '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 transition-colors'; const labelCls = 'block text-xs font-medium text-[var(--dg-text-secondary)] mb-1'; return ( -
-
e.stopPropagation()} - > - {/* Header */} -
-
-

{mode === 'clone' ? '克隆数据源' : '编辑数据源'}

-

{instance.db_type.toUpperCase()} · {instance.host}:{instance.port}

-
- + +
+ {/* Header(关闭钮由弹窗基座右上角提供) */} +
+

{mode === 'clone' ? '克隆数据源' : '编辑数据源'}

+

+ {instance.db_type.toUpperCase()} · {instance.host}:{instance.port} +

{/* Body */} -
+
{error &&
{error}
}
- setName(e.target.value)} placeholder="实例名称" /> + setName(e.target.value)} placeholder="实例名称" />
- setHost(e.target.value)} placeholder="127.0.0.1" /> + setHost(e.target.value)} + placeholder="127.0.0.1" + />
- setPort(e.target.value)} placeholder="3306" /> + setPort(e.target.value)} placeholder="3306" />
@@ -220,19 +261,37 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
- setUsername(e.target.value)} placeholder={instance.db_type === 'redis' ? '可选,留空则仅密码认证' : '用户名'} /> + setUsername(e.target.value)} + placeholder={instance.db_type === 'redis' ? '可选,留空则仅密码认证' : '用户名'} + />
- setPassword(e.target.value)} placeholder={mode === 'edit' ? '不修改请留空' : '••••••'} /> + setPassword(e.target.value)} + placeholder={mode === 'edit' ? '不修改请留空' : '••••••'} + />
- setDatabase(e.target.value)} placeholder={instance.db_type === 'redis' ? 'Redis 数据库编号,默认 0' : '可选'} /> + setDatabase(e.target.value)} + placeholder={instance.db_type === 'redis' ? 'Redis 数据库编号,默认 0' : '可选'} + />
{instance.db_type === 'redis' && ( @@ -242,11 +301,14 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m {[ { value: false, label: '单节点', desc: '普通 Redis 或哨兵入口' }, { value: true, label: '集群', desc: 'Redis Cluster,按 slot 路由' }, - ].map(item => ( + ].map((item) => ( - - +
-
+ ); } diff --git a/frontend/src/components/FormDialog.tsx b/frontend/src/components/FormDialog.tsx index 94a8464..6d2d5e0 100644 --- a/frontend/src/components/FormDialog.tsx +++ b/frontend/src/components/FormDialog.tsx @@ -1,52 +1,53 @@ -import { - Dialog, - DialogContent, - DialogTitle, -} from '@/components/ui/dialog'; -import { XIcon } from 'lucide-react'; +/** + * 统一表单弹窗基座(指南 06/07 章): + * - 结构:固定头(标题+右上角统一关闭钮)/内容区单滚动/固定底(footer 右对齐 pt-4) + * - 视觉:rounded-xl 12px、单层 shadow-2xl、无边框;ESC/点遮罩不关(表单防误触) + * - width 预设:sm(max-w-[420px])/md(max-w-lg)/lg(max-w-2xl)/xl(max-w-4xl),可传任意值覆盖 + */ +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; +import { cn } from '@/lib/utils'; import type { ReactNode } from 'react'; +const WIDTH_PRESETS: Record = { + sm: 'sm:max-w-[420px]', + md: 'sm:max-w-lg', + lg: 'sm:max-w-2xl', + xl: 'sm:max-w-4xl', +}; + interface Props { open: boolean; onClose: () => void; title: string; + /** 可选副标题(展示在标题下) */ + description?: string; children: ReactNode; + /** sm | md | lg | xl 或任意 max-w-* 类 */ width?: string; footer?: ReactNode; } -export function FormDialog({ - open, - onClose, - title, - children, - width = 'max-w-lg', - footer, -}: Props) { +export function FormDialog({ open, onClose, title, description, children, width = 'md', footer }: Props) { + const widthCls = WIDTH_PRESETS[width] ?? width; return ( - { if (!v) onClose(); }}> - e.preventDefault()} - onInteractOutside={(e) => e.preventDefault()} - > - {/* Header */} -
+ { + if (!v) onClose(); + }} + > + + {/* Header(固定,不随内容滚动;关闭钮由基座右上角提供) */} +
{title} - + {description &&

{description}

}
- {/* Scrollable content */} -
{children}
+ {/* 内容区单滚动 */} +
{children}
- {/* Optional footer */} - {footer &&
{footer}
} + {/* Footer(固定,右对齐) */} + {footer &&
{footer}
}
); diff --git a/frontend/src/components/ModalOverlay.tsx b/frontend/src/components/ModalOverlay.tsx index 497b459..ba1f3d3 100644 --- a/frontend/src/components/ModalOverlay.tsx +++ b/frontend/src/components/ModalOverlay.tsx @@ -1,46 +1,66 @@ -import { useEffect, useCallback } from 'react'; +/** + * 通用弹窗容器(Radix 内核,接口与旧版完全兼容): + * - portal/焦点陷阱/body 滚动锁定/ESC(嵌套弹窗自动分层,修复连锁关闭) + * - z-300 档,压过侧栏/顶栏;200ms fade+zoom 动画;点遮罩不关闭(防表单误触) + * - 结构:内容直挂(不再需要内层卡片!),面板 flex-col + 内容区单滚动 + * - 头部(标题+关闭钮)由子组件渲染或用 title prop + */ +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; +import { cn } from '@/lib/utils'; + +const WIDTH_PRESETS: Record = { + sm: 'sm:max-w-[420px]', + md: 'sm:max-w-lg', + lg: 'sm:max-w-2xl', + xl: 'sm:max-w-4xl', +}; interface ModalOverlayProps { open: boolean; onClose: () => void; + /** sm | md | lg | xl 或任意 max-w-* 类 */ width?: string; + /** 可选标题;不传时渲染 sr-only 标题满足可访问性 */ + title?: string; + /** 传 true 时子内容自己管理滚动(默认内容区自动滚动) */ + noScroll?: boolean; className?: string; children: React.ReactNode; } -/** - * 通用弹框组件 - * - overlay z-[100],低于 header/sidebar 的 z-[200],不遮挡面包屑 - * - 点击遮罩层不关闭 - * - ESC 键关闭 - */ -export function ModalOverlay({ open, onClose, width = 'max-w-lg', className = '', children }: ModalOverlayProps) { - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }, - [onClose], - ); - - useEffect(() => { - if (open) { - document.addEventListener('keydown', handleKeyDown); - } - return () => { - document.removeEventListener('keydown', handleKeyDown); - }; - }, [open, handleKeyDown]); - - if (!open) return null; - +export function ModalOverlay({ + open, + onClose, + width = 'md', + title, + noScroll = false, + className = '', + children, +}: ModalOverlayProps) { + const widthCls = WIDTH_PRESETS[width] ?? width; return ( -
-
e.stopPropagation()} - > - {children} -
-
+ { + if (!v) onClose(); + }} + > + + {title ? ( + {title} + ) : ( + 对话框 + )} +
+ {children} +
+
+
); } diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index fccd229..11a38b8 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -24,7 +24,7 @@ function DialogOverlay({ className, ...props }: React.ComponentProps Close @@ -84,7 +84,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { return ( -
+
) } diff --git a/frontend/src/features/datasource/components/CreateInstanceForm.tsx b/frontend/src/features/datasource/components/CreateInstanceForm.tsx index 535d7cd..add901f 100644 --- a/frontend/src/features/datasource/components/CreateInstanceForm.tsx +++ b/frontend/src/features/datasource/components/CreateInstanceForm.tsx @@ -5,8 +5,17 @@ */ import { useState, useCallback } from 'react'; import { - Database, Server, Zap, Activity, AlertTriangle, CheckCircle, XCircle, - ChevronRight, Eye, EyeOff, Loader2, + Database, + Server, + Zap, + Activity, + AlertTriangle, + CheckCircle, + XCircle, + ChevronRight, + Eye, + EyeOff, + Loader2, } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; import { ModalOverlay } from '@/components/ModalOverlay'; @@ -16,9 +25,22 @@ import { MySQLIcon, PostgreSQLIcon, RedisIcon, MongoDBIcon } from './DbTypeIcons /** 预置脱敏字段集合(高级配置一键填入用) */ const PRESET_MASK_FIELDS = [ - 'mobile', 'concat_mobile', 'email_alias', 'email', 'password', - 'ga_key', 'private_key', 'secret', 'secret_key', 'key_snow', - 'telephone', 'username', 'real_name', 'token', 'two_factor_secret', 'content', + 'mobile', + 'concat_mobile', + 'email_alias', + 'email', + 'password', + 'ga_key', + 'private_key', + 'secret', + 'secret_key', + 'key_snow', + 'telephone', + 'username', + 'real_name', + 'token', + 'two_factor_secret', + 'content', ]; /** 创建向导的四个步骤(静态序列) */ @@ -60,7 +82,12 @@ export function CreateInstanceForm({ defaultType, onCancel, onCreated }: CreateI const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(''); const [testing, setTesting] = useState(false); - const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string; cluster_mode?: boolean } | null>(null); + const [testResult, setTestResult] = useState<{ + success: boolean; + message: string; + version?: string; + cluster_mode?: boolean; + } | null>(null); const isRedis = dbType === 'redis'; const needUsername = !isRedis; // Redis username is optional (6.0+ ACL) const needDatabase = !isRedis; @@ -71,28 +98,59 @@ export function CreateInstanceForm({ defaultType, onCancel, onCreated }: CreateI setDbType(t); setPort(String(DB_DEFAULT_PORTS[t] || 3306)); setTestResult(null); - if (t === 'redis') { setDatabase(''); } else { setRedisClusterMode(false); } + if (t === 'redis') { + setDatabase(''); + } else { + setRedisClusterMode(false); + } }, []); const handleSubmit = useCallback(async () => { - if (!name) { setError('请填写名称'); return; } - if (!host || !port) { setError('请填写地址和端口'); return; } - if (needUsername && !username) { setError('请填写用户名'); return; } - setSubmitting(true); setError(''); + if (!name) { + setError('请填写名称'); + return; + } + if (!host || !port) { + setError('请填写地址和端口'); + return; + } + if (needUsername && !username) { + setError('请填写用户名'); + return; + } + setSubmitting(true); + setError(''); try { await instanceApi.create({ - name, db_type: dbType, host, port: Number(port), - username: isRedis ? (username || undefined) : (username || 'default'), + name, + db_type: dbType, + host, + port: Number(port), + username: isRedis ? username || undefined : username || 'default', password: password || 'default', database: database || undefined, cluster_mode: isRedis ? redisClusterMode : undefined, env: (finalEnv || 'development') as string, access_mode: accessMode, - excluded_databases: excludedDbs ? excludedDbs.split(',').map(s => s.trim()).filter(Boolean) : undefined, - mask_fields: maskFields ? maskFields.split('\n').filter(Boolean).map(line => { - const parts = line.split(':'); - return { table: parts[0]?.trim() || '', field: parts[1]?.trim() || parts[0]?.trim() || '', rule: parts[2]?.trim() || 'mask' }; - }) : undefined, + excluded_databases: excludedDbs + ? excludedDbs + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : undefined, + mask_fields: maskFields + ? maskFields + .split('\n') + .filter(Boolean) + .map((line) => { + const parts = line.split(':'); + return { + table: parts[0]?.trim() || '', + field: parts[1]?.trim() || parts[0]?.trim() || '', + rule: parts[2]?.trim() || 'mask', + }; + }) + : undefined, }); onCreated(); } catch (e: unknown) { @@ -101,20 +159,48 @@ export function CreateInstanceForm({ defaultType, onCancel, onCreated }: CreateI } finally { setSubmitting(false); } - }, [name, host, port, dbType, username, password, database, redisClusterMode, finalEnv, accessMode, excludedDbs, maskFields, needUsername, isRedis, onCreated]); + }, [ + name, + host, + port, + dbType, + username, + password, + database, + redisClusterMode, + finalEnv, + accessMode, + excludedDbs, + maskFields, + needUsername, + isRedis, + onCreated, + ]); const handleTestConnection = useCallback(async () => { - if (!host || !port) { setError('请填写地址和端口'); return; } - if (needUsername && !username) { setError('请填写用户名'); return; } - setTesting(true); setError(''); setTestResult(null); + if (!host || !port) { + setError('请填写地址和端口'); + return; + } + if (needUsername && !username) { + setError('请填写用户名'); + return; + } + setTesting(true); + setError(''); + setTestResult(null); try { const res = await instanceApi.testNewConnection({ - name: name || '__test__', db_type: dbType, host, port: Number(port), - username: isRedis ? (username || undefined) : (username || 'default'), + name: name || '__test__', + db_type: dbType, + host, + port: Number(port), + username: isRedis ? username || undefined : username || 'default', password: password || 'default', database: database || undefined, cluster_mode: isRedis ? redisClusterMode : undefined, - env: (finalEnv || 'development') as string, access_mode: accessMode, + env: (finalEnv || 'development') as string, + access_mode: accessMode, }); const result = res as { success: boolean; message: string; version?: string; cluster_mode?: boolean }; if (isRedis && result.success) { @@ -127,426 +213,538 @@ export function CreateInstanceForm({ defaultType, onCancel, onCreated }: CreateI } finally { setTesting(false); } - }, [host, port, dbType, username, password, database, redisClusterMode, finalEnv, accessMode, needUsername, name, isRedis]); + }, [ + host, + port, + dbType, + username, + password, + database, + redisClusterMode, + finalEnv, + accessMode, + needUsername, + name, + isRedis, + ]); - const validateStep = useCallback((step: number): boolean => { - setError(''); - if (step === 1) { - if (!name) { setError('请填写实例名称'); return false; } - } - if (step === 2) { - if (!host || !port) { setError('请填写地址和端口'); return false; } - if (needUsername && !username) { setError('请填写用户名'); return false; } - } - return true; - }, [name, host, port, needUsername, username]); + const validateStep = useCallback( + (step: number): boolean => { + setError(''); + if (step === 1) { + if (!name) { + setError('请填写实例名称'); + return false; + } + } + if (step === 2) { + if (!host || !port) { + setError('请填写地址和端口'); + return false; + } + if (needUsername && !username) { + setError('请填写用户名'); + return false; + } + } + return true; + }, + [name, host, port, needUsername, username], + ); const goNext = useCallback(() => { if (validateStep(currentStep)) { - setCurrentStep(s => Math.min(s + 1, formSteps.length - 1)); + setCurrentStep((s) => Math.min(s + 1, formSteps.length - 1)); } }, [currentStep, validateStep]); const goPrev = useCallback(() => { setError(''); - setCurrentStep(s => Math.max(s - 1, 0)); + setCurrentStep((s) => Math.max(s - 1, 0)); }, []); - const inputCls = 'w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors'; + const inputCls = + 'w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors'; const labelCls = 'block text-sm font-medium mb-1.5'; return ( <> - -
- {/* Header */} -
-
-

添加数据源

-

步骤 {currentStep + 1} / {formSteps.length}: {formSteps[currentStep].label}

+ +
+ {/* Header */} +
+
+

添加数据源

+

+ 步骤 {currentStep + 1} / {formSteps.length}: {formSteps[currentStep].label} +

+
+
- -
- {/* Step Indicator */} -
-
- {formSteps.map((step, idx) => ( -
- - {idx < formSteps.length - 1 && ( - - )} -
- ))} -
-
- -
- {/* Step 0: Type Selection */} - {currentStep === 0 && ( - - -

选择数据源类型

-
- {[ - { value: 'mysql', label: 'MySQL', Icon: MySQLIcon }, - { value: 'postgres', label: 'PostgreSQL', Icon: PostgreSQLIcon }, - { value: 'redis', label: 'Redis', Icon: RedisIcon }, - { value: 'mongo', label: 'MongoDB', Icon: MongoDBIcon }, - ].map(t => ( - - ))} -
-
-
- )} - - {/* Step 1: Basic Info */} - {currentStep === 1 && ( - - -

基本信息

-
-
- - { setName(e.target.value); setTestResult(null); }} - className={inputCls} - placeholder="例如:prod-mysql-只读库" + {/* Step Indicator */} +
+
+ {formSteps.map((step, idx) => ( +
+ + {idx < formSteps.length - 1 && ( + -
-
- -
- - {env === '__custom__' && ( - setCustomEnv(e.target.value)} - className={inputCls} - placeholder="输入自定义环境名" - /> - )} -
-
-
- {isRedis && ( -
-

- - Redis 6.0+ ACL 支持用户名+密码认证;传统模式仅填密码即可 -

-
- )} - - - )} - - {/* Step 2: Connection Info */} - {currentStep === 2 && ( - - -

连接信息

-
-
- - { setHost(e.target.value); setTestResult(null); }} - className={inputCls} - placeholder="例如:mysql.example.com" - /> -
-
- - { setPort(e.target.value); setTestResult(null); }} - className={inputCls} - placeholder={String(DB_DEFAULT_PORTS[dbType] || 3306)} - /> -
-
- - { setUsername(e.target.value); setTestResult(null); }} - className={inputCls} - placeholder={isRedis ? 'ACL 用户名,留空则仅密码认证' : 'root'} - /> -
-
-
-
- -
- { setPassword(e.target.value); setTestResult(null); }} - className={inputCls + ' pr-10'} - placeholder={isRedis ? '可选,Redis 无密码可留空' : '数据库密码'} - /> - -
-
- {needDatabase ? ( -
- - { setDatabase(e.target.value); setTestResult(null); }} - className={inputCls} - placeholder="可选,留空则连接默认库" - /> -
- ) : ( -
- - setRedisDb(e.target.value)} - className={inputCls} - placeholder="默认 0" - /> -
)}
- {isRedis && ( -
- -
- {[ - { value: false, label: '单节点', desc: '普通 Redis 或哨兵入口,查询只访问当前实例' }, - { value: true, label: '集群', desc: 'Redis Cluster,按 slot 路由并扫描所有 master' }, - ].map(mode => ( + ))} +
+
+ +
+ {/* Step 0: Type Selection */} + {currentStep === 0 && ( + + +

选择数据源类型

+
+ {[ + { value: 'mysql', label: 'MySQL', Icon: MySQLIcon }, + { value: 'postgres', label: 'PostgreSQL', Icon: PostgreSQLIcon }, + { value: 'redis', label: 'Redis', Icon: RedisIcon }, + { value: 'mongo', label: 'MongoDB', Icon: MongoDBIcon }, + ].map((t) => ( + + ))} +
+
+
+ )} + + {/* Step 1: Basic Info */} + {currentStep === 1 && ( + + +

基本信息

+
+
+ + { + setName(e.target.value); + setTestResult(null); + }} + className={inputCls} + placeholder="例如:prod-mysql-只读库" + /> +
+
+ +
+ + {env === '__custom__' && ( + setCustomEnv(e.target.value)} + className={inputCls} + placeholder="输入自定义环境名" + /> + )} +
+
+
+ {isRedis && ( +
+

+ + Redis 6.0+ ACL 支持用户名+密码认证;传统模式仅填密码即可 +

+
+ )} +
+
+ )} + + {/* Step 2: Connection Info */} + {currentStep === 2 && ( + + +

连接信息

+
+
+ + { + setHost(e.target.value); + setTestResult(null); + }} + className={inputCls} + placeholder="例如:mysql.example.com" + /> +
+
+ + { + setPort(e.target.value); + setTestResult(null); + }} + className={inputCls} + placeholder={String(DB_DEFAULT_PORTS[dbType] || 3306)} + /> +
+
+ + { + setUsername(e.target.value); + setTestResult(null); + }} + className={inputCls} + placeholder={isRedis ? 'ACL 用户名,留空则仅密码认证' : 'root'} + /> +
+
+
+
+ +
+ { + setPassword(e.target.value); + setTestResult(null); + }} + className={inputCls + ' pr-10'} + placeholder={isRedis ? '可选,Redis 无密码可留空' : '数据库密码'} + /> +
+
+ {needDatabase ? ( +
+ + { + setDatabase(e.target.value); + setTestResult(null); + }} + className={inputCls} + placeholder="可选,留空则连接默认库" + /> +
+ ) : ( +
+ + setRedisDb(e.target.value)} + className={inputCls} + placeholder="默认 0" + /> +
+ )} +
+ {isRedis && ( +
+ +
+ {[ + { value: false, label: '单节点', desc: '普通 Redis 或哨兵入口,查询只访问当前实例' }, + { value: true, label: '集群', desc: 'Redis Cluster,按 slot 路由并扫描所有 master' }, + ].map((mode) => ( + + ))} +
+
+ )} + {/* Connection Mode */} +
+ +
+ {[ + { value: 'readwrite' as const, label: '读写' }, + { value: 'write' as const, label: '写' }, + { value: 'read' as const, label: '读' }, + ].map((m) => ( + ))}
- )} - {/* Connection Mode */} -
- -
- {[ - { value: 'readwrite' as const, label: '读写' }, - { value: 'write' as const, label: '写' }, - { value: 'read' as const, label: '读' }, - ].map(m => ( - - ))} -
-
- {/* Test Result */} - {testResult && ( -
- {testResult.success - ? <> 连接成功{testResult.version ? ` · ${testResult.version}` : ''}{isRedis ? ` · ${testResult.cluster_mode ? '集群' : '单节点'}` : ''} - : <> {testResult.message} - } -
- )} -
-
- )} - - {/* Step 3: Advanced Config */} - {currentStep === 3 && ( - - -

高级配置

- {!isRedis && ( -
- - setExcludedDbs(e.target.value)} - className={inputCls} - placeholder="以逗号分隔,如:sys, information_schema, mysql" - /> -
- )} -
-
- -
- - + {/* Test Result */} + {testResult && ( +
+ {testResult.success ? ( + <> + 连接成功 + {testResult.version ? ` · ${testResult.version}` : ''} + {isRedis ? ` · ${testResult.cluster_mode ? '集群' : '单节点'}` : ''} + + ) : ( + <> + {testResult.message} + + )}
-
-
- {PRESET_MASK_FIELDS.map(f => { - const isSelected = maskFields.split(/[\n,:]/).some(s => s.trim() === f); - return ( + )} + + + )} + + {/* Step 3: Advanced Config */} + {currentStep === 3 && ( + + +

高级配置

+ {!isRedis && ( +
+ + setExcludedDbs(e.target.value)} + className={inputCls} + placeholder="以逗号分隔,如:sys, information_schema, mysql" + /> +
+ )} +
+
+ +
- ); - })} + +
+
+
+ {PRESET_MASK_FIELDS.map((f) => { + const isSelected = maskFields.split(/[\n,:]/).some((s) => s.trim() === f); + return ( + + ); + })} +
+