import { useState, useEffect, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { ChevronRight, Check, AlertTriangle, Shield, Database, FileText, Sparkles, RotateCcw, Loader2, Info, ChevronDown, ChevronUp, Table2, Columns3, FolderOpen, KeyRound, Search, Hash, Clock, BarChart3, X, Zap, Copy, CheckCheck, } from 'lucide-react'; import { ticketApi } from '@/api/ticket'; import { instanceApi } from '@/api/instance'; import { instanceDetailApi, type TableMeta, type TableDetail } from '@/api/instanceDetail'; import { redisApi } from '@/api/redis'; import SqlEditor, { formatSQL } from '@/components/SqlEditor'; /* ------------------------------------------------------------------ */ /* Types */ /* ------------------------------------------------------------------ */ interface InstanceItem { id: number; name: string; host: string; port: number; db_type: string; } interface AuditFinding { rule: string; level: string; message: string; } interface AuditResult { score: number; passed: boolean; has_warnings: boolean; findings: AuditFinding[]; } interface RedisKeyItem { key: string; type: string; ttl: number; size?: number; } /* ------------------------------------------------------------------ */ /* Constants */ /* ------------------------------------------------------------------ */ const LEVEL_COLOR: Record = { error: 'text-red-400 bg-red-500/15', warning: 'text-amber-400 bg-amber-500/15', info: 'text-blue-400 bg-blue-500/15', }; const LEVEL_LABEL: Record = { error: '错误', warning: '警告', info: '提示', }; const DB_TYPE_LABELS: Record = { mysql: 'MySQL', redis: 'Redis', postgres: 'PostgreSQL', postgresql: 'PostgreSQL', mongodb: 'MongoDB', }; /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } 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'; } /* ================================================================== */ /* MAIN COMPONENT */ /* ================================================================== */ export default function TicketCreatePage() { const navigate = useNavigate(); // -- Form fields -- const [title, setTitle] = useState(''); const [instanceId, setInstanceId] = useState(''); const [database, setDatabase] = useState(''); const [sqlContent, setSqlContent] = useState(''); const [reason, setReason] = useState(''); const [scheduledAt, setScheduledAt] = useState(''); const [enableSchedule, setEnableSchedule] = useState(false); // -- Data / Loading -- const [instances, setInstances] = useState([]); const [databases, setDatabases] = useState([]); const [loadingInstances, setLoadingInstances] = useState(false); const [loadingDatabases, setLoadingDatabases] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(''); // -- Inline audit result -- const [auditResult, setAuditResult] = useState(null); const [validating, setValidating] = useState(false); const [validated, setValidated] = useState(false); // -- Schema Browser State -- const [tables, setTables] = useState([]); const [loadingTables, setLoadingTables] = useState(false); const [expandedTable, setExpandedTable] = useState(null); const [tableDetail, setTableDetail] = useState(null); const [loadingDetail, setLoadingDetail] = useState(false); const [copiedDDL, setCopiedDDL] = useState(false); // -- Redis Browser State -- const [redisDb, setRedisDb] = useState(0); const [redisPattern, setRedisPattern] = useState('*'); const [redisKeys, setRedisKeys] = useState([]); const [loadingRedisKeys, setLoadingRedisKeys] = useState(false); const [selectedRedisKey, setSelectedRedisKey] = useState(null); const [redisKeyDetail, setRedisKeyDetail] = useState(null); const [loadingRedisDetail, setLoadingRedisDetail] = useState(false); // -- Browser Tab -- const [browserTab, setBrowserTab] = useState<'tables' | 'indexes' | 'redis' | 'collections'>('tables'); /* ---------------------------------------------------------------- */ /* Fetch instances */ /* ---------------------------------------------------------------- */ useEffect(() => { setLoadingInstances(true); instanceApi.accessible('write') .then((res: unknown) => { const data = (res as { items?: unknown[] })?.items ?? res; setInstances(Array.isArray(data) ? data as InstanceItem[] : []); }) .catch(() => setInstances([])) .finally(() => setLoadingInstances(false)); }, []); const selectedInstance = useMemo(() => instances.find((i) => i.id === Number(instanceId)), [instances, instanceId]); const dbType = selectedInstance?.db_type || ''; useEffect(() => { if (!instanceId) { setDatabases([]); setDatabase(''); return; } setDatabase(''); setDatabases([]); setTables([]); const inst = instances.find(i => i.id === Number(instanceId)); if (!inst) return; setLoadingDatabases(true); instanceApi.listDatabases(Number(instanceId)) .then((res: unknown) => { const data = (res as { databases?: string[] })?.databases || (res as string[]) || []; const list = Array.isArray(data) ? data as string[] : []; setDatabases(list); if (list.length > 0) { setDatabase(inst.db_type === 'redis' ? 'db0' : String(list[0])); } }) .catch(() => setDatabases([])) .finally(() => setLoadingDatabases(false)); }, [instanceId, instances]); /* ---------------------------------------------------------------- */ /* Fetch tables when instance+db changes */ /* ---------------------------------------------------------------- */ useEffect(() => { if (!instanceId || !database) { setTables([]); return; } const inst = instances.find(i => i.id === Number(instanceId)); if (!inst || inst.db_type === 'redis') { setTables([]); return; } setLoadingTables(true); instanceDetailApi.listTables(Number(instanceId), database) .then((data) => setTables(Array.isArray(data) ? data : [])) .catch(() => setTables([])) .finally(() => setLoadingTables(false)); }, [instanceId, database, instances]); const handleExpandTable = useCallback(async (tableName: string) => { if (expandedTable === tableName) { setExpandedTable(null); return; } setExpandedTable(tableName); setTableDetail(null); setLoadingDetail(true); try { const detail = await instanceDetailApi.getTableDetail(Number(instanceId), database, tableName); setTableDetail(detail); } catch { /* ignore */ } finally { setLoadingDetail(false); } }, [expandedTable, instanceId, database]); /* ---------------------------------------------------------------- */ /* Fetch Redis keys */ /* ---------------------------------------------------------------- */ const fetchRedisKeys = useCallback(async () => { if (!instanceId) return; setLoadingRedisKeys(true); try { const res = await redisApi.scanKeys(Number(instanceId), { 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, })); setRedisKeys(keys); } catch { setRedisKeys([]); } finally { setLoadingRedisKeys(false); } }, [instanceId, redisDb, redisPattern]); useEffect(() => { const inst = instances.find(i => i.id === Number(instanceId)); if (inst?.db_type === 'redis' && browserTab === 'redis') fetchRedisKeys(); }, [instanceId, browserTab, fetchRedisKeys, instances]); const handleSelectRedisKey = useCallback(async (key: string) => { setSelectedRedisKey(key); setLoadingRedisDetail(true); try { const detail = await redisApi.getKeyDetail(Number(instanceId), key, redisDb); setRedisKeyDetail(detail); } catch { setRedisKeyDetail(null); } finally { setLoadingRedisDetail(false); } }, [instanceId, redisDb]); /* ---------------------------------------------------------------- */ /* Insert / change helpers */ /* ---------------------------------------------------------------- */ const insertToEditor = useCallback((text: string) => { setSqlContent(prev => { const sep = prev.trim() ? (prev.trimEnd().endsWith('\n') ? '' : ' ') : ''; return prev + sep + text + ' '; }); }, []); const handleFormatSQL = useCallback(() => { if (!sqlContent.trim()) return; setSqlContent(formatSQL(sqlContent)); }, [sqlContent]); // SQL 内容变化时清除上次审核结果 const handleSqlChange = useCallback((val: string) => { setSqlContent(val); if (auditResult) setAuditResult(null); if (validated) setValidated(false); }, [auditResult, validated]); /* ---------------------------------------------------------------- */ /* Validate: SQL 检测(不提交) */ /* ---------------------------------------------------------------- */ const handleValidate = useCallback(async () => { if (!sqlContent.trim()) { setError('请填写 SQL 语句'); return; } if (!instanceId) { setError('请选择目标实例'); return; } if (!database) { setError('请选择数据库'); return; } setError(''); setAuditResult(null); setValidating(true); try { const result = await ticketApi.audit({ sql_content: sqlContent, instance_id: Number(instanceId), database, }) as unknown as AuditResult; setAuditResult(result); const hasErrors = result.findings?.some((f) => f.level === 'error'); if (hasErrors) { setError('SQL 审核未通过,请修改后重新检测'); setValidated(false); } else { setValidated(true); } } catch (e: unknown) { const err = e as { response?: { data?: { detail?: string } } }; setError(err?.response?.data?.detail || 'SQL 检测失败'); setValidated(false); } finally { setValidating(false); } }, [sqlContent, instanceId, database]); /* ---------------------------------------------------------------- */ /* Submit: 验证通过后直接提交工单 */ /* ---------------------------------------------------------------- */ const handleSubmit = useCallback(async () => { 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(''); setSubmitting(true); try { 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, reason: reason.trim(), priority: 'medium', scheduled_at: enableSchedule && scheduledAt ? new Date(scheduledAt).toISOString() : undefined, }); navigate('/tickets'); } catch (e: unknown) { const err = e as { response?: { data?: { detail?: string | Array<{ msg: string }> } } }; const raw = err?.response?.data?.detail; const msg = Array.isArray(raw) ? raw.map((v) => v.msg).join('; ') : (raw || '提交失败,请稍后重试'); setError(msg); } finally { setSubmitting(false); } }, [title, sqlContent, instanceId, database, reason, instances, navigate, enableSchedule, scheduledAt]); /* ---------------------------------------------------------------- */ /* Derived values */ /* ---------------------------------------------------------------- */ const isRedis = selectedInstance?.db_type === 'redis'; const isSQL = selectedInstance?.db_type === 'mysql' || selectedInstance?.db_type === 'postgresql' || selectedInstance?.db_type === 'postgres'; const isMongo = selectedInstance?.db_type === 'mongo'; 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 { } }, []); /* ================================================================== */ /* BROWSER PANEL — Tables / Indexes / Redis Keys */ /* ================================================================== */ const renderBrowserPanel = () => { if (!instanceId || !database) return null; 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 (tabs.length === 0) return null; return (
{tabs.map(tab => ( ))} 点击插入到 SQL
{browserTab === 'tables' && renderTablesTab()} {browserTab === 'indexes' && renderIndexesTab()} {browserTab === 'redis' && renderRedisTab()} {browserTab === 'collections' && renderCollectionsTab()}
); }; /* ---- Tables Tab ---- */ const renderTablesTab = () => { if (loadingTables) return (
加载表列表...
); if (tables.length === 0) return (
无可用表
); return (
{tables.map(t => (
{expandedTable === t.name && (
{loadingDetail ? (
加载字段...
) : tableDetail ? (
{tableDetail.columns.map((col) => ( ))}
{tableDetail.ddl && (
CREATE TABLE
{tableDetail.ddl}
)}
) :

字段信息不可用

}
)}
))}
); }; /* ---- 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 (
表: {expandedTable} {Object.keys(grouped).length} 个索引
{Object.entries(grouped).map(([keyName, idxs]) => (
{keyName} {keyName === 'PRIMARY' ? '主键' : idxs[0]?.Index_type || '索引'} {idxs[0]?.Cardinality?.toLocaleString()} 基数
{idxs.map((idx, i) => ( ))}
))}
); }; /* ---- Redis Tree ---- */ interface RedisTreeNode { name: string; fullKey?: string; type?: string; children?: RedisTreeNode[]; } const buildRedisTree = useCallback((keys: RedisKeyItem[]): RedisTreeNode[] => { const root: RedisTreeNode[] = []; for (const k of keys) { const parts = k.key.split(':'); let nodes = root; 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 (i === parts.length - 1) { node.fullKey = k.key; node.type = k.type; delete node.children; } else { if (!node.children) node.children = []; nodes = node.children; } } } 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.forEach(n => { if (n.children) sortNodes(n.children); }); }; sortNodes(root); return root; }, []); 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; }); }, []); const redisCmdForKey = useCallback((key: string, type: string): string => { switch (type) { 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', }; const renderRedisTreeNode = useCallback((node: RedisTreeNode, depth: number, parentPath: string): React.ReactNode => { const path = parentPath ? parentPath + ':' + node.name : node.name; const isLeaf = !!node.fullKey; const isExpanded = redisExpanded.has(path); const indent = depth * 14; if (isLeaf) { const isSelected = selectedRedisKey === node.fullKey; const keyIconColor = keyTypeColor[node.type || 'string'] || 'text-amber-400'; return (
{isSelected && (
{loadingRedisDetail ? (
加载...
) : redisKeyDetail ? (
{redisKeyDetail.type} TTL: {formatTTL(redisKeyDetail.ttl)} {redisKeyDetail.size && {formatBytes(redisKeyDetail.size)}}
                    {typeof redisKeyDetail.value === 'object' ? JSON.stringify(redisKeyDetail.value, null, 2) : String(redisKeyDetail.value)}
                  
) : null}
)}
); } const childCount = node.children?.length || 0; return (
{isExpanded && (
{node.children?.map(child => renderRedisTreeNode(child, depth + 1, path))}
)}
); }, [redisExpanded, selectedRedisKey, toggleRedisFolder, redisCmdForKey, handleSelectRedisKey, loadingRedisDetail, redisKeyDetail]); const redisTree = useMemo(() => buildRedisTree(redisKeys), [redisKeys, buildRedisTree]); const renderRedisTab = () => (
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:*" />
{redisKeys.length > 0 ? redisTree.map(node => renderRedisTreeNode(node, 0, '')) :
{loadingRedisKeys ? '扫描中...' : '点击扫描加载 Keys'}
}
); /* ---- Collections Tab (MongoDB) ---- */ const renderCollectionsTab = () => { if (loadingTables) return
加载集合列表...
; if (tables.length === 0) return
无集合
; return (
{tables.map((t) => ( ))}
); }; /* ================================================================== */ /* MAIN RENDER — 全高布局,参考 QueriesPage */ /* ================================================================== */ const [showBrowser, setShowBrowser] = useState(true); return (
{/* ====== 左侧 Schema 浏览面板 ====== */} {showBrowser && instanceId && database && (
库表浏览
{renderBrowserPanel()}
)} {/* ====== 主区域 ====== */}
{/* -- 顶栏 -- */}
{/* 返回 */} {/* 工单标题 */} setTitle(e.target.value)} className="px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-sm focus:border-blue-400 focus:outline-none min-w-[180px] max-w-[280px]" placeholder="工单标题" /> {/* 实例 */} {/* 数据库 */} {isRedis ? ( ) : databases.length > 0 ? ( ) : ( setDatabase(e.target.value)} disabled={!instanceId} className="px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-sm focus:border-blue-400 focus:outline-none min-w-[140px] disabled:opacity-50" placeholder={instanceId ? '数据库名' : '选择实例'} /> )}
{/* 定时执行 */} {enableSchedule && ( setScheduledAt(e.target.value)} step="1" min={new Date().toISOString().slice(0, 16)} className="px-2 py-1 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-xs focus:border-blue-400 focus:outline-none" /> )} {/* 浏览面板开关 */} {instanceId && database && ( )} {/* 格式化 */} {/* SQL 检测 */} {/* 提交审批 */}
{/* -- 定时执行提示 -- */} {enableSchedule && scheduledAt && (
定时执行:{new Date(scheduledAt).toLocaleString('zh-CN')}
)} {/* -- 错误 -- */} {error && (
{error}
)} {/* -- SQL 编辑器 -- */}
{sqlContent.trim() ? sqlContent.trim().split(/;\s*/).filter(Boolean).length + ' 条语句 · ' : ''} {sqlContent.length} 字符
{validated && 检测通过} Ctrl+Enter 格式化
{/* -- 变更原因 + 审核结果(底部面板) -- */}
{/* 审核结果 */} {auditResult && (
审核结果 {errorCount > 0 && {errorCount} 错误} {warnCount > 0 && {warnCount} 警告} {infoCount > 0 && {infoCount} 提示} {errorCount === 0 && warnCount === 0 && infoCount === 0 && ( 通过 )}
{auditResult.findings.length > 0 && (
{auditResult.findings.map((f, idx) => ( {LEVEL_LABEL[f.level] || f.level}: {f.message} ))}
)}
)} {/* 变更原因 */}
变更原因 * setReason(e.target.value)} className="flex-1 px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-sm focus:border-blue-400 focus:outline-none" placeholder="请描述变更原因、影响范围、回滚方案等" />
); }