From 08167020844fee2b401b9e49bd60e1a68cd52a35 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 16 Jun 2026 08:48:43 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(external-sql):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=A4=96=E9=83=A8=20SQL=20=E6=96=87=E4=BB=B6=E4=B8=A2?= =?UTF-8?q?=E5=A4=B1=E5=90=8E=E6=A0=87=E7=AD=BE=E6=97=A0=E6=B3=95=E5=85=B3?= =?UTF-8?q?=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端读取 SQL 文件失败时返回 file_not_found 结构化错误码 - 前端识别文件被删除或移动的场景,允许用户确认关闭标签 - 保留权限、网络盘异常等非缺失错误的关闭拦截,避免误丢草稿 - 补充前后端测试覆盖缺失文件识别与标签关闭提示 Close #566 --- .../src/components/TabManager.hover.test.tsx | 5 + frontend/src/components/TabManager.tsx | 138 +++++++++++------- frontend/src/utils/sqlFileTabDirty.test.ts | 22 +++ frontend/src/utils/sqlFileTabDirty.ts | 34 +++++ internal/app/methods_file.go | 8 +- .../app/methods_file_sql_directory_test.go | 20 +++ 6 files changed, 175 insertions(+), 52 deletions(-) diff --git a/frontend/src/components/TabManager.hover.test.tsx b/frontend/src/components/TabManager.hover.test.tsx index 420ba74..2a2c4c6 100644 --- a/frontend/src/components/TabManager.hover.test.tsx +++ b/frontend/src/components/TabManager.hover.test.tsx @@ -208,6 +208,11 @@ describe('TabManager hover info', () => { const source = readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8'); expect(source).toContain('ReadSQLFile(filePath)'); + expect(source).toContain('isSQLFileMissingReadResult(res)'); + expect(source).toContain('isSQLFileMissingErrorMessage(errorMessage)'); + expect(source).toContain("title: '关闭已丢失的 SQL 文件标签?'"); + expect(source).toContain('关闭后将丢弃标签内的本地草稿'); + expect(source).toContain('confirmDirtyTabsOrClose();'); expect(source).toContain("getSQLFileTabDraft(tab.id, String(tab.query ?? ''))"); expect(source).toContain('hasSQLFileTabUnsavedChanges({ ...tab, query: draft }, normalizeSQLFileReadContent(res.data))'); expect(source).toContain("title: '保存 SQL 文件修改?'"); diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index afee795..b231dce 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -34,6 +34,8 @@ import { ReadSQLFile, WriteSQLFile } from '../../wailsjs/go/app/App'; import { getSQLFileTabPath, hasSQLFileTabUnsavedChanges, + isSQLFileMissingErrorMessage, + isSQLFileMissingReadResult, isSQLFileQueryTab, normalizeSQLFileReadContent, } from '../utils/sqlFileTabDirty'; @@ -450,12 +452,17 @@ const TabManager: React.FC = React.memo(() => { }; const dirtyTabs: Array<{ tab: TabData; draft: string }> = []; + const missingFileTabs: Array<{ tab: TabData; filePath: string }> = []; for (const tab of candidateTabs) { const filePath = getSQLFileTabPath(tab); if (!filePath) continue; try { const res = await ReadSQLFile(filePath); if (!res.success) { + if (isSQLFileMissingReadResult(res)) { + missingFileTabs.push({ tab, filePath }); + continue; + } message.error(`读取 SQL 文件失败,已取消关闭:${res.message || filePath}`); return; } @@ -464,64 +471,93 @@ const TabManager: React.FC = React.memo(() => { dirtyTabs.push({ tab, draft }); } } catch (error) { - message.error('读取 SQL 文件失败,已取消关闭:' + (error instanceof Error ? error.message : String(error))); + const errorMessage = error instanceof Error ? error.message : String(error); + if (isSQLFileMissingErrorMessage(errorMessage)) { + missingFileTabs.push({ tab, filePath }); + continue; + } + message.error('读取 SQL 文件失败,已取消关闭:' + errorMessage); return; } } - if (dirtyTabs.length === 0) { - closeConfirmedTabsAndClearDrafts(); + const confirmDirtyTabsOrClose = () => { + if (dirtyTabs.length === 0) { + closeConfirmedTabsAndClearDrafts(); + return; + } + + const firstDirtyTab = dirtyTabs[0].tab; + const dirtyFilePath = getSQLFileTabPath(firstDirtyTab); + const dirtyLabel = dirtyTabs.length === 1 + ? `“${firstDirtyTab.title || dirtyFilePath}”` + : `${dirtyTabs.length} 个 SQL 文件`; + + let destroyConfirm: (() => void) | null = null; + const confirmRef = Modal.confirm({ + title: '保存 SQL 文件修改?', + content: `${dirtyLabel} 有未保存修改,是否保存后再关闭?`, + okText: '保存并关闭', + cancelText: '取消', + closable: true, + maskClosable: true, + okButtonProps: { type: 'primary' }, + footer: (_, { OkBtn, CancelBtn }) => ( + <> + + + + + ), + onOk: async () => { + try { + for (const { tab, draft } of dirtyTabs) { + const filePath = getSQLFileTabPath(tab); + if (!filePath) continue; + const res = await WriteSQLFile(filePath, draft); + if (!res.success) { + throw new Error(`保存 ${tab.title || filePath} 失败:${res.message || '未知错误'}`); + } + } + message.success('SQL 文件已保存'); + closeConfirmedTabsAndClearDrafts(); + } catch (error) { + message.error(error instanceof Error ? error.message : String(error)); + throw error; + } + }, + }); + destroyConfirm = confirmRef.destroy; + }; + + if (missingFileTabs.length > 0) { + const firstMissing = missingFileTabs[0]; + const missingLabel = missingFileTabs.length === 1 + ? `“${firstMissing.tab.title || firstMissing.filePath}”` + : `${missingFileTabs.length} 个 SQL 文件标签`; + Modal.confirm({ + title: '关闭已丢失的 SQL 文件标签?', + content: `${missingLabel} 对应的外部 SQL 文件已不存在或已被移动,关闭后将丢弃标签内的本地草稿。`, + okText: dirtyTabs.length > 0 ? '继续关闭' : '关闭标签', + cancelText: '取消', + closable: true, + maskClosable: true, + okButtonProps: { danger: true }, + onOk: () => { + confirmDirtyTabsOrClose(); + }, + }); return; } - const firstDirtyTab = dirtyTabs[0].tab; - const dirtyFilePath = getSQLFileTabPath(firstDirtyTab); - const dirtyLabel = dirtyTabs.length === 1 - ? `“${firstDirtyTab.title || dirtyFilePath}”` - : `${dirtyTabs.length} 个 SQL 文件`; - - let destroyConfirm: (() => void) | null = null; - const confirmRef = Modal.confirm({ - title: '保存 SQL 文件修改?', - content: `${dirtyLabel} 有未保存修改,是否保存后再关闭?`, - okText: '保存并关闭', - cancelText: '取消', - closable: true, - maskClosable: true, - okButtonProps: { type: 'primary' }, - footer: (_, { OkBtn, CancelBtn }) => ( - <> - - - - - ), - onOk: async () => { - try { - for (const { tab, draft } of dirtyTabs) { - const filePath = getSQLFileTabPath(tab); - if (!filePath) continue; - const res = await WriteSQLFile(filePath, draft); - if (!res.success) { - throw new Error(`保存 ${tab.title || filePath} 失败:${res.message || '未知错误'}`); - } - } - message.success('SQL 文件已保存'); - closeConfirmedTabsAndClearDrafts(); - } catch (error) { - message.error(error instanceof Error ? error.message : String(error)); - throw error; - } - }, - }); - destroyConfirm = confirmRef.destroy; + confirmDirtyTabsOrClose(); }, []); const closeTabsWithSQLFilePrompt = useCallback((targetIds: string[], closeConfirmedTabs: () => void) => { diff --git a/frontend/src/utils/sqlFileTabDirty.test.ts b/frontend/src/utils/sqlFileTabDirty.test.ts index 83250eb..bed3ac6 100644 --- a/frontend/src/utils/sqlFileTabDirty.test.ts +++ b/frontend/src/utils/sqlFileTabDirty.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest'; import { getSQLFileTabPath, hasSQLFileTabUnsavedChanges, + isSQLFileMissingErrorMessage, + isSQLFileMissingReadResult, isSQLFileQueryTab, normalizeSQLFileReadContent, } from './sqlFileTabDirty'; @@ -34,4 +36,24 @@ describe('sqlFileTabDirty', () => { query: 'select 2;', } as any, 'select 1;')).toBe(true); }); + + it('detects missing SQL file read failures by structured error code', () => { + expect(isSQLFileMissingReadResult({ + success: false, + message: '无法读取文件信息: stat /tmp/missing.sql: no such file or directory', + data: { errorCode: 'file_not_found', filePath: '/tmp/missing.sql' }, + })).toBe(true); + + expect(isSQLFileMissingReadResult({ + success: false, + message: '无法读取文件信息: permission denied', + data: { filePath: '/tmp/report.sql' }, + })).toBe(false); + }); + + it('keeps platform-specific missing file messages as a fallback', () => { + expect(isSQLFileMissingErrorMessage('GetFileAttributesEx C:\\Users\\me\\missing.sql: The system cannot find the file specified.')).toBe(true); + expect(isSQLFileMissingErrorMessage('stat /Users/me/missing.sql: no such file or directory')).toBe(true); + expect(isSQLFileMissingErrorMessage('无法读取文件信息: 权限不足')).toBe(false); + }); }); diff --git a/frontend/src/utils/sqlFileTabDirty.ts b/frontend/src/utils/sqlFileTabDirty.ts index c8442ac..473f9fd 100644 --- a/frontend/src/utils/sqlFileTabDirty.ts +++ b/frontend/src/utils/sqlFileTabDirty.ts @@ -2,6 +2,8 @@ import type { TabData } from '../types'; const toTrimmedString = (value: unknown): string => String(value ?? '').trim(); +export const SQL_FILE_NOT_FOUND_ERROR_CODE = 'file_not_found'; + export const getSQLFileTabPath = (tab: Pick | null | undefined): string => { if (!tab || tab.type !== 'query') return ''; return toTrimmedString(tab.filePath); @@ -28,3 +30,35 @@ export const hasSQLFileTabUnsavedChanges = ( if (!isSQLFileQueryTab(tab)) return false; return String(tab.query ?? '') !== diskContent; }; + +const SQL_FILE_MISSING_MESSAGE_PATTERNS = [ + 'no such file or directory', + 'cannot find the file specified', + 'system cannot find the file specified', + 'does not exist', + 'not exist', + '系统找不到指定的文件', + '文件不存在', +]; + +export const isSQLFileMissingErrorMessage = (message: unknown): boolean => { + const normalizedMessage = toTrimmedString(message).toLowerCase(); + if (!normalizedMessage) return false; + return SQL_FILE_MISSING_MESSAGE_PATTERNS.some((pattern) => normalizedMessage.includes(pattern)); +}; + +export const isSQLFileMissingReadResult = (result: unknown): boolean => { + if (!result || typeof result !== 'object') return false; + const payload = result as Record; + if (payload.success === true) return false; + + const data = payload.data; + if (data && typeof data === 'object') { + const errorCode = toTrimmedString((data as Record).errorCode).toLowerCase(); + if (errorCode === SQL_FILE_NOT_FOUND_ERROR_CODE) { + return true; + } + } + + return isSQLFileMissingErrorMessage(payload.message); +}; diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index 4fc0d3e..c8390ef 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -30,6 +30,8 @@ import ( const minExportQueryTimeout = 5 * time.Minute const minClickHouseExportQueryTimeout = 2 * time.Hour const maxSQLFileSizeBytes int64 = 50 * 1024 * 1024 + +const sqlFileErrorCodeNotFound = "file_not_found" const sqlFileBatchMaxStatements = 1000 const sqlFileBatchMaxBytes = 4 * 1024 * 1024 const sqlFileProgressStatementInterval = 100 @@ -321,7 +323,11 @@ func readSQLFileByPath(filePath string) connection.QueryResult { fi, err := os.Stat(selection) if err != nil { - return connection.QueryResult{Success: false, Message: fmt.Sprintf("无法读取文件信息: %v", err)} + data := map[string]interface{}{"filePath": selection} + if os.IsNotExist(err) { + data["errorCode"] = sqlFileErrorCodeNotFound + } + return connection.QueryResult{Success: false, Message: fmt.Sprintf("无法读取文件信息: %v", err), Data: data} } if fi.IsDir() { return connection.QueryResult{Success: false, Message: "所选路径不是 SQL 文件"} diff --git a/internal/app/methods_file_sql_directory_test.go b/internal/app/methods_file_sql_directory_test.go index 2b7aae7..397b6e5 100644 --- a/internal/app/methods_file_sql_directory_test.go +++ b/internal/app/methods_file_sql_directory_test.go @@ -302,6 +302,26 @@ func TestReadSQLFileByPathReturnsLargeFileMetadata(t *testing.T) { } } +func TestReadSQLFileByPathMarksMissingFile(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "missing.sql") + + result := readSQLFileByPath(filePath) + if result.Success { + t.Fatalf("expected missing sql file read to fail, got %#v", result) + } + + data, ok := result.Data.(map[string]interface{}) + if !ok { + t.Fatalf("expected error metadata map, got %#v", result.Data) + } + if data["errorCode"] != sqlFileErrorCodeNotFound { + t.Fatalf("expected file_not_found error code, got %#v", data["errorCode"]) + } + if data["filePath"] != filePath { + t.Fatalf("expected filePath %q, got %#v", filePath, data["filePath"]) + } +} + func TestReadSQLFileWithMetadataByPathReturnsSmallFileContentAndPath(t *testing.T) { filePath := filepath.Join(t.TempDir(), "report.sql") if err := os.WriteFile(filePath, []byte("select 1;"), 0o644); err != nil {