diff --git a/.github/ISSUE_TEMPLATE/01-bug_report.yml b/.github/ISSUE_TEMPLATE/01-bug_report.yml new file mode 100644 index 0000000..5baaa9f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/01-bug_report.yml @@ -0,0 +1,58 @@ +name: 问题反馈 +description: 软件问题反馈 +title: "[Bug] " +labels: ["bug"] + +body: + - type: checkboxes + id: searched + attributes: + label: 已经搜索过 Issues,未发现重复问题* + options: + - label: 我已经搜索过 Issues,没有发现重复问题 + validations: + required: true + + - type: input + id: system + attributes: + label: 操作系统及版本 + placeholder: Windows 10 22H2 / macOS Mojave / Linux + validations: + required: true + + - type: input + id: version + attributes: + label: 软件安装版本 + placeholder: v0.2.3 + validations: + required: true + + - type: textarea + id: description + attributes: + label: 问题简述及复现流程 + description: 请详细描述你遇到的问题,并提供复现步骤 + placeholder: | + 1. 打开软件 + 2. 点击 xxx + 3. 预期结果是 ... + 4. 实际结果是 ... + 5. 截图 ... + validations: + required: true + + - type: textarea + id: extra + attributes: + label: 其他补充 + description: 如果你有额外信息,请在此填写 + placeholder: 可选 + + - type: checkboxes + id: pr + attributes: + label: 是否愿意提交 PR 修复当前 Issue + options: + - label: 我愿意尝试提交 PR diff --git a/.github/ISSUE_TEMPLATE/02-feature_request.yml b/.github/ISSUE_TEMPLATE/02-feature_request.yml new file mode 100644 index 0000000..268211f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/02-feature_request.yml @@ -0,0 +1,37 @@ +name: 功能建议 +description: 添加全新功能或改进现有功能 +title: "[Enhancement] " +labels: ["enhancement"] + +body: + - type: checkboxes + id: searched + attributes: + label: 已经搜索过 Issues,未发现重复问题* + options: + - label: 我已经搜索过 Issues,没有发现重复问题 + validations: + required: true + + - type: textarea + id: feature + attributes: + label: 功能描述 + description: 请详细描述你希望添加或改进的功能 + placeholder: 请描述你想要的功能 + validations: + required: true + + - type: textarea + id: extra + attributes: + label: 其他补充 + description: 如果你有额外信息,请在此填写 + placeholder: 可选 + + - type: checkboxes + id: pr + attributes: + label: 是否愿意提交 PR 实现当前 Issue + options: + - label: 我愿意尝试提交 PR diff --git a/.github/ISSUE_TEMPLATE/03-generic.yml b/.github/ISSUE_TEMPLATE/03-generic.yml new file mode 100644 index 0000000..d3aeb5a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/03-generic.yml @@ -0,0 +1,30 @@ +name: 其他反馈 +description: 其他类型反馈、建议或讨论 +title: "[Question] " +labels: ["question"] + +body: + - type: checkboxes + id: searched + attributes: + label: 已经搜索过 Issues,未发现重复问题* + options: + - label: 我已经搜索过 Issues,没有发现重复问题 + validations: + required: true + + - type: textarea + id: content + attributes: + label: 内容 + description: 请填写你的反馈、建议或讨论内容 + placeholder: 请描述你的问题或想法 + validations: + required: true + + - type: textarea + id: extra + attributes: + label: 其他补充 + description: 如果你有额外信息,请在此填写 + placeholder: 可选 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f4feaf2..f5b630f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -285,12 +285,12 @@ function App() { title="拖动调整宽度" /> - -
- -
- {isLogPanelOpen && ( - +
+ +
+ {isLogPanelOpen && ( + setIsLogPanelOpen(false)} onResizeStart={handleLogResizeStart} @@ -343,4 +343,4 @@ function App() { ); } -export default App; \ No newline at end of file +export default App; diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 1a1d1c8..8601b35 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -243,26 +243,38 @@ const DataGrid: React.FC = ({ const containerRef = useRef(null); useEffect(() => { - if (!containerRef.current) return; - - let rafId: number; + const el = containerRef.current; + if (!el) return; + + let rafId: number | null = null; + const resizeObserver = new ResizeObserver(entries => { + if (rafId !== null) cancelAnimationFrame(rafId); rafId = requestAnimationFrame(() => { - for (let entry of entries) { - // Use boundingClientRect for more accurate render size (including padding if any) - const height = entry.contentRect.height; - if (height < 50) return; - // Subtract header (~42px) and a buffer - const h = Math.max(100, height - 42); - setTableHeight(h); - } + const target = (entries[0]?.target as HTMLElement | undefined) || containerRef.current; + if (!target) return; + + const height = target.getBoundingClientRect().height; + if (!Number.isFinite(height) || height < 50) return; + + const headerEl = + (target.querySelector('.ant-table-header') as HTMLElement | null) || + (target.querySelector('.ant-table-thead') as HTMLElement | null); + const rawHeaderHeight = headerEl ? headerEl.getBoundingClientRect().height : NaN; + const headerHeight = + Number.isFinite(rawHeaderHeight) && rawHeaderHeight >= 24 && rawHeaderHeight <= 120 ? rawHeaderHeight : 42; + + // 留一点余量,避免底部(边框/滚动条)遮挡最后一行 + const extraBottom = 16; + const nextHeight = Math.max(100, Math.floor(height - headerHeight - extraBottom)); + setTableHeight(nextHeight); }); }); - - resizeObserver.observe(containerRef.current); + + resizeObserver.observe(el); return () => { resizeObserver.disconnect(); - cancelAnimationFrame(rafId); + if (rafId !== null) cancelAnimationFrame(rafId); }; }, []); @@ -727,12 +739,12 @@ const DataGrid: React.FC = ({ const enableVirtual = mergedDisplayData.length >= 200; return ( -
- {/* Toolbar */} -
- {onReload &&
)} - + {/* Ghost Resize Line for Columns */}
= ({ tab }) => { ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" } }; + const normalizeIdentPart = (ident: string) => { + let raw = (ident || '').trim(); + if (!raw) return raw; + const first = raw[0]; + const last = raw[raw.length - 1]; + if ((first === '"' && last === '"') || (first === '`' && last === '`')) { + raw = raw.slice(1, -1).trim(); + } + // 防御:如果传入已包含引号(例如 `"schema"."table"` 的拆分结果),移除残留引号再重新安全转义。 + raw = raw.replace(/["`]/g, '').trim(); + return raw; + }; + const quoteIdentPart = (ident: string) => { - if (!ident) return ident; - if (config.type === 'mysql') return `\`${ident.replace(/`/g, '``')}\``; - return `"${ident.replace(/"/g, '""')}"`; + const raw = normalizeIdentPart(ident); + if (!raw) return raw; + if (config.type === 'mysql') return `\`${raw.replace(/`/g, '``')}\``; + return `"${raw.replace(/"/g, '""')}"`; }; const quoteQualifiedIdent = (ident: string) => { const raw = (ident || '').trim(); if (!raw) return raw; - const parts = raw.split('.').filter(Boolean); + const parts = raw.split('.').map(normalizeIdentPart).filter(Boolean); if (parts.length <= 1) return quoteIdentPart(raw); return parts.map(quoteIdentPart).join('.'); }; @@ -227,7 +241,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => { }, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter return ( -
+
= ({ tab }) => { const [query, setQuery] = useState(tab.query || 'SELECT * FROM '); - // DataGrid State - const [results, setResults] = useState([]); - const [columnNames, setColumnNames] = useState([]); - const [pkColumns, setPkColumns] = useState([]); - const [targetTableName, setTargetTableName] = useState(undefined); + type ResultSet = { + key: string; + sql: string; + rows: any[]; + columns: string[]; + tableName?: string; + pkColumns: string[]; + readOnly: boolean; + }; + + // Result Sets + const [resultSets, setResultSets] = useState([]); + const [activeResultKey, setActiveResultKey] = useState(''); const [loading, setLoading] = useState(false); const [isSaveModalOpen, setIsSaveModalOpen] = useState(false); @@ -210,6 +218,144 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { }, ]; + const splitSQLStatements = (sql: string): string[] => { + const text = (sql || '').replace(/\r\n/g, '\n'); + const statements: string[] = []; + + let cur = ''; + let inSingle = false; + let inDouble = false; + let inBacktick = false; + let escaped = false; + let inLineComment = false; + let inBlockComment = false; + let dollarTag: string | null = null; // postgres/kingbase: $$...$$ or $tag$...$tag$ + + const push = () => { + const s = cur.trim(); + if (s) statements.push(s); + cur = ''; + }; + + const isWS = (ch: string) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'; + + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + const next = i + 1 < text.length ? text[i + 1] : ''; + const prev = i > 0 ? text[i - 1] : ''; + const next2 = i + 2 < text.length ? text[i + 2] : ''; + + if (!inSingle && !inDouble && !inBacktick) { + if (inLineComment) { + cur += ch; + if (ch === '\n') inLineComment = false; + continue; + } + + if (inBlockComment) { + cur += ch; + if (ch === '*' && next === '/') { + cur += next; + i++; + inBlockComment = false; + } + continue; + } + + // Start comments + if (ch === '/' && next === '*') { + cur += ch + next; + i++; + inBlockComment = true; + continue; + } + if (ch === '#') { + cur += ch; + inLineComment = true; + continue; + } + if (ch === '-' && next === '-' && (i === 0 || isWS(prev)) && (next2 === '' || isWS(next2))) { + cur += ch + next; + i++; + inLineComment = true; + continue; + } + + // Dollar-quoted strings (PG/Kingbase) + if (dollarTag) { + if (text.startsWith(dollarTag, i)) { + cur += dollarTag; + i += dollarTag.length - 1; + dollarTag = null; + } else { + cur += ch; + } + continue; + } + if (ch === '$') { + const m = text.slice(i).match(/^\$[A-Za-z0-9_]*\$/); + if (m && m[0]) { + dollarTag = m[0]; + cur += dollarTag; + i += dollarTag.length - 1; + continue; + } + } + } + + if (escaped) { + cur += ch; + escaped = false; + continue; + } + + if ((inSingle || inDouble) && ch === '\\') { + cur += ch; + escaped = true; + continue; + } + + if (!inDouble && !inBacktick && ch === '\'') { + inSingle = !inSingle; + cur += ch; + continue; + } + if (!inSingle && !inBacktick && ch === '"') { + inDouble = !inDouble; + cur += ch; + continue; + } + if (!inSingle && !inDouble && ch === '`') { + inBacktick = !inBacktick; + cur += ch; + continue; + } + + if (!inSingle && !inDouble && !inBacktick && !dollarTag && (ch === ';' || ch === ';')) { + push(); + continue; + } + + cur += ch; + } + + push(); + return statements; + }; + + const getSelectedSQL = (): string => { + const editor = editorRef.current; + if (!editor) return ''; + const model = editor.getModel?.(); + const selection = editor.getSelection?.(); + if (!model || !selection) return ''; + + const selected = model.getValueInRange?.(selection) || ''; + if (typeof selected !== 'string') return ''; + if (!selected.trim()) return ''; + return selected; + }; + const handleRun = async () => { if (!query.trim()) return; if (!currentDb) { @@ -217,6 +363,7 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { return; } setLoading(true); + const runStartTime = Date.now(); const conn = connections.find(c => c.id === currentConnectionId); if (!conn) { message.error("Connection not found"); @@ -233,76 +380,114 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { 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 DBQuery(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 - }); + const rawSQL = getSelectedSQL() || query; + const statements = splitSQLStatements(rawSQL); + if (statements.length === 0) { + message.info('没有可执行的 SQL。'); + setResultSets([]); + setActiveResultKey(''); + return; + } + + const nextResultSets: ResultSet[] = []; + + for (let idx = 0; idx < statements.length; idx++) { + const sql = statements[idx]; + const startTime = Date.now(); + const res = await DBQuery(config as any, currentDb, sql); + const duration = Date.now() - startTime; + + addSqlLog({ + id: `log-${Date.now()}-query-${idx + 1}`, + timestamp: Date.now(), + sql, + 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) { + const prefix = statements.length > 1 ? `第 ${idx + 1} 条语句执行失败:` : ''; + message.error(prefix + res.message); + setResultSets([]); + setActiveResultKey(''); + return; + } + + if (Array.isArray(res.data)) { + const rows = (res.data as any[]) || []; + const cols = (res.fields && res.fields.length > 0) + ? (res.fields as string[]) + : (rows.length > 0 ? Object.keys(rows[0]) : []); - if (res.success) { - if (Array.isArray(res.data)) { - if (res.data.length > 0) { - const cols = Object.keys(res.data[0]); - setColumnNames(cols); - const rows = res.data as any[]; rows.forEach((row: any, i: number) => { if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = i; }); - setResults(rows); + + let simpleTableName: string | undefined = undefined; + let primaryKeys: string[] = []; + const tableMatch = sql.match(/^\s*SELECT\s+\*\s+FROM\s+[`"]?(\w+)[`"]?\s*(?:WHERE.*)?(?:ORDER BY.*)?(?:LIMIT.*)?$/i); + if (tableMatch) { + simpleTableName = tableMatch[1]; + 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); + } + } + + nextResultSets.push({ + key: `result-${idx + 1}`, + sql, + rows, + columns: cols, + tableName: simpleTableName, + pkColumns: primaryKeys, + readOnly: !simpleTableName + }); } else { - message.info('查询执行成功,但没有返回结果。'); - setResults([]); - setColumnNames([]); + const affected = Number((res.data as any)?.affectedRows); + if (Number.isFinite(affected)) { + const row = { affectedRows: affected }; + (row as any)[GONAVI_ROW_KEY] = 0; + nextResultSets.push({ + key: `result-${idx + 1}`, + sql, + rows: [row], + columns: ['affectedRows'], + pkColumns: [], + readOnly: true + }); + } } - } else { - const affected = (res.data as any).affectedRows; - message.success(`受影响行数: ${affected}`); - setResults([]); - setColumnNames([]); - } - } else { - message.error(res.message); + } + + setResultSets(nextResultSets); + setActiveResultKey(nextResultSets[0]?.key || ''); + + if (statements.length > 1) { + message.success(`已执行 ${statements.length} 条语句,生成 ${nextResultSets.length} 个结果集。`); + } else if (nextResultSets.length === 0) { + message.success('执行成功。'); } } catch (e: any) { message.error("Error executing query: " + e.message); addSqlLog({ id: `log-${Date.now()}-error`, timestamp: Date.now(), - sql: query, + sql: getSelectedSQL() || query, status: 'error', - duration: Date.now() - startTime, + duration: Date.now() - runStartTime, message: e.message, dbName: currentDb }); + setResultSets([]); + setActiveResultKey(''); + } finally { + setLoading(false); } - setLoading(false); }; const handleSave = async () => { @@ -322,8 +507,66 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => { } }; + const handleCloseResult = (key: string) => { + setResultSets(prev => { + const idx = prev.findIndex(r => r.key === key); + if (idx < 0) return prev; + const next = prev.filter(r => r.key !== key); + + setActiveResultKey(prevActive => { + if (prevActive && prevActive !== key) return prevActive; + const nextKey = next[idx]?.key || next[idx - 1]?.key || next[0]?.key || ''; + return nextKey; + }); + + return next; + }); + }; + return ( -
+
+