From 7e26837c5f05eeccf9841cba5abfd4fa1b000475 Mon Sep 17 00:00:00 2001 From: logan Date: Thu, 28 May 2026 21:34:59 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20TicketCreatePage=E7=94=A8SchemaSide?= =?UTF-8?q?bar=E6=9B=BF=E6=8D=A2=E6=89=8B=E5=86=99=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8+SqlEditor=E6=9A=B4=E9=9C=B2insertText=E5=85=89?= =?UTF-8?q?=E6=A0=87=E5=AE=9A=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SqlEditor: 添加 forwardRef + useImperativeHandle 暴露 insertText 方法 - 支持在光标位置插入文本并移动光标 - TicketCreatePage: 删除300+行手写表结构/Redis/索引浏览代码 - 统一使用 SchemaSidebar 组件(与查询页一致) - handleSchemaInsert: 已选库插table名,未选库插db.table名 - 清理未用 imports/icons/types/helpers (RedisKeyItem, formatBytes等) - 848行 → 435行 --- frontend/src/components/SqlEditor.tsx | 27 +- frontend/src/pages/TicketCreatePage.tsx | 503 +++--------------------- 2 files changed, 69 insertions(+), 461 deletions(-) diff --git a/frontend/src/components/SqlEditor.tsx b/frontend/src/components/SqlEditor.tsx index 84d3b6a..22524ca 100644 --- a/frontend/src/components/SqlEditor.tsx +++ b/frontend/src/components/SqlEditor.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useEffect, useCallback } from 'react'; +import React, { useRef, useEffect, useCallback, useImperativeHandle, forwardRef } from 'react'; import { EditorState, Compartment } from '@codemirror/state'; import type { Extension } from '@codemirror/state'; import { EditorView, keymap, lineNumbers, placeholder as cmPlaceholder } from '@codemirror/view'; @@ -259,7 +259,12 @@ export function formatSQL(input: string): string { // SqlEditor component // --------------------------------------------------------------------------- -const SqlEditor: React.FC = ({ +export interface SqlEditorHandle { + /** Insert text at cursor position and move cursor after inserted text */ + insertText: (text: string) => void; +} + +const SqlEditor = forwardRef(({ value, onChange, placeholder = 'Enter SQL query…', @@ -268,7 +273,7 @@ const SqlEditor: React.FC = ({ readOnly = false, onExecute, schema, -}) => { +}, ref) => { const containerRef = useRef(null); const viewRef = useRef(null); const langCompartmentRef = useRef(new Compartment()); @@ -276,6 +281,20 @@ const SqlEditor: React.FC = ({ const placeholderCompartmentRef = useRef(new Compartment()); const isExternalUpdateRef = useRef(false); + // Expose insertText to parent via ref + useImperativeHandle(ref, () => ({ + insertText: (text: string) => { + const view = viewRef.current; + if (!view) return; + const pos = view.state.selection.main.head; + view.dispatch({ + changes: { from: pos, insert: text }, + selection: { anchor: pos + text.length }, + }); + view.focus(); + }, + }), []); + // Keep onChange in a stable ref so callbacks never go stale const onChangeRef = useRef(onChange); onChangeRef.current = onChange; @@ -427,6 +446,6 @@ const SqlEditor: React.FC = ({ className="sql-editor-wrapper" /> ); -}; +}); export default SqlEditor; \ No newline at end of file diff --git a/frontend/src/pages/TicketCreatePage.tsx b/frontend/src/pages/TicketCreatePage.tsx index e129b04..e00db71 100644 --- a/frontend/src/pages/TicketCreatePage.tsx +++ b/frontend/src/pages/TicketCreatePage.tsx @@ -1,17 +1,13 @@ -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } 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, + Loader2, Clock, } 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'; +import SqlEditor, { formatSQL, type SqlEditorHandle } from '@/components/SqlEditor'; +import SchemaSidebar from '@/components/SchemaSidebar'; /* ------------------------------------------------------------------ */ /* Types */ @@ -38,13 +34,6 @@ interface AuditResult { findings: AuditFinding[]; } -interface RedisKeyItem { - key: string; - type: string; - ttl: number; - size?: number; -} - /* ------------------------------------------------------------------ */ /* Constants */ /* ------------------------------------------------------------------ */ @@ -65,26 +54,6 @@ 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 */ /* ================================================================== */ @@ -114,25 +83,8 @@ export default function TicketCreatePage() { 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'); + // -- Editor ref for insertText -- + const editorRef = useRef(null); /* ---------------------------------------------------------------- */ /* Fetch instances */ @@ -171,76 +123,40 @@ export default function TicketCreatePage() { .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 + ' '; - }); - }, []); + /** + * SchemaSidebar always sends text like "db.table" or "table.column". + * Logic: + * - If database is selected and text starts with "db.", strip the prefix → insert just table/column name + * - If database is NOT selected, keep the full "db.table" format + * - Use editorRef.insertText for proper cursor positioning + */ + const handleSchemaInsert = useCallback((text: string) => { + let insertText = text; + if (database) { + // Database already selected — strip "db." prefix if present + const dotIdx = insertText.indexOf('.'); + if (dotIdx > 0) { + // Check if the part before dot matches our selected database + const prefix = insertText.substring(0, dotIdx); + if (prefix === database) { + insertText = insertText.substring(dotIdx + 1); + } + } + } + // Use imperative handle for cursor positioning, fallback to setState + if (editorRef.current) { + editorRef.current.insertText(insertText + ' '); + } else { + setSqlContent(prev => { + const sep = prev.trim() ? (prev.trimEnd().endsWith('\n') ? '' : ' ') : ''; + return prev + sep + insertText + ' '; + }); + } + }, [database]); const handleFormatSQL = useCallback(() => { if (!sqlContent.trim()) return; @@ -330,337 +246,12 @@ export default function TicketCreatePage() { 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 */ /* ================================================================== */ const [showBrowser, setShowBrowser] = useState(true); + if (loadingTables) return ( return (
@@ -731,9 +322,7 @@ export default function TicketCreatePage() { {/* 浏览面板 */} {instanceId && database && ( -