diff --git a/frontend/src/pages/TicketCreatePage.tsx b/frontend/src/pages/TicketCreatePage.tsx index 0783537..f5979b6 100644 --- a/frontend/src/pages/TicketCreatePage.tsx +++ b/frontend/src/pages/TicketCreatePage.tsx @@ -4,7 +4,7 @@ import { ChevronRight, Check, AlertTriangle, Shield, Database, FileText, Sparkles, RotateCcw, Loader2, Info, ChevronDown, ChevronUp, Table2, Columns3, FolderOpen, - KeyRound, ListTree, Search, Hash, Clock, BarChart3, + KeyRound, Search, Hash, Clock, BarChart3, X, Zap, Copy, CheckCheck, } from 'lucide-react'; import { ticketApi } from '@/api/ticket'; @@ -38,12 +38,6 @@ interface AuditResult { findings: AuditFinding[]; } -interface StepConfig { - num: number; - label: string; - icon: React.ElementType; -} - interface RedisKeyItem { key: string; type: string; @@ -55,12 +49,6 @@ interface RedisKeyItem { /* Constants */ /* ------------------------------------------------------------------ */ -const STEPS: StepConfig[] = [ - { num: 1, label: '填写信息', icon: FileText }, - { num: 2, label: 'SQL 审核', icon: Shield }, - { num: 3, label: '确认提交', icon: Check }, -]; - const LEVEL_COLOR: Record = { error: 'text-red-400 bg-red-500/15', warning: 'text-amber-400 bg-amber-500/15', @@ -73,69 +61,14 @@ const LEVEL_LABEL: Record = { info: '提示', }; -const RISK_LABEL: Record = { - low: '低风险', - medium: '中风险', - high: '高风险', -}; - -const RISK_COLOR: Record = { - low: 'text-green-400 bg-green-500/15 border-green-500/30', - medium: 'text-amber-400 bg-amber-500/15 border-amber-500/30', - high: 'text-red-400 bg-red-500/15 border-red-500/30', -}; - -const PRIORITY_OPTIONS = [ - { value: 'low', label: '低' }, - { value: 'medium', label: '中' }, - { value: 'high', label: '高' }, -]; - -const DB_TYPE_OPTIONS = [ - { value: '', label: '请选择数据库类型' }, - { value: 'mysql', label: 'MySQL' }, - { value: 'redis', label: 'Redis' }, - { value: 'postgres', label: 'PostgreSQL' }, - { value: 'mongodb', label: 'MongoDB' }, -]; - const DB_TYPE_LABELS: Record = { - mysql: 'MySQL', - redis: 'Redis', - postgres: 'PostgreSQL', - postgresql: 'PostgreSQL', - mongodb: 'MongoDB', + mysql: 'MySQL', redis: 'Redis', postgres: 'PostgreSQL', postgresql: 'PostgreSQL', mongodb: 'MongoDB', }; /* ------------------------------------------------------------------ */ -/* Helper */ +/* Helpers */ /* ------------------------------------------------------------------ */ -function generateRollbackSuggestion(sql: string): string { - const upper = sql.toUpperCase().trim(); - if (upper.startsWith('UPDATE')) { - const tableMatch = sql.match(/UPDATE\s+(\w+)/i); - const table = tableMatch ? tableMatch[1] : 'table'; - const whereMatch = sql.match(/WHERE\s+(.+)/i); - const where = whereMatch ? whereMatch[1].trim() : ''; - return `-- 建议回滚语句(请根据实际业务调整):\n-- UPDATE ${table} SET ... WHERE ${where || '...'};`; - } - if (upper.startsWith('DELETE')) { - const tableMatch = sql.match(/FROM\s+(\w+)/i) || sql.match(/DELETE\s+(\w+)/i); - const table = tableMatch ? tableMatch[1] : 'table'; - return `-- 建议回滚语句:\n-- INSERT INTO ${table} SELECT * FROM ${table}_backup WHERE ...;\n-- 或从 Binlog 恢复`; - } - if (upper.startsWith('ALTER')) { - return `-- DDL 回滚说明:\n-- 1. ALTER TABLE 操作无法直接回滚\n-- 2. 建议先使用 pt-online-schema-change 或 gh-ost 进行在线变更\n-- 3. 执行前请确保有完整备份`; - } - if (upper.startsWith('INSERT')) { - const tableMatch = sql.match(/INTO\s+(\w+)/i); - const table = tableMatch ? tableMatch[1] : 'table'; - return `-- 建议回滚语句:\n-- DELETE FROM ${table} WHERE ...;\n-- 请根据 INSERT 条件编写对应的 DELETE 语句`; - } - return `-- 请根据业务逻辑编写回滚语句`; -} - function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; const k = 1024; @@ -146,10 +79,10 @@ function formatBytes(bytes: number): string { function formatTTL(ttl: number): string { if (ttl < 0) return '永不过期'; - if (ttl < 60) return `${ttl}s`; - if (ttl < 3600) return `${Math.floor(ttl / 60)}m`; - if (ttl < 86400) return `${Math.floor(ttl / 3600)}h`; - return `${Math.floor(ttl / 86400)}d`; + if (ttl < 60) return ttl + 's'; + if (ttl < 3600) return Math.floor(ttl / 60) + 'm'; + if (ttl < 86400) return Math.floor(ttl / 3600) + 'h'; + return Math.floor(ttl / 86400) + 'd'; } /* ================================================================== */ @@ -159,16 +92,11 @@ function formatTTL(ttl: number): string { export default function TicketCreatePage() { const navigate = useNavigate(); - // -- Step state -- - const [step, setStep] = useState(1); - // -- Form fields -- const [title, setTitle] = useState(''); - const [priority, setPriority] = useState('medium'); const [instanceId, setInstanceId] = useState(''); const [database, setDatabase] = useState(''); const [sqlContent, setSqlContent] = useState(''); - const [rollbackSql, setRollbackSql] = useState(''); const [reason, setReason] = useState(''); // -- Data / Loading -- @@ -176,9 +104,10 @@ export default function TicketCreatePage() { const [databases, setDatabases] = useState([]); const [loadingInstances, setLoadingInstances] = useState(false); const [loadingDatabases, setLoadingDatabases] = useState(false); - const [auditing, setAuditing] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(''); + + // -- Inline audit result -- const [auditResult, setAuditResult] = useState(null); // -- Schema Browser State -- @@ -216,19 +145,14 @@ export default function TicketCreatePage() { .finally(() => setLoadingInstances(false)); }, []); - // 从选中实例自动获取 dbType const selectedInstance = useMemo(() => instances.find((i) => i.id === Number(instanceId)), [instances, instanceId]); const dbType = selectedInstance?.db_type || ''; useEffect(() => { if (!instanceId) { setDatabases([]); setDatabase(''); return; } - // 立即重置,避免旧 database 残留触发 tables 请求 - setDatabase(''); - setDatabases([]); - setTables([]); + setDatabase(''); setDatabases([]); setTables([]); const inst = instances.find(i => i.id === Number(instanceId)); if (!inst) return; - // 所有类型都走后端 listDatabases API(Redis 返回 db0-db15, MongoDB 返回库名列表) setLoadingDatabases(true); instanceApi.listDatabases(Number(instanceId)) .then((res: unknown) => { @@ -250,10 +174,7 @@ export default function TicketCreatePage() { useEffect(() => { if (!instanceId || !database) { setTables([]); return; } const inst = instances.find(i => i.id === Number(instanceId)); - if (!inst) return; - // Redis 无表概念,跳过 - if (inst.db_type === 'redis') { setTables([]); return; } - // MySQL / PG / MongoDB 都走 listTables API + if (!inst || inst.db_type === 'redis') { setTables([]); return; } setLoadingTables(true); instanceDetailApi.listTables(Number(instanceId), database) .then((data) => setTables(Array.isArray(data) ? data : [])) @@ -261,15 +182,9 @@ export default function TicketCreatePage() { .finally(() => setLoadingTables(false)); }, [instanceId, database, instances]); - /* ---------------------------------------------------------------- */ - /* Fetch table detail (columns + indexes) */ - /* ---------------------------------------------------------------- */ - const handleExpandTable = useCallback(async (tableName: string) => { if (expandedTable === tableName) { setExpandedTable(null); return; } - setExpandedTable(tableName); - setTableDetail(null); - setLoadingDetail(true); + setExpandedTable(tableName); setTableDetail(null); setLoadingDetail(true); try { const detail = await instanceDetailApi.getTableDetail(Number(instanceId), database, tableName); setTableDetail(detail); @@ -286,15 +201,12 @@ export default function TicketCreatePage() { setLoadingRedisKeys(true); try { const res = await redisApi.scanKeys(Number(instanceId), { - db: redisDb, - pattern: redisPattern || '*', - count: 100, + db: redisDb, pattern: redisPattern || '*', count: 100, }); const keys = (res?.keys || []).map((k: any) => ({ key: typeof k === 'string' ? k : k.key, type: typeof k === 'string' ? 'string' : (k.type || 'string'), - ttl: k.ttl ?? -1, - size: k.size || 0, + ttl: k.ttl ?? -1, size: k.size || 0, })); setRedisKeys(keys); } catch { setRedisKeys([]); } @@ -303,18 +215,11 @@ export default function TicketCreatePage() { useEffect(() => { const inst = instances.find(i => i.id === Number(instanceId)); - if (inst?.db_type === 'redis' && browserTab === 'redis') { - fetchRedisKeys(); - } + if (inst?.db_type === 'redis' && browserTab === 'redis') fetchRedisKeys(); }, [instanceId, browserTab, fetchRedisKeys, instances]); - /* ---------------------------------------------------------------- */ - /* Fetch Redis key detail */ - /* ---------------------------------------------------------------- */ - const handleSelectRedisKey = useCallback(async (key: string) => { - setSelectedRedisKey(key); - setLoadingRedisDetail(true); + setSelectedRedisKey(key); setLoadingRedisDetail(true); try { const detail = await redisApi.getKeyDetail(Number(instanceId), key, redisDb); setRedisKeyDetail(detail); @@ -323,7 +228,7 @@ export default function TicketCreatePage() { }, [instanceId, redisDb]); /* ---------------------------------------------------------------- */ - /* Insert table/column/key into SQL editor */ + /* Insert / change helpers */ /* ---------------------------------------------------------------- */ const insertToEditor = useCallback((text: string) => { @@ -333,51 +238,55 @@ export default function TicketCreatePage() { }); }, []); - const handleSqlChange = useCallback((val: string) => { - setSqlContent(val); - if (val.trim().length > 10) setRollbackSql(generateRollbackSuggestion(val)); - }, []); - const handleFormatSQL = useCallback(() => { if (!sqlContent.trim()) return; setSqlContent(formatSQL(sqlContent)); }, [sqlContent]); + // SQL 内容变化时清除上次审核结果 + const handleSqlChange = useCallback((val: string) => { + setSqlContent(val); + if (auditResult) setAuditResult(null); + }, [auditResult]); + /* ---------------------------------------------------------------- */ - /* Audit handler */ + /* Submit: audit + create in one click */ /* ---------------------------------------------------------------- */ - const handleAudit = useCallback(async () => { - if (!sqlContent.trim() || !instanceId) { setError('请填写 SQL 语句并选择实例'); return; } - setAuditing(true); setError(''); + const handleSubmit = useCallback(async () => { + // 1. 表单校验 + if (!title.trim()) { setError('请填写工单标题'); return; } + if (!instanceId) { setError('请选择目标实例'); return; } + if (!database) { setError('请选择数据库'); return; } + if (!sqlContent.trim()) { setError('请填写 SQL 语句'); return; } + if (!reason.trim()) { setError('请填写变更原因'); return; } + + setError(''); setAuditResult(null); setSubmitting(true); + try { + // 2. 先审核 const result = await ticketApi.audit({ sql_content: sqlContent, instance_id: Number(instanceId), database, }) as unknown as AuditResult; setAuditResult(result); - setStep(2); - } catch (e: unknown) { - const err = e as { response?: { data?: { detail?: string } } }; - setError(err?.response?.data?.detail || '审核失败,请检查 SQL 语法'); - } finally { setAuditing(false); } - }, [sqlContent, instanceId, database]); - /* ---------------------------------------------------------------- */ - /* Submit handler */ - /* ---------------------------------------------------------------- */ + // 3. 有 error 级别违规则阻止提交 + const hasErrors = result.findings?.some((f) => f.level === 'error'); + if (hasErrors) { + setError('SQL 审核未通过,请修改后重新提交'); + return; + } - const handleSubmit = useCallback(async () => { - if (!title.trim() || !sqlContent.trim() || !instanceId || !database || !reason.trim()) { - setError('请填写所有必填项'); return; - } - setSubmitting(true); setError(''); - try { + // 4. 提交工单 const inst = instances.find((i) => i.id === Number(instanceId)); await ticketApi.create({ - title: title.trim(), db_type: inst?.db_type || 'mysql', - instance_id: Number(instanceId), target_database: database, - sql_content: sqlContent, rollback_sql: rollbackSql || undefined, - reason: reason.trim(), priority: priority, + title: title.trim(), + db_type: inst?.db_type || 'mysql', + instance_id: Number(instanceId), + target_database: database, + sql_content: sqlContent, + reason: reason.trim(), + priority: 'medium', }); navigate('/tickets'); } catch (e: unknown) { @@ -386,7 +295,7 @@ export default function TicketCreatePage() { const msg = Array.isArray(raw) ? raw.map((v) => v.msg).join('; ') : (raw || '提交失败,请稍后重试'); setError(msg); } finally { setSubmitting(false); } - }, [title, sqlContent, instanceId, database, reason, priority, rollbackSql, instances, navigate]); + }, [title, sqlContent, instanceId, database, reason, instances, navigate]); /* ---------------------------------------------------------------- */ /* Derived values */ @@ -396,56 +305,14 @@ export default function TicketCreatePage() { const isSQL = selectedInstance?.db_type === 'mysql' || selectedInstance?.db_type === 'postgresql' || selectedInstance?.db_type === 'postgres'; const isMongo = selectedInstance?.db_type === 'mongo'; - const riskLevel = useMemo(() => { - if (!auditResult) return ''; - if (auditResult.score >= 90) return 'low'; - if (auditResult.score >= 60) return 'medium'; - return 'high'; - }, [auditResult]); - - const isStep1Valid = title.trim() && sqlContent.trim() && instanceId && database && reason.trim(); - - /* ---------------------------------------------------------------- */ - /* Copy helper */ - /* ---------------------------------------------------------------- */ + const errorCount = auditResult?.findings?.filter(f => f.level === 'error').length || 0; + const warnCount = auditResult?.findings?.filter(f => f.level === 'warning').length || 0; + const infoCount = auditResult?.findings?.filter(f => f.level === 'info').length || 0; const copyToClipboard = useCallback(async (text: string) => { try { await navigator.clipboard.writeText(text); setCopiedDDL(true); setTimeout(() => setCopiedDDL(false), 2000); } catch { } }, []); - /* ---------------------------------------------------------------- */ - /* RENDER: Step Indicator */ - /* ---------------------------------------------------------------- */ - - const renderStepIndicator = () => ( -
- {STEPS.map((s, idx) => { - const isActive = step === s.num; - const isDone = step > s.num; - const StepIcon = s.icon; - return ( -
-
- {isDone ? : } - {s.label} -
- {idx < STEPS.length - 1 && } -
- ); - })} -
- ); - - const renderError = () => error ? ( -
- - {error} -
- ) : null; - /* ================================================================== */ /* BROWSER PANEL — Tables / Indexes / Redis Keys */ /* ================================================================== */ @@ -453,42 +320,30 @@ export default function TicketCreatePage() { const renderBrowserPanel = () => { if (!instanceId || !database) return null; - // Determine available tabs const tabs: { id: 'tables' | 'indexes' | 'redis' | 'collections'; label: string; icon: React.ElementType }[] = []; if (isSQL) { tabs.push({ id: 'tables', label: '表结构', icon: Table2 }); tabs.push({ id: 'indexes', label: '索引', icon: BarChart3 }); } - if (isMongo) { - tabs.push({ id: 'collections', label: '集合', icon: Table2 }); - } - if (isRedis) { - tabs.push({ id: 'redis', label: 'Redis Keys', icon: KeyRound }); - } + if (isMongo) tabs.push({ id: 'collections', label: '集合', icon: Table2 }); + if (isRedis) tabs.push({ id: 'redis', label: 'Redis Keys', icon: KeyRound }); if (tabs.length === 0) return null; return (
- {/* Tab Header */}
{tabs.map(tab => ( - ))} 点击插入到 SQL
- - {/* Tab content */}
{browserTab === 'tables' && renderTablesTab()} {browserTab === 'indexes' && renderIndexesTab()} @@ -509,35 +364,24 @@ export default function TicketCreatePage() { if (tables.length === 0) return (
无可用表
); - return (
{tables.map(t => (
- {/* Table Row */} - - - {/* Expanded: Columns + DDL */} {expandedTable === t.name && (
{loadingDetail ? ( @@ -546,15 +390,11 @@ export default function TicketCreatePage() {
) : tableDetail ? (
- {/* Columns */}
{tableDetail.columns.map((col) => ( - ))}
- - {/* DDL Preview */} {tableDetail.ddl && (
CREATE TABLE -
-
-                          {tableDetail.ddl}
-                        
+
{tableDetail.ddl}
)}
- ) : ( -

字段信息不可用

- )} + ) :

字段信息不可用

}
)}
@@ -596,17 +428,13 @@ export default function TicketCreatePage() { /* ---- Indexes Tab ---- */ const renderIndexesTab = () => { if (!expandedTable || !tableDetail?.indexes) return ( -
- 请先选择一个表查看索引 -
+
请先选择一个表查看索引
); - const grouped = tableDetail.indexes.reduce((acc, idx) => { if (!acc[idx.Key_name]) acc[idx.Key_name] = []; acc[idx.Key_name].push(idx); return acc; }, {} as Record); - return (
@@ -618,22 +446,15 @@ export default function TicketCreatePage() {
{keyName} - + {keyName === 'PRIMARY' ? '主键' : idxs[0]?.Index_type || '索引'} - - {idxs[0]?.Cardinality?.toLocaleString()} 基数 - + {idxs[0]?.Cardinality?.toLocaleString()} 基数
{idxs.map((idx, i) => ( - ))} @@ -644,16 +465,14 @@ export default function TicketCreatePage() { ); }; - /* ---- Redis Tab ---- */ - /* ---- Redis Tree Node type ---- */ + /* ---- Redis Tree ---- */ interface RedisTreeNode { name: string; - fullKey?: string; // only leaf nodes - type?: string; // key type: string/hash/list/set/zset + fullKey?: string; + type?: string; children?: RedisTreeNode[]; } - /** Build a tree from flat key list using ':' as delimiter */ const buildRedisTree = useCallback((keys: RedisKeyItem[]): RedisTreeNode[] => { const root: RedisTreeNode[] = []; for (const k of keys) { @@ -662,14 +481,9 @@ export default function TicketCreatePage() { for (let i = 0; i < parts.length; i++) { const part = parts[i]; let node = nodes.find(n => n.name === part); - if (!node) { - node = { name: part, children: [] }; - nodes.push(node); - } + if (!node) { node = { name: part, children: [] }; nodes.push(node); } if (i === parts.length - 1) { - node.fullKey = k.key; - node.type = k.type; - delete node.children; + node.fullKey = k.key; node.type = k.type; delete node.children; } else { if (!node.children) node.children = []; nodes = node.children; @@ -677,10 +491,7 @@ export default function TicketCreatePage() { } } const sortNodes = (arr: RedisTreeNode[]) => { - arr.sort((a, b) => { - if (!!a.fullKey !== !!b.fullKey) return a.fullKey ? 1 : -1; - return a.name.localeCompare(b.name); - }); + arr.sort((a, b) => { if (!!a.fullKey !== !!b.fullKey) return a.fullKey ? 1 : -1; return a.name.localeCompare(b.name); }); arr.forEach(n => { if (n.children) sortNodes(n.children); }); }; sortNodes(root); @@ -690,35 +501,25 @@ export default function TicketCreatePage() { const [redisExpanded, setRedisExpanded] = useState>(new Set()); const toggleRedisFolder = useCallback((path: string) => { - setRedisExpanded(prev => { - const next = new Set(prev); - if (next.has(path)) next.delete(path); else next.add(path); - return next; - }); + setRedisExpanded(prev => { const next = new Set(prev); if (next.has(path)) next.delete(path); else next.add(path); return next; }); }, []); const redisCmdForKey = useCallback((key: string, type: string): string => { switch (type) { - case 'string': return `GET ${key}`; - case 'hash': return `HGETALL ${key}`; - case 'list': return `LRANGE ${key} 0 -1`; - case 'set': return `SMEMBERS ${key}`; - case 'zset': return `ZRANGE ${key} 0 -1 WITHSCORES`; - default: return `GET ${key}`; + case 'hash': return 'HGETALL ' + key; + case 'list': return 'LRANGE ' + key + ' 0 -1'; + case 'set': return 'SMEMBERS ' + key; + case 'zset': return 'ZRANGE ' + key + ' 0 -1 WITHSCORES'; + default: return 'GET ' + key; } }, []); const keyTypeColor: Record = { - string: 'text-emerald-400', - hash: 'text-blue-400', - list: 'text-purple-400', - set: 'text-orange-400', - zset: 'text-rose-400', + string: 'text-emerald-400', hash: 'text-blue-400', list: 'text-purple-400', set: 'text-orange-400', zset: 'text-rose-400', }; - /** Recursive tree renderer — single column with inline detail */ const renderRedisTreeNode = useCallback((node: RedisTreeNode, depth: number, parentPath: string): React.ReactNode => { - const path = parentPath ? `${parentPath}:${node.name}` : node.name; + const path = parentPath ? parentPath + ':' + node.name : node.name; const isLeaf = !!node.fullKey; const isExpanded = redisExpanded.has(path); const indent = depth * 14; @@ -734,22 +535,19 @@ export default function TicketCreatePage() { setSqlContent(prev => prev + (prev && !prev.endsWith('\n') ? '\n' : '') + cmd); handleSelectRedisKey(node.fullKey!); }} - className={`w-full flex items-center gap-1.5 py-1 text-[11px] font-mono transition-colors group ${ + className={'w-full flex items-center gap-1.5 py-1 text-[11px] font-mono transition-colors group ' + ( isSelected ? 'text-blue-400' : 'hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]' - }`} - style={{ paddingLeft: `${indent + 6}px` }}> - + )} + style={{ paddingLeft: indent + 6 + 'px' }}> + {node.name} {node.type} 插入 - {/* Inline key detail */} {isSelected && (
{loadingRedisDetail ? ( -
- 加载... -
+
加载...
) : redisKeyDetail ? (
@@ -758,9 +556,7 @@ export default function TicketCreatePage() { {redisKeyDetail.size && {formatBytes(redisKeyDetail.size)}}
-                    {typeof redisKeyDetail.value === 'object'
-                      ? JSON.stringify(redisKeyDetail.value, null, 2)
-                      : String(redisKeyDetail.value)}
+                    {typeof redisKeyDetail.value === 'object' ? JSON.stringify(redisKeyDetail.value, null, 2) : String(redisKeyDetail.value)}
                   
) : null} @@ -770,22 +566,19 @@ export default function TicketCreatePage() { ); } - // folder node const childCount = node.children?.length || 0; return (
{isExpanded && ( -
+
{node.children?.map(child => renderRedisTreeNode(child, depth + 1, path))}
)} @@ -797,65 +590,41 @@ export default function TicketCreatePage() { const renderRedisTab = () => (
- {/* Search Bar */}
- { setRedisDb(Number(e.target.value)); fetchRedisKeys(); }} + className="px-2 py-1.5 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500"> {databases.length > 0 ? databases.map(db => ) - : Array.from({ length: 16 }, (_, i) => ) - } + : Array.from({ length: 16 }, (_, i) => )}
- setRedisPattern(e.target.value)} + setRedisPattern(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && fetchRedisKeys()} className="w-full pl-7 pr-2 py-1.5 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500" - placeholder="matcher:*" - /> + placeholder="matcher:*" />
-
- - {/* Key Tree — single column with inline detail */}
- {redisKeys.length > 0 ? ( - redisTree.map(node => renderRedisTreeNode(node, 0, '')) - ) : ( -
- {loadingRedisKeys ? '扫描中...' : '点击扫描加载 Keys'} -
- )} + {redisKeys.length > 0 ? redisTree.map(node => renderRedisTreeNode(node, 0, '')) + :
{loadingRedisKeys ? '扫描中...' : '点击扫描加载 Keys'}
}
); /* ---- Collections Tab (MongoDB) ---- */ const renderCollectionsTab = () => { - if (loadingTables) return ( -
- 加载集合列表... -
- ); - if (tables.length === 0) return ( -
无集合
- ); + if (loadingTables) return
加载集合列表...
; + if (tables.length === 0) return
无集合
; return (
{tables.map((t) => ( - - {sqlContent.trim() ? `${sqlContent.trim().split(/;\s*/).filter(Boolean).length} 条语句` : ''} - -
-
- -

- - 提交前必须通过 SQL 审核(语法检查 + 审核规则) -

-
- - {/* Rollback SQL */} - {rollbackSql && ( -
-
- - -
- -
- )} -
- - {/* Right: Browser Panel */} -
- {renderBrowserPanel()} -
-
- - {/* Reason */} -
- -