refactor: TicketCreatePage用SchemaSidebar替换手写浏览器+SqlEditor暴露insertText光标定位
- SqlEditor: 添加 forwardRef + useImperativeHandle 暴露 insertText 方法 - 支持在光标位置插入文本并移动光标 - TicketCreatePage: 删除300+行手写表结构/Redis/索引浏览代码 - 统一使用 SchemaSidebar 组件(与查询页一致) - handleSchemaInsert: 已选库插table名,未选库插db.table名 - 清理未用 imports/icons/types/helpers (RedisKeyItem, formatBytes等) - 848行 → 435行
This commit is contained in:
1 parent
c6ef67adba
commit
7e26837c5f
2 files changed
+69
-461
No files matched your search
@@ -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 { EditorState, Compartment } from '@codemirror/state';
|
||||||
import type { Extension } from '@codemirror/state';
|
import type { Extension } from '@codemirror/state';
|
||||||
import { EditorView, keymap, lineNumbers, placeholder as cmPlaceholder } from '@codemirror/view';
|
import { EditorView, keymap, lineNumbers, placeholder as cmPlaceholder } from '@codemirror/view';
|
||||||
@@ -259,7 +259,12 @@ export function formatSQL(input: string): string {
|
|||||||
// SqlEditor component
|
// SqlEditor component
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const SqlEditor: React.FC<SqlEditorProps> = ({
|
export interface SqlEditorHandle {
|
||||||
|
/** Insert text at cursor position and move cursor after inserted text */
|
||||||
|
insertText: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SqlEditor = forwardRef<SqlEditorHandle, SqlEditorProps>(({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = 'Enter SQL query…',
|
placeholder = 'Enter SQL query…',
|
||||||
@@ -268,7 +273,7 @@ const SqlEditor: React.FC<SqlEditorProps> = ({
|
|||||||
readOnly = false,
|
readOnly = false,
|
||||||
onExecute,
|
onExecute,
|
||||||
schema,
|
schema,
|
||||||
}) => {
|
}, ref) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const viewRef = useRef<EditorView | null>(null);
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
const langCompartmentRef = useRef(new Compartment());
|
const langCompartmentRef = useRef(new Compartment());
|
||||||
@@ -276,6 +281,20 @@ const SqlEditor: React.FC<SqlEditorProps> = ({
|
|||||||
const placeholderCompartmentRef = useRef(new Compartment());
|
const placeholderCompartmentRef = useRef(new Compartment());
|
||||||
const isExternalUpdateRef = useRef(false);
|
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
|
// Keep onChange in a stable ref so callbacks never go stale
|
||||||
const onChangeRef = useRef(onChange);
|
const onChangeRef = useRef(onChange);
|
||||||
onChangeRef.current = onChange;
|
onChangeRef.current = onChange;
|
||||||
@@ -427,6 +446,6 @@ const SqlEditor: React.FC<SqlEditorProps> = ({
|
|||||||
className="sql-editor-wrapper"
|
className="sql-editor-wrapper"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default SqlEditor;
|
export default SqlEditor;
|
||||||
@@ -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 { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
ChevronRight, Check, AlertTriangle, Shield, Database,
|
ChevronRight, Check, AlertTriangle, Shield, Database,
|
||||||
FileText, Sparkles, RotateCcw, Loader2, Info,
|
Loader2, Clock,
|
||||||
ChevronDown, ChevronUp, Table2, Columns3, FolderOpen,
|
|
||||||
KeyRound, Search, Hash, Clock, BarChart3,
|
|
||||||
X, Zap, Copy, CheckCheck,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ticketApi } from '@/api/ticket';
|
import { ticketApi } from '@/api/ticket';
|
||||||
import { instanceApi } from '@/api/instance';
|
import { instanceApi } from '@/api/instance';
|
||||||
import { instanceDetailApi, type TableMeta, type TableDetail } from '@/api/instanceDetail';
|
import SqlEditor, { formatSQL, type SqlEditorHandle } from '@/components/SqlEditor';
|
||||||
import { redisApi } from '@/api/redis';
|
import SchemaSidebar from '@/components/SchemaSidebar';
|
||||||
import SqlEditor, { formatSQL } from '@/components/SqlEditor';
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Types */
|
/* Types */
|
||||||
@@ -38,13 +34,6 @@ interface AuditResult {
|
|||||||
findings: AuditFinding[];
|
findings: AuditFinding[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RedisKeyItem {
|
|
||||||
key: string;
|
|
||||||
type: string;
|
|
||||||
ttl: number;
|
|
||||||
size?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Constants */
|
/* Constants */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -65,26 +54,6 @@ const DB_TYPE_LABELS: Record<string, string> = {
|
|||||||
mysql: 'MySQL', redis: 'Redis', postgres: 'PostgreSQL', postgresql: 'PostgreSQL', mongodb: 'MongoDB',
|
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 */
|
/* MAIN COMPONENT */
|
||||||
/* ================================================================== */
|
/* ================================================================== */
|
||||||
@@ -114,25 +83,8 @@ export default function TicketCreatePage() {
|
|||||||
const [validating, setValidating] = useState(false);
|
const [validating, setValidating] = useState(false);
|
||||||
const [validated, setValidated] = useState(false);
|
const [validated, setValidated] = useState(false);
|
||||||
|
|
||||||
// -- Schema Browser State --
|
// -- Editor ref for insertText --
|
||||||
const [tables, setTables] = useState<TableMeta[]>([]);
|
const editorRef = useRef<SqlEditorHandle>(null);
|
||||||
const [loadingTables, setLoadingTables] = useState(false);
|
|
||||||
const [expandedTable, setExpandedTable] = useState<string | null>(null);
|
|
||||||
const [tableDetail, setTableDetail] = useState<TableDetail | null>(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<RedisKeyItem[]>([]);
|
|
||||||
const [loadingRedisKeys, setLoadingRedisKeys] = useState(false);
|
|
||||||
const [selectedRedisKey, setSelectedRedisKey] = useState<string | null>(null);
|
|
||||||
const [redisKeyDetail, setRedisKeyDetail] = useState<any>(null);
|
|
||||||
const [loadingRedisDetail, setLoadingRedisDetail] = useState(false);
|
|
||||||
|
|
||||||
// -- Browser Tab --
|
|
||||||
const [browserTab, setBrowserTab] = useState<'tables' | 'indexes' | 'redis' | 'collections'>('tables');
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
/* Fetch instances */
|
/* Fetch instances */
|
||||||
@@ -171,76 +123,40 @@ export default function TicketCreatePage() {
|
|||||||
.finally(() => setLoadingDatabases(false));
|
.finally(() => setLoadingDatabases(false));
|
||||||
}, [instanceId, instances]);
|
}, [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 */
|
/* Insert / change helpers */
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
const insertToEditor = useCallback((text: string) => {
|
/**
|
||||||
setSqlContent(prev => {
|
* SchemaSidebar always sends text like "db.table" or "table.column".
|
||||||
const sep = prev.trim() ? (prev.trimEnd().endsWith('\n') ? '' : ' ') : '';
|
* Logic:
|
||||||
return prev + sep + text + ' ';
|
* - 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(() => {
|
const handleFormatSQL = useCallback(() => {
|
||||||
if (!sqlContent.trim()) return;
|
if (!sqlContent.trim()) return;
|
||||||
@@ -330,337 +246,12 @@ export default function TicketCreatePage() {
|
|||||||
const warnCount = auditResult?.findings?.filter(f => f.level === 'warning').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 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 (
|
|
||||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg border border-[var(--dg-border)] overflow-hidden">
|
|
||||||
<div className="flex items-center border-b border-[var(--dg-border)]">
|
|
||||||
{tabs.map(tab => (
|
|
||||||
<button key={tab.id} onClick={() => setBrowserTab(tab.id)}
|
|
||||||
className={'flex items-center gap-1.5 px-4 py-2.5 text-xs font-medium transition-all border-b-2 ' + (
|
|
||||||
browserTab === tab.id
|
|
||||||
? 'text-blue-400 border-blue-400 bg-blue-500/5'
|
|
||||||
: 'text-[var(--dg-text-muted)] border-transparent hover:text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
|
|
||||||
)}>
|
|
||||||
<tab.icon className="w-3.5 h-3.5" /> {tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<span className="ml-auto text-[10px] text-[var(--dg-text-muted)] px-3">点击插入到 SQL</span>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-[320px] overflow-y-auto">
|
|
||||||
{browserTab === 'tables' && renderTablesTab()}
|
|
||||||
{browserTab === 'indexes' && renderIndexesTab()}
|
|
||||||
{browserTab === 'redis' && renderRedisTab()}
|
|
||||||
{browserTab === 'collections' && renderCollectionsTab()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ---- Tables Tab ---- */
|
|
||||||
const renderTablesTab = () => {
|
|
||||||
if (loadingTables) return (
|
|
||||||
<div className="flex items-center gap-2 p-4 text-xs text-[var(--dg-text-muted)]">
|
|
||||||
<Loader2 className="w-3 h-3 animate-spin" /> 加载表列表...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
if (tables.length === 0) return (
|
|
||||||
<div className="p-4 text-xs text-[var(--dg-text-muted)] text-center">无可用表</div>
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div className="divide-y divide-[var(--dg-border-light)]">
|
|
||||||
{tables.map(t => (
|
|
||||||
<div key={t.name}>
|
|
||||||
<button onClick={() => handleExpandTable(t.name)}
|
|
||||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-[var(--dg-bg-hover)] transition-colors group">
|
|
||||||
{expandedTable === t.name
|
|
||||||
? <ChevronDown className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
|
||||||
: <ChevronRight className="w-3 h-3 text-[var(--dg-text-muted)]" />}
|
|
||||||
<Table2 className="w-3.5 h-3.5 text-blue-400" />
|
|
||||||
<span className="font-mono text-[var(--dg-text-primary)] hover:text-blue-400 transition-colors"
|
|
||||||
onClick={(e) => { e.stopPropagation(); insertToEditor(t.name); }}
|
|
||||||
title="点击插入表名">{t.name}</span>
|
|
||||||
<span className="text-[10px] text-[var(--dg-text-muted)] ml-1">{t.engine}</span>
|
|
||||||
{t.rows !== undefined && (
|
|
||||||
<span className="text-[10px] text-[var(--dg-text-muted)] ml-auto">{t.rows.toLocaleString()} 行</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{expandedTable === t.name && (
|
|
||||||
<div className="px-3 pb-3">
|
|
||||||
{loadingDetail ? (
|
|
||||||
<div className="flex items-center gap-2 py-2 text-xs text-[var(--dg-text-muted)]">
|
|
||||||
<Loader2 className="w-3 h-3 animate-spin" /> 加载字段...
|
|
||||||
</div>
|
|
||||||
) : tableDetail ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="ml-5 space-y-0.5">
|
|
||||||
{tableDetail.columns.map((col) => (
|
|
||||||
<button key={col.Field} onClick={() => insertToEditor(col.Field)}
|
|
||||||
className="flex items-center gap-2 w-full px-2 py-1 text-xs rounded hover:bg-[var(--dg-bg-hover)] transition-colors"
|
|
||||||
title={col.Type + ' ' + (col.Null === 'NO' ? 'NOT NULL' : 'NULL') + ' ' + (col.Key ? '(' + col.Key + ')' : '')}>
|
|
||||||
<Columns3 className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
|
||||||
<span className="font-mono text-[var(--dg-text-secondary)] hover:text-blue-400">{col.Field}</span>
|
|
||||||
<span className="text-[10px] text-[var(--dg-text-muted)]">{col.Type}</span>
|
|
||||||
{col.Key === 'PRI' && <span className="text-[9px] px-1 bg-yellow-500/10 text-yellow-400 rounded">PK</span>}
|
|
||||||
{col.Key === 'MUL' && <span className="text-[9px] px-1 bg-blue-500/10 text-blue-400 rounded">IDX</span>}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{tableDetail.ddl && (
|
|
||||||
<div className="ml-5 mt-2 p-2 bg-[var(--dg-bg-primary)] rounded border border-[var(--dg-border)]">
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<span className="text-[10px] text-[var(--dg-text-muted)]">CREATE TABLE</span>
|
|
||||||
<button onClick={() => copyToClipboard(tableDetail.ddl)}
|
|
||||||
className="text-[10px] text-blue-400 hover:text-blue-300 flex items-center gap-1">
|
|
||||||
{copiedDDL ? <><CheckCheck className="w-3 h-3" /> 已复制</> : <><Copy className="w-3 h-3" /> 复制</>}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<pre className="text-[10px] font-mono text-[var(--dg-text-secondary)] overflow-x-auto max-h-[80px] overflow-y-auto whitespace-pre-wrap">{tableDetail.ddl}</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : <p className="text-xs text-[var(--dg-text-muted)] ml-5">字段信息不可用</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ---- Indexes Tab ---- */
|
|
||||||
const renderIndexesTab = () => {
|
|
||||||
if (!expandedTable || !tableDetail?.indexes) return (
|
|
||||||
<div className="p-4 text-xs text-[var(--dg-text-muted)] text-center">请先选择一个表查看索引</div>
|
|
||||||
);
|
|
||||||
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<string, typeof tableDetail.indexes>);
|
|
||||||
return (
|
|
||||||
<div className="p-3 space-y-2">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">表:</span>
|
|
||||||
<span className="text-sm font-mono font-medium">{expandedTable}</span>
|
|
||||||
<span className="text-xs text-[var(--dg-text-muted)] ml-auto">{Object.keys(grouped).length} 个索引</span>
|
|
||||||
</div>
|
|
||||||
{Object.entries(grouped).map(([keyName, idxs]) => (
|
|
||||||
<div key={keyName} className="p-2.5 bg-[var(--dg-bg-primary)] rounded border border-[var(--dg-border)]">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="text-xs font-medium font-mono">{keyName}</span>
|
|
||||||
<span className={'text-[10px] px-1.5 py-0.5 rounded ' + (keyName === 'PRIMARY' ? 'bg-yellow-500/10 text-yellow-400' : 'bg-blue-500/10 text-blue-400')}>
|
|
||||||
{keyName === 'PRIMARY' ? '主键' : idxs[0]?.Index_type || '索引'}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-[var(--dg-text-muted)] ml-auto">{idxs[0]?.Cardinality?.toLocaleString()} 基数</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{idxs.map((idx, i) => (
|
|
||||||
<button key={i} onClick={() => insertToEditor(idx.Column_name)}
|
|
||||||
className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-[var(--dg-bg-tertiary)] hover:bg-blue-500/10 hover:text-blue-400 transition-colors">
|
|
||||||
{idx.Column_name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ---- 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<Set<string>>(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, string> = {
|
|
||||||
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 (
|
|
||||||
<div key={path}>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const cmd = redisCmdForKey(node.fullKey!, node.type || 'string');
|
|
||||||
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 ' + (
|
|
||||||
isSelected ? 'text-blue-400' : 'hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]'
|
|
||||||
)}
|
|
||||||
style={{ paddingLeft: indent + 6 + 'px' }}>
|
|
||||||
<KeyRound className={'w-3 h-3 ' + keyIconColor + ' shrink-0'} />
|
|
||||||
<span className="truncate flex-1 text-left">{node.name}</span>
|
|
||||||
<span className="text-[9px] px-1 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] shrink-0">{node.type}</span>
|
|
||||||
<span className="text-[9px] text-blue-400 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">插入</span>
|
|
||||||
</button>
|
|
||||||
{isSelected && (
|
|
||||||
<div className="px-2 pb-2 ml-4 border-l border-[var(--dg-border-light)]">
|
|
||||||
{loadingRedisDetail ? (
|
|
||||||
<div className="flex items-center gap-1 p-1 text-[10px] text-[var(--dg-text-muted)]"><Loader2 className="w-2.5 h-2.5 animate-spin" /> 加载...</div>
|
|
||||||
) : redisKeyDetail ? (
|
|
||||||
<div className="p-2 bg-[var(--dg-bg-primary)] rounded border border-[var(--dg-border)] space-y-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">{redisKeyDetail.type}</span>
|
|
||||||
<span className="text-[10px] text-[var(--dg-text-muted)]">TTL: {formatTTL(redisKeyDetail.ttl)}</span>
|
|
||||||
{redisKeyDetail.size && <span className="text-[10px] text-[var(--dg-text-muted)]">{formatBytes(redisKeyDetail.size)}</span>}
|
|
||||||
</div>
|
|
||||||
<pre className="text-[10px] font-mono text-[var(--dg-text-secondary)] overflow-auto max-h-[120px] whitespace-pre-wrap">
|
|
||||||
{typeof redisKeyDetail.value === 'object' ? JSON.stringify(redisKeyDetail.value, null, 2) : String(redisKeyDetail.value)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const childCount = node.children?.length || 0;
|
|
||||||
return (
|
|
||||||
<div key={path}>
|
|
||||||
<button onClick={() => toggleRedisFolder(path)}
|
|
||||||
className="w-full flex items-center gap-1.5 py-1 text-[11px] font-medium text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
|
||||||
style={{ paddingLeft: indent + 6 + 'px' }}>
|
|
||||||
{isExpanded ? <ChevronDown className="w-3 h-3 text-[var(--dg-text-muted)] shrink-0" /> : <ChevronRight className="w-3 h-3 text-[var(--dg-text-muted)] shrink-0" />}
|
|
||||||
<FolderOpen className="w-3 h-3 text-amber-400/70 shrink-0" />
|
|
||||||
<span className="truncate">{node.name}</span>
|
|
||||||
<span className="text-[9px] text-[var(--dg-text-muted)] ml-auto mr-1">{childCount}</span>
|
|
||||||
</button>
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="border-l border-[var(--dg-border-light)]" style={{ marginLeft: indent + 12 + 'px' }}>
|
|
||||||
{node.children?.map(child => renderRedisTreeNode(child, depth + 1, path))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}, [redisExpanded, selectedRedisKey, toggleRedisFolder, redisCmdForKey, handleSelectRedisKey, loadingRedisDetail, redisKeyDetail]);
|
|
||||||
|
|
||||||
const redisTree = useMemo(() => buildRedisTree(redisKeys), [redisKeys, buildRedisTree]);
|
|
||||||
|
|
||||||
const renderRedisTab = () => (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-[var(--dg-border)]">
|
|
||||||
<select value={redisDb} onChange={(e) => { 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, idx) => { const num = parseInt(String(db).replace(/\D/g, ''), 10); return <option key={db} value={isNaN(num) ? idx : num}>{db}</option>; })
|
|
||||||
: Array.from({ length: 16 }, (_, i) => <option key={i} value={i}>DB{i}</option>)}
|
|
||||||
</select>
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" />
|
|
||||||
<input value={redisPattern} onChange={(e) => 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:*" />
|
|
||||||
</div>
|
|
||||||
<button onClick={fetchRedisKeys} disabled={loadingRedisKeys}
|
|
||||||
className="px-2.5 py-1.5 bg-blue-500/15 text-blue-400 rounded text-[11px] hover:bg-blue-500/25 transition-colors disabled:opacity-50">
|
|
||||||
{loadingRedisKeys ? <Loader2 className="w-3 h-3 animate-spin" /> : '扫描'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-[300px] overflow-y-auto">
|
|
||||||
{redisKeys.length > 0 ? redisTree.map(node => renderRedisTreeNode(node, 0, ''))
|
|
||||||
: <div className="text-xs text-[var(--dg-text-muted)] text-center py-6">{loadingRedisKeys ? '扫描中...' : '点击扫描加载 Keys'}</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ---- Collections Tab (MongoDB) ---- */
|
|
||||||
const renderCollectionsTab = () => {
|
|
||||||
if (loadingTables) return <div className="flex items-center gap-2 p-4 text-xs text-[var(--dg-text-muted)]"><Loader2 className="w-3 h-3 animate-spin" /> 加载集合列表...</div>;
|
|
||||||
if (tables.length === 0) return <div className="text-xs text-[var(--dg-text-muted)] text-center py-4">无集合</div>;
|
|
||||||
return (
|
|
||||||
<div className="p-2 space-y-0.5">
|
|
||||||
{tables.map((t) => (
|
|
||||||
<button key={t.name}
|
|
||||||
onClick={() => setSqlContent(prev => prev + (prev && !prev.endsWith('\n') ? '\n' : '') + 'db.' + t.name + '.find()')}
|
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm text-left hover:bg-[var(--dg-bg-hover)] transition-colors group">
|
|
||||||
<Table2 className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
|
||||||
<span className="text-[var(--dg-text-primary)] truncate">{t.name}</span>
|
|
||||||
<span className="ml-auto text-[10px] text-[var(--dg-text-muted)] opacity-0 group-hover:opacity-100 transition-opacity">插入</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ================================================================== */
|
/* ================================================================== */
|
||||||
/* MAIN RENDER */
|
/* MAIN RENDER */
|
||||||
/* ================================================================== */
|
/* ================================================================== */
|
||||||
|
|
||||||
const [showBrowser, setShowBrowser] = useState(true);
|
const [showBrowser, setShowBrowser] = useState(true);
|
||||||
|
if (loadingTables) return (
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[calc(100vh-3rem)] overflow-y-auto">
|
<div className="h-[calc(100vh-3rem)] overflow-y-auto">
|
||||||
@@ -731,9 +322,7 @@ export default function TicketCreatePage() {
|
|||||||
|
|
||||||
{/* 浏览面板 */}
|
{/* 浏览面板 */}
|
||||||
{instanceId && database && (
|
{instanceId && database && (
|
||||||
<button onClick={() => setShowBrowser(!showBrowser)}
|
|
||||||
className={`flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-lg border transition-all ${
|
className={`flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-lg border transition-all ${
|
||||||
showBrowser ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : 'text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]'
|
|
||||||
}`}>
|
}`}>
|
||||||
<Database className="w-3.5 h-3.5" />
|
<Database className="w-3.5 h-3.5" />
|
||||||
{showBrowser ? '隐藏浏览' : '库表浏览'}
|
{showBrowser ? '隐藏浏览' : '库表浏览'}
|
||||||
@@ -757,19 +346,19 @@ export default function TicketCreatePage() {
|
|||||||
{/* ====== 编辑器 + 浏览面板 ====== */}
|
{/* ====== 编辑器 + 浏览面板 ====== */}
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{/* 浏览面板 */}
|
{/* 浏览面板 */}
|
||||||
{showBrowser && instanceId && database && (
|
{showBrowser && instanceId && (
|
||||||
<div className="w-[260px] shrink-0 bg-[var(--dg-bg-secondary)] rounded-lg border border-[var(--dg-border)] overflow-hidden">
|
<div className="w-[280px] shrink-0">
|
||||||
<div className="max-h-[420px] overflow-y-auto">
|
<SchemaSidebar instanceId={instanceId} database={database} dbType={dbType}
|
||||||
{renderBrowserPanel()}
|
onInsert={handleSchemaInsert} />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* SQL 编辑器 */}
|
{/* SQL 编辑器 */}
|
||||||
<div className={`dg-card overflow-hidden flex flex-col ${showBrowser && instanceId && database ? 'flex-1' : 'flex-1'}`}>
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
<SqlEditor value={sqlContent} onChange={handleSqlChange} language="sql"
|
<div className={`dg-card overflow-hidden flex flex-col`}>
|
||||||
height="320px"
|
<SqlEditor ref={editorRef} value={sqlContent} onChange={handleSqlChange} language="sql"
|
||||||
placeholder="请输入 SQL 变更语句,支持多条 SQL(以分号分隔)..." />
|
height="320px"
|
||||||
|
placeholder="请输入 SQL 变更语句,支持多条 SQL(以分号分隔)..." />
|
||||||
<div className="flex items-center justify-between px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-[11px] text-[var(--dg-text-muted)]">
|
<div className="flex items-center justify-between px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-[11px] text-[var(--dg-text-muted)]">
|
||||||
<span>
|
<span>
|
||||||
{sqlContent.trim() ? sqlContent.trim().split(/;\s*/).filter(Boolean).length + ' 条语句 · ' : ''}
|
{sqlContent.trim() ? sqlContent.trim().split(/;\s*/).filter(Boolean).length + ' 条语句 · ' : ''}
|
||||||
|
|||||||
Reference in new issue
Block a user