Files
MyGoNavi/frontend/src/components/QueryEditor.tsx
杨国锋 987a3cf9d7 feat(datagrid): 增强数据导出功能并优化表格交互
- 新增导出选中数据/当前页功能,支持 CSV/JSON/MD 格式
- 实现导出文件名默认使用表名
- 在侧边栏增加连接状态指示灯(红/绿/灰)及断开连接功能
- 优化数据表格点击交互,改为双击编辑防止误触
- 修复侧边栏滚动条显示及内容截断问题
- 修复 SQL 调试日志及导出时的上下文引用错误
2026-02-02 14:38:15 +08:00

424 lines
16 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import Editor, { OnMount } from '@monaco-editor/react';
import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select } from 'antd';
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined } from '@ant-design/icons';
import { format } from 'sql-formatter';
import { TabData, ColumnDefinition } from '../types';
import { useStore } from '../store';
import { MySQLQuery, DBGetTables, DBGetAllColumns, MySQLGetDatabases, DBGetColumns } from '../../wailsjs/go/app/App';
import DataGrid from './DataGrid';
const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
const [query, setQuery] = useState(tab.query || 'SELECT * FROM ');
// DataGrid State
const [results, setResults] = useState<any[]>([]);
const [columnNames, setColumnNames] = useState<string[]>([]);
const [pkColumns, setPkColumns] = useState<string[]>([]);
const [targetTableName, setTargetTableName] = useState<string | undefined>(undefined);
const [loading, setLoading] = useState(false);
const [isSaveModalOpen, setIsSaveModalOpen] = useState(false);
const [saveForm] = Form.useForm();
// Database Selection
const [currentConnectionId, setCurrentConnectionId] = useState<string>(tab.connectionId);
const [currentDb, setCurrentDb] = useState<string>(tab.dbName || '');
const [dbList, setDbList] = useState<string[]>([]);
// Resizing state
const [editorHeight, setEditorHeight] = useState(300);
const editorRef = useRef<any>(null);
const monacoRef = useRef<any>(null);
const dragRef = useRef<{ startY: number, startHeight: number } | null>(null);
const tablesRef = useRef<string[]>([]); // Store tables for autocomplete
const allColumnsRef = useRef<{tableName: string, name: string, type: string}[]>([]); // Store all columns
const { connections, addSqlLog } = useStore();
const saveQuery = useStore(state => state.saveQuery);
const darkMode = useStore(state => state.darkMode);
const sqlFormatOptions = useStore(state => state.sqlFormatOptions);
const setSqlFormatOptions = useStore(state => state.setSqlFormatOptions);
// If opening a saved query, load its SQL
useEffect(() => {
if (tab.query) setQuery(tab.query);
}, [tab.query]);
// Fetch Database List
useEffect(() => {
const fetchDbs = async () => {
const conn = connections.find(c => c.id === currentConnectionId);
if (!conn) return;
const config = {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
database: conn.config.database || "",
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
const res = await MySQLGetDatabases(config as any);
if (res.success && Array.isArray(res.data)) {
const dbs = res.data.map((row: any) => row.Database || row.database);
setDbList(dbs);
if (!currentDb) {
if (conn.config.database) setCurrentDb(conn.config.database);
else if (dbs.length > 0 && dbs[0] !== 'information_schema') setCurrentDb(dbs[0]);
}
} else {
setDbList([]);
}
};
fetchDbs();
}, [currentConnectionId, connections, currentDb]);
// Fetch Metadata for Autocomplete
useEffect(() => {
const fetchMetadata = async () => {
const conn = connections.find(c => c.id === currentConnectionId);
if (!conn || !currentDb) return;
const config = {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
database: conn.config.database || "",
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
const resTables = await DBGetTables(config as any, currentDb);
if (resTables.success && Array.isArray(resTables.data)) {
const tableNames = resTables.data.map((row: any) => Object.values(row)[0] as string);
tablesRef.current = tableNames;
} else {
tablesRef.current = [];
}
if (config.type === 'mysql' || !config.type) {
const resCols = await DBGetAllColumns(config as any, currentDb);
if (resCols.success && Array.isArray(resCols.data)) {
allColumnsRef.current = resCols.data;
} else {
allColumnsRef.current = [];
}
}
};
fetchMetadata();
}, [currentConnectionId, currentDb, connections]);
// Handle Resizing
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
dragRef.current = { startY: e.clientY, startHeight: editorHeight };
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
const handleMouseMove = (e: MouseEvent) => {
if (!dragRef.current) return;
const delta = e.clientY - dragRef.current.startY;
const newHeight = Math.max(100, Math.min(window.innerHeight - 200, dragRef.current.startHeight + delta));
setEditorHeight(newHeight);
};
const handleMouseUp = () => {
dragRef.current = null;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
// Setup Autocomplete and Editor
const handleEditorDidMount: OnMount = (editor, monaco) => {
editorRef.current = editor;
monacoRef.current = monaco;
monaco.languages.registerCompletionItemProvider('sql', {
provideCompletionItems: (model: any, position: any) => {
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
const tableRegex = /(?:FROM|JOIN|UPDATE|INTO)\s+[`"]?(\w+)[`"]?/gi;
const foundTables = new Set<string>();
let match;
const fullText = model.getValue();
while ((match = tableRegex.exec(fullText)) !== null) {
foundTables.add(match[1]);
}
const relevantColumns = allColumnsRef.current
.filter(c => foundTables.has(c.tableName))
.map(c => ({
label: c.name,
kind: monaco.languages.CompletionItemKind.Field,
insertText: c.name,
detail: `${c.type} (${c.tableName})`,
range,
sortText: '0' + c.name
}));
const suggestions = [
...['SELECT', 'FROM', 'WHERE', 'LIMIT', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'ON', 'GROUP BY', 'ORDER BY', 'AS', 'AND', 'OR', 'NOT', 'NULL', 'IS', 'IN', 'VALUES', 'SET', 'CREATE', 'TABLE', 'DROP', 'ALTER', 'Add', 'MODIFY', 'CHANGE', 'COLUMN', 'KEY', 'PRIMARY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'AUTO_INCREMENT', 'COMMENT', 'SHOW', 'DESCRIBE', 'EXPLAIN'].map(k => ({
label: k,
kind: monaco.languages.CompletionItemKind.Keyword,
insertText: k,
range
})),
...tablesRef.current.map(t => ({
label: t,
kind: monaco.languages.CompletionItemKind.Class,
insertText: t,
detail: 'Table',
range
})),
...relevantColumns
];
return { suggestions };
}
});
};
const handleFormat = () => {
try {
const formatted = format(query, { language: 'mysql', keywordCase: sqlFormatOptions.keywordCase });
setQuery(formatted);
} catch (e) {
message.error("格式化失败: SQL 语法可能有误");
}
};
const formatSettingsMenu: MenuProps['items'] = [
{
key: 'upper',
label: '关键字大写',
icon: sqlFormatOptions.keywordCase === 'upper' ? '✓' : undefined,
onClick: () => setSqlFormatOptions({ keywordCase: 'upper' })
},
{
key: 'lower',
label: '关键字小写',
icon: sqlFormatOptions.keywordCase === 'lower' ? '✓' : undefined,
onClick: () => setSqlFormatOptions({ keywordCase: 'lower' })
},
];
const handleRun = async () => {
if (!query.trim()) return;
if (!currentDb) {
message.error("请先选择数据库");
return;
}
setLoading(true);
const conn = connections.find(c => c.id === currentConnectionId);
if (!conn) {
message.error("Connection not found");
setLoading(false);
return;
}
const config = {
...conn.config,
port: Number(conn.config.port),
password: conn.config.password || "",
database: conn.config.database || "",
useSSH: conn.config.useSSH || false,
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
// Detect Simple Table Query
let simpleTableName: string | undefined = undefined;
let primaryKeys: string[] = [];
// Naive regex to detect SELECT * FROM table
const tableMatch = query.match(/^\s*SELECT\s+\*\s+FROM\s+[`"]?(\w+)[`"]?\s*(?:WHERE.*)?(?:ORDER BY.*)?(?:LIMIT.*)?$/i);
if (tableMatch) {
simpleTableName = tableMatch[1];
// Fetch PKs for editing
const resCols = await DBGetColumns(config as any, currentDb, simpleTableName);
if (resCols.success) {
primaryKeys = (resCols.data as ColumnDefinition[]).filter(c => c.key === 'PRI').map(c => c.name);
}
}
setTargetTableName(simpleTableName);
setPkColumns(primaryKeys);
const startTime = Date.now();
try {
const res = await MySQLQuery(config as any, currentDb, query);
const duration = Date.now() - startTime;
addSqlLog({
id: `log-${Date.now()}-query`,
timestamp: Date.now(),
sql: query,
status: res.success ? 'success' : 'error',
duration,
message: res.success ? '' : res.message,
affectedRows: (res.success && !Array.isArray(res.data)) ? (res.data as any).affectedRows : (Array.isArray(res.data) ? res.data.length : undefined),
dbName: currentDb
});
if (res.success) {
if (Array.isArray(res.data)) {
if (res.data.length > 0) {
const cols = Object.keys(res.data[0]);
setColumnNames(cols);
setResults(res.data.map((row: any, i: number) => ({ ...row, key: i })));
} else {
message.info('查询执行成功,但没有返回结果。');
setResults([]);
setColumnNames([]);
}
} else {
const affected = (res.data as any).affectedRows;
message.success(`受影响行数: ${affected}`);
setResults([]);
setColumnNames([]);
}
} else {
message.error(res.message);
}
} catch (e: any) {
message.error("Error executing query: " + e.message);
addSqlLog({
id: `log-${Date.now()}-error`,
timestamp: Date.now(),
sql: query,
status: 'error',
duration: Date.now() - startTime,
message: e.message,
dbName: currentDb
});
}
setLoading(false);
};
const handleSave = async () => {
try {
const values = await saveForm.validateFields();
saveQuery({
id: tab.id.startsWith('saved-') ? tab.id : `saved-${Date.now()}`,
name: values.name,
sql: query,
connectionId: currentConnectionId,
dbName: currentDb || tab.dbName || '',
createdAt: Date.now()
});
message.success('查询已保存!');
setIsSaveModalOpen(false);
} catch (e) {
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}>
<Select
style={{ width: 150 }}
placeholder="选择连接"
value={currentConnectionId}
onChange={(val) => {
setCurrentConnectionId(val);
setCurrentDb('');
}}
options={connections.map(c => ({ label: c.name, value: c.id }))}
showSearch
/>
<Select
style={{ width: 200 }}
placeholder="选择数据库"
value={currentDb}
onChange={setCurrentDb}
options={dbList.map(db => ({ label: db, value: db }))}
showSearch
/>
<Button type="primary" icon={<PlayCircleOutlined />} onClick={handleRun} loading={loading}>
</Button>
<Button icon={<SaveOutlined />} onClick={() => {
saveForm.setFieldsValue({ name: tab.title.replace('Query (', '').replace(')', '') });
setIsSaveModalOpen(true);
}}>
</Button>
<Button.Group>
<Tooltip title="美化 SQL">
<Button icon={<FormatPainterOutlined />} onClick={handleFormat}></Button>
</Tooltip>
<Dropdown menu={{ items: formatSettingsMenu }} placement="bottomRight">
<Button icon={<SettingOutlined />} />
</Dropdown>
</Button.Group>
</div>
<div style={{ height: editorHeight, minHeight: '100px', borderBottom: '1px solid #eee' }}>
<Editor
height="100%"
defaultLanguage="sql"
theme={darkMode ? "vs-dark" : "light"}
value={query}
onChange={(val) => setQuery(val || '')}
onMount={handleEditorDidMount}
options={{
minimap: { enabled: false },
automaticLayout: true,
scrollBeyondLastLine: false,
fontSize: 14
}}
/>
</div>
<div
onMouseDown={handleMouseDown}
style={{
height: '5px',
cursor: 'row-resize',
background: darkMode ? '#333' : '#f0f0f0',
flexShrink: 0,
zIndex: 10
}}
title="拖动调整高度"
/>
<div style={{ flex: 1, overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
<DataGrid
data={results}
columnNames={columnNames}
loading={loading}
tableName={targetTableName} // Pass table name only if detection succeeded
dbName={currentDb}
connectionId={currentConnectionId}
pkColumns={pkColumns}
onReload={handleRun}
readOnly={!targetTableName} // Read-only if not a simple table query
/>
</div>
<Modal
title="保存查询"
open={isSaveModalOpen}
onOk={handleSave}
onCancel={() => setIsSaveModalOpen(false)}
okText="确认"
cancelText="取消"
>
<Form form={saveForm} layout="vertical">
<Form.Item name="name" label="查询名称" rules={[{ required: true, message: '请输入查询名称' }]}>
<Input placeholder="例如:查询所有用户" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default QueryEditor;