From 30f3ac86aa3753304667bc8646a912e89ad28460 Mon Sep 17 00:00:00 2001 From: TonyJiangWJ Date: Sun, 10 May 2026 08:16:38 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(query-editor):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20SQL=20=E7=89=87=E6=AE=B5=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package.json.md5 | 2 +- frontend/src/App.tsx | 18 + frontend/src/components/QueryEditor.tsx | 40 ++ .../src/components/SnippetSettingsModal.tsx | 422 ++++++++++++++++++ frontend/src/store.ts | 81 +++- frontend/src/types.ts | 10 + frontend/src/utils/sqlSnippetDefaults.test.ts | 73 +++ frontend/src/utils/sqlSnippetDefaults.ts | 154 +++++++ frontend/wailsjs/runtime/runtime.d.ts | 83 +++- frontend/wailsjs/runtime/runtime.js | 56 +++ 10 files changed, 936 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/SnippetSettingsModal.tsx create mode 100644 frontend/src/utils/sqlSnippetDefaults.test.ts create mode 100644 frontend/src/utils/sqlSnippetDefaults.ts diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index bed8925..848588e 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -0295a42fd931778d85157816d79d29e5 \ No newline at end of file +26a843d5fd071d0c7e9d8022e98eb4e3 \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 70c52d2..d15af6d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGe import Sidebar from './components/Sidebar'; import TabManager from './components/TabManager'; import ConnectionModal from './components/ConnectionModal'; +import SnippetSettingsModal from './components/SnippetSettingsModal'; import ConnectionPackagePasswordModal from './components/ConnectionPackagePasswordModal'; import DataSyncModal from './components/DataSyncModal'; import DriverManagerModal from './components/DriverManagerModal'; @@ -1886,6 +1887,7 @@ function App() { const [themeModalSection, setThemeModalSection] = useState<'theme' | 'appearance'>('theme'); const [isAppearanceModalOpen, setIsAppearanceModalOpen] = useState(false); const [isShortcutModalOpen, setIsShortcutModalOpen] = useState(false); + const [isSnippetModalOpen, setIsSnippetModalOpen] = useState(false); const [capturingShortcutAction, setCapturingShortcutAction] = useState(null); const [isProxyModalOpen, setIsProxyModalOpen] = useState(false); const [isDataRootModalOpen, setIsDataRootModalOpen] = useState(false); @@ -2425,6 +2427,16 @@ function App() { }; }, []); + useEffect(() => { + const handleOpenSnippetSettingsEvent = () => { + setIsSnippetModalOpen(true); + }; + window.addEventListener('gonavi:open-snippet-settings', handleOpenSnippetSettingsEvent as EventListener); + return () => { + window.removeEventListener('gonavi:open-snippet-settings', handleOpenSnippetSettingsEvent as EventListener); + }; + }, []); + useEffect(() => { if (!isMacRuntime || !useNativeMacWindowControls) { return; @@ -3606,6 +3618,12 @@ function App() { })} + setIsSnippetModalOpen(false)} + darkMode={darkMode} + overlayTheme={overlayTheme} + /> , '全局代理设置', '统一配置更新检查、驱动管理与未单独指定代理的连接网络出口。')} open={isProxyModalOpen} diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 1bdccc6..9e9c382 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -1372,6 +1372,41 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }, }); + // SQL snippet completion provider + monaco.languages.registerCompletionItemProvider('sql', { + provideCompletionItems: (model: any, position: any) => { + const word = model.getWordUntilPosition(position); + const prefix = word.word.toLowerCase(); + if (!prefix) return { suggestions: [] }; + + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + + const allSnippets = useStore.getState().sqlSnippets; + const matched = allSnippets.filter(s => + s.prefix.toLowerCase().startsWith(prefix) || + s.name.toLowerCase().includes(prefix) + ); + + return { + suggestions: matched.map(s => ({ + label: s.prefix, + kind: monaco.languages.CompletionItemKind.Snippet, + insertText: s.body, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + detail: s.name, + documentation: s.description || s.body, + range, + sortText: '04' + s.prefix, + })), + }; + }, + }); + } // end sqlCompletionRegistered guard // 每个编辑器实例都注册内容变化监听(检测斜杠命令标记) @@ -1462,6 +1497,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc onClick: () => setSqlFormatOptions({ keywordCase: 'lower' }) }, { type: 'divider' }, + { + key: 'snippet-settings', + label: '代码片段管理...', + onClick: () => window.dispatchEvent(new CustomEvent('gonavi:open-snippet-settings')), + }, { key: 'shortcut-settings', label: '快捷键管理...', diff --git a/frontend/src/components/SnippetSettingsModal.tsx b/frontend/src/components/SnippetSettingsModal.tsx new file mode 100644 index 0000000..4339ca5 --- /dev/null +++ b/frontend/src/components/SnippetSettingsModal.tsx @@ -0,0 +1,422 @@ +import { useState, useMemo, useCallback } from 'react'; +import { Modal, Button, Input, List, Tag, Popconfirm, message, Collapse, Typography } from 'antd'; +import { + PlusOutlined, + DeleteOutlined, + UndoOutlined, + SaveOutlined, + CodeOutlined, +} from '@ant-design/icons'; +import { v4 as uuidv4 } from 'uuid'; +import type { SqlSnippet } from '../types'; +import { useStore } from '../store'; +import { BUILTIN_SNIPPET_MAP } from '../utils/sqlSnippetDefaults'; +import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; + +interface SnippetSettingsModalProps { + open: boolean; + onClose: () => void; + darkMode: boolean; + overlayTheme: OverlayWorkbenchTheme; +} + +type DraftSnippet = Omit & { createdAt?: number }; + +const emptyDraft = (): DraftSnippet => ({ + id: uuidv4(), + prefix: '', + name: '', + description: '', + body: '', + isBuiltin: false, +}); + +export default function SnippetSettingsModal({ + open, + onClose, + darkMode, + overlayTheme, +}: SnippetSettingsModalProps) { + const sqlSnippets = useStore((s) => s.sqlSnippets); + const saveSqlSnippet = useStore((s) => s.saveSqlSnippet); + const deleteSqlSnippet = useStore((s) => s.deleteSqlSnippet); + const resetBuiltinSqlSnippet = useStore((s) => s.resetBuiltinSqlSnippet); + + const [selectedId, setSelectedId] = useState(null); + const [draft, setDraft] = useState(emptyDraft()); + const [isCreating, setIsCreating] = useState(false); + + const shellStyle = useMemo( + () => ({ + background: overlayTheme.shellBg, + border: overlayTheme.shellBorder, + boxShadow: overlayTheme.shellShadow, + backdropFilter: overlayTheme.shellBackdropFilter, + }), + [overlayTheme], + ); + + const panelStyle = useMemo( + () => ({ + padding: 16, + borderRadius: 14, + border: overlayTheme.sectionBorder, + background: overlayTheme.sectionBg, + }), + [overlayTheme], + ); + + const textColor = darkMode ? 'rgba(255,255,255,0.85)' : 'rgba(16,24,40,0.9)'; + const mutedColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)'; + const selectedBg = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)'; + + const sortedSnippets = useMemo( + () => [...sqlSnippets].sort((a, b) => a.prefix.localeCompare(b.prefix)), + [sqlSnippets], + ); + + const selectedSnippet = useMemo( + () => sqlSnippets.find((s) => s.id === selectedId) ?? null, + [sqlSnippets, selectedId], + ); + + const handleSelect = useCallback( + (snippet: SqlSnippet) => { + setIsCreating(false); + setSelectedId(snippet.id); + setDraft({ ...snippet }); + }, + [], + ); + + const handleNew = useCallback(() => { + setIsCreating(true); + setSelectedId(null); + setDraft(emptyDraft()); + }, []); + + const handleSave = useCallback(() => { + const prefix = draft.prefix.toLowerCase().replace(/[^a-z0-9_]/g, '').slice(0, 20); + if (!prefix) { + void message.warning('前缀不能为空'); + return; + } + if (!draft.name.trim()) { + void message.warning('名称不能为空'); + return; + } + if (!draft.body.trim()) { + void message.warning('片段内容不能为空'); + return; + } + + const duplicate = sqlSnippets.find( + (s) => s.prefix.toLowerCase() === prefix && s.id !== draft.id, + ); + if (duplicate) { + void message.warning(`前缀 "${prefix}" 已被其他片段使用`); + return; + } + + const toSave: SqlSnippet = { + id: draft.id, + prefix, + name: draft.name.trim(), + description: draft.description?.trim() || undefined, + body: draft.body, + isBuiltin: draft.isBuiltin, + createdAt: draft.createdAt ?? Date.now(), + }; + + saveSqlSnippet(toSave); + setSelectedId(toSave.id); + setIsCreating(false); + void message.success('片段已保存'); + }, [draft, sqlSnippets, saveSqlSnippet]); + + const handleDelete = useCallback( + (id: string) => { + deleteSqlSnippet(id); + if (selectedId === id) { + setSelectedId(null); + setDraft(emptyDraft()); + } + void message.success('片段已删除'); + }, + [deleteSqlSnippet, selectedId], + ); + + const handleReset = useCallback( + (id: string) => { + resetBuiltinSqlSnippet(id); + const original = BUILTIN_SNIPPET_MAP[id]; + if (original && selectedId === id) { + setDraft({ ...original }); + } + void message.success('已重置为默认'); + }, + [resetBuiltinSqlSnippet, selectedId], + ); + + const syntaxHelpItems = [ + { + key: 'syntax', + label: '片段语法说明', + children: ( +
+
{'${1:占位符} 第一个 Tab 位,占位符为提示文字'}
+
{'${2:默认值} 第二个 Tab 位,默认值可直接确认'}
+
{'$0 最终光标位置'}
+
{'${1:表名} 同一数字在多处出现时会同步编辑'}
+
{'内置变量(展开时自动替换为实际值):'}
+
{'${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE} 当前日期'}
+
{'${CURRENT_HOUR}:${CURRENT_MINUTE}:${CURRENT_SECOND} 当前时间'}
+
{'${CURRENT_SECONDS_UNIX} Unix 时间戳'}
+
{'${UUID} 随机 UUID'}
+
{'${RANDOM} 6 位随机数'}
+
+ {'示例:SELECT ${1:列名} FROM ${2:表名} WHERE date >= \'${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}\';$0'} +
+
+ ), + }, + ]; + + const showEditor = isCreating || selectedSnippet; + + return ( + +
+ +
+
+
代码片段管理
+
+ 管理 SQL 代码片段,输入前缀后按 Tab 展开 +
+
+ + } + open={open} + onCancel={onClose} + width={820} + styles={{ + content: shellStyle, + header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, + body: { paddingTop: 8 }, + footer: { background: 'transparent', borderTop: 'none', paddingTop: 40 }, + }} + footer={[ + , + ]} + > +
+ {/* Left: snippet list */} +
+
+ 片段列表 +
+
+ ( + handleSelect(snippet)} + style={{ + cursor: 'pointer', + padding: '6px 12px', + background: selectedId === snippet.id ? selectedBg : 'transparent', + borderLeft: + selectedId === snippet.id + ? `3px solid ${overlayTheme.iconBg}` + : '3px solid transparent', + transition: 'all 0.15s', + }} + > +
+ + {snippet.prefix} + + + {snippet.name} + + {snippet.isBuiltin && ( + + 内置 + + )} +
+
+ )} + /> +
+
+ +
+
+ + {/* Right: editor */} +
+ {showEditor ? ( +
+
+
+
前缀
+ + setDraft((d) => ({ ...d, prefix: e.target.value.toLowerCase() })) + } + placeholder="如 sel, ins" + maxLength={20} + size="small" + /> +
+
+
名称
+ setDraft((d) => ({ ...d, name: e.target.value }))} + placeholder="片段显示名称" + maxLength={60} + size="small" + /> +
+
+ +
+
描述(可选)
+ setDraft((d) => ({ ...d, description: e.target.value }))} + placeholder="补全详情中的描述文字" + maxLength={200} + size="small" + /> +
+ +
+
片段内容
+ setDraft((d) => ({ ...d, body: e.target.value }))} + placeholder={'SELECT ${1:columns} FROM ${2:table_name}$0;'} + style={{ + flex: 1, + minHeight: 120, + fontFamily: 'monospace', + fontSize: 13, + resize: 'none', + }} + /> + +
+ +
+ {draft.isBuiltin && draft.createdAt && ( + handleReset(draft.id)} + > + + + )} + {!draft.isBuiltin && !isCreating && ( + handleDelete(draft.id)} + > + + + )} + +
+
+ ) : ( +
+ 选择左侧片段编辑,或点击「新建片段」 +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 3c0fd25..81221b5 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -13,6 +13,7 @@ import { ExternalSQLDirectory, JVMDiagnosticCommandDraft, JVMDiagnosticEventChunk, + SqlSnippet, } from "./types"; import { ShortcutAction, @@ -23,6 +24,10 @@ import { sanitizeShortcutOptions, } from "./utils/shortcuts"; import { buildExternalSQLDirectoryId } from "./utils/externalSqlTree"; +import { + DEFAULT_SQL_SNIPPETS, + BUILTIN_SNIPPET_MAP, +} from "./utils/sqlSnippetDefaults"; import { toPersistedGlobalProxy } from "./utils/globalProxyDraft"; import { DEFAULT_DATA_GRID_DISPLAY_SETTINGS, @@ -60,7 +65,7 @@ const DEFAULT_TIMEOUT_SECONDS = 30; const MAX_TIMEOUT_SECONDS = 3600; const DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS = 15; const MAX_DIAGNOSTIC_TIMEOUT_SECONDS = 300; -const PERSIST_VERSION = 8; +const PERSIST_VERSION = 9; const PERSIST_STORAGE_KEY = "lite-db-storage"; const DEFAULT_CONNECTION_TYPE = "mysql"; const DEFAULT_JVM_PORT = 9010; @@ -788,6 +793,7 @@ interface AppState { sqlFormatOptions: { keywordCase: "upper" | "lower" }; queryOptions: QueryOptions; shortcutOptions: ShortcutOptions; + sqlSnippets: SqlSnippet[]; sqlLogs: SqlLog[]; tableAccessCount: Record; tableSortPreference: Record; @@ -875,6 +881,9 @@ interface AppState { binding: Partial, ) => void; resetShortcutOptions: () => void; + saveSqlSnippet: (snippet: SqlSnippet) => void; + deleteSqlSnippet: (id: string) => void; + resetBuiltinSqlSnippet: (id: string) => void; addSqlLog: (log: SqlLog) => void; clearSqlLogs: () => void; @@ -967,6 +976,37 @@ const sanitizeSavedQueries = (value: unknown): SavedQuery[] => { return result; }; +const sanitizeSqlSnippets = (value: unknown): SqlSnippet[] => { + if (!Array.isArray(value)) return DEFAULT_SQL_SNIPPETS; + const result: SqlSnippet[] = []; + const seenIds = new Set(); + value.forEach((entry, index) => { + if (!entry || typeof entry !== "object") return; + const raw = entry as Record; + const prefix = toTrimmedString(raw.prefix) + .toLowerCase() + .replace(/[^a-z0-9_]/g, "") + .slice(0, 20); + const body = toTrimmedString(raw.body); + if (!prefix || !body) return; + const id = toTrimmedString(raw.id, `snippet-${index + 1}`) || `snippet-${index + 1}`; + if (seenIds.has(id)) return; + seenIds.add(id); + result.push({ + id, + prefix, + name: toTrimmedString(raw.name, `片段-${index + 1}`) || `片段-${index + 1}`, + description: toTrimmedString(raw.description) || undefined, + body, + isBuiltin: raw.isBuiltin === true, + createdAt: Number.isFinite(Number(raw.createdAt)) + ? Number(raw.createdAt) + : Date.now(), + }); + }); + return result; +}; + const sanitizeExternalSQLDirectories = ( value: unknown, ): ExternalSQLDirectory[] => { @@ -1402,6 +1442,7 @@ export const useStore = create()( showColumnType: true, }, shortcutOptions: cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS), + sqlSnippets: DEFAULT_SQL_SNIPPETS, sqlLogs: [], tableAccessCount: {}, tableSortPreference: {}, @@ -1805,6 +1846,33 @@ export const useStore = create()( }); }, + saveSqlSnippet: (snippet) => + set((state) => { + const existing = state.sqlSnippets.findIndex((s) => s.id === snippet.id); + if (existing >= 0) { + const updated = [...state.sqlSnippets]; + updated[existing] = snippet; + return { sqlSnippets: updated }; + } + return { sqlSnippets: [...state.sqlSnippets, snippet] }; + }), + deleteSqlSnippet: (id) => + set((state) => ({ + sqlSnippets: state.sqlSnippets.filter( + (s) => s.id !== id || s.isBuiltin, + ), + })), + resetBuiltinSqlSnippet: (id) => + set((state) => { + const original = BUILTIN_SNIPPET_MAP[id]; + if (!original) return state; + return { + sqlSnippets: state.sqlSnippets.map((s) => + s.id === id ? { ...original } : s, + ), + }; + }), + addSqlLog: (log) => set((state) => ({ sqlLogs: [log, ...state.sqlLogs].slice(0, 1000) })), // Keep last 1000 logs clearSqlLogs: () => set({ sqlLogs: [] }), @@ -2140,6 +2208,15 @@ export const useStore = create()( nextState.shortcutOptions = sanitizeShortcutOptions( state.shortcutOptions, ); + const existingSnippets = sanitizeSqlSnippets(state.sqlSnippets); + const existingSnippetIds = new Set(existingSnippets.map((s) => s.id)); + const missingSnippets = DEFAULT_SQL_SNIPPETS.filter( + (d) => !existingSnippetIds.has(d.id), + ); + nextState.sqlSnippets = + missingSnippets.length > 0 + ? [...existingSnippets, ...missingSnippets] + : existingSnippets; nextState.tableAccessCount = sanitizeTableAccessCount( state.tableAccessCount, ); @@ -2204,6 +2281,7 @@ export const useStore = create()( sqlFormatOptions: sanitizeSqlFormatOptions(state.sqlFormatOptions), queryOptions: sanitizeQueryOptions(state.queryOptions), shortcutOptions: sanitizeShortcutOptions(state.shortcutOptions), + sqlSnippets: sanitizeSqlSnippets(state.sqlSnippets), tableAccessCount: sanitizeTableAccessCount(state.tableAccessCount), // AI 会话数据不再从 localStorage 恢复,改为从后端文件加载 @@ -2228,6 +2306,7 @@ export const useStore = create()( sqlFormatOptions: state.sqlFormatOptions, queryOptions: state.queryOptions, shortcutOptions: resolveShortcutOptionsForPersistence(state.shortcutOptions), + sqlSnippets: state.sqlSnippets, tableAccessCount: state.tableAccessCount, tableSortPreference: state.tableSortPreference, tableColumnOrders: state.tableColumnOrders, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 768c64f..9b31cfd 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -454,6 +454,16 @@ export interface SavedQuery { createdAt: number; } +export interface SqlSnippet { + id: string; + prefix: string; + name: string; + description?: string; + body: string; + isBuiltin: boolean; + createdAt: number; +} + export interface ExternalSQLDirectory { id: string; name: string; diff --git a/frontend/src/utils/sqlSnippetDefaults.test.ts b/frontend/src/utils/sqlSnippetDefaults.test.ts new file mode 100644 index 0000000..219f1c3 --- /dev/null +++ b/frontend/src/utils/sqlSnippetDefaults.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { DEFAULT_SQL_SNIPPETS, BUILTIN_SNIPPET_MAP } from './sqlSnippetDefaults'; +import type { SqlSnippet } from '../types'; + +describe('sqlSnippetDefaults', () => { + it('DEFAULT_SQL_SNIPPETS should be a non-empty array', () => { + expect(Array.isArray(DEFAULT_SQL_SNIPPETS)).toBe(true); + expect(DEFAULT_SQL_SNIPPETS.length).toBeGreaterThan(0); + }); + + it('every default snippet should have required fields', () => { + for (const s of DEFAULT_SQL_SNIPPETS) { + expect(s.id).toBeTruthy(); + expect(s.prefix).toBeTruthy(); + expect(s.name).toBeTruthy(); + expect(s.body).toBeTruthy(); + expect(s.isBuiltin).toBe(true); + expect(typeof s.createdAt).toBe('number'); + } + }); + + it('every prefix should be lowercase alphanumeric/underscore', () => { + for (const s of DEFAULT_SQL_SNIPPETS) { + expect(s.prefix).toMatch(/^[a-z0-9_]+$/); + } + }); + + it('prefixes should be unique', () => { + const prefixes = DEFAULT_SQL_SNIPPETS.map((s) => s.prefix); + expect(new Set(prefixes).size).toBe(prefixes.length); + }); + + it('ids should be unique', () => { + const ids = DEFAULT_SQL_SNIPPETS.map((s) => s.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('all default snippets should have snippet syntax in body', () => { + for (const s of DEFAULT_SQL_SNIPPETS) { + const hasTabStopOrVariable = /\$\d|\$\{|CURRENT_/.test(s.body); + expect(hasTabStopOrVariable).toBe(true); + } + }); + + it('time-variable snippets should contain CURRENT_ markers', () => { + const seld = DEFAULT_SQL_SNIPPETS.find((s) => s.prefix === 'seld'); + expect(seld).toBeDefined(); + expect(seld!.body).toContain('CURRENT_YEAR'); + expect(seld!.body).toContain('CURRENT_MONTH'); + expect(seld!.body).toContain('CURRENT_DATE'); + + const inst = DEFAULT_SQL_SNIPPETS.find((s) => s.prefix === 'inst'); + expect(inst).toBeDefined(); + expect(inst!.body).toContain('CURRENT_HOUR'); + expect(inst!.body).toContain('CURRENT_MINUTE'); + expect(inst!.body).toContain('CURRENT_SECOND'); + }); + + it('BUILTIN_SNIPPET_MAP should contain all default snippet ids', () => { + for (const s of DEFAULT_SQL_SNIPPETS) { + expect(BUILTIN_SNIPPET_MAP[s.id]).toBeDefined(); + expect(BUILTIN_SNIPPET_MAP[s.id].prefix).toBe(s.prefix); + expect(BUILTIN_SNIPPET_MAP[s.id].body).toBe(s.body); + } + }); + + it('BUILTIN_SNIPPET_MAP entries should be independent copies', () => { + for (const s of DEFAULT_SQL_SNIPPETS) { + const mapped = BUILTIN_SNIPPET_MAP[s.id]; + expect(mapped).not.toBe(s); + } + }); +}); diff --git a/frontend/src/utils/sqlSnippetDefaults.ts b/frontend/src/utils/sqlSnippetDefaults.ts new file mode 100644 index 0000000..458f3a0 --- /dev/null +++ b/frontend/src/utils/sqlSnippetDefaults.ts @@ -0,0 +1,154 @@ +import type { SqlSnippet } from "../types"; + +const builtinSnippets: Omit[] = [ + { + id: "builtin-sel", + prefix: "sel", + name: "SELECT 基本查询", + description: "基本 SELECT 查询模板", + body: "SELECT ${1:column_list} FROM ${2:table_name}$0;", + isBuiltin: true, + }, + { + id: "builtin-selw", + prefix: "selw", + name: "SELECT WHERE", + description: "带 WHERE 条件的 SELECT 查询", + body: "SELECT ${1:columns} FROM ${2:table_name} WHERE ${3:condition}$0;", + isBuiltin: true, + }, + { + id: "builtin-selj", + prefix: "selj", + name: "SELECT JOIN", + description: "带 INNER JOIN 的 SELECT 查询", + body: "SELECT ${1:columns}\nFROM ${2:t1}\nINNER JOIN ${3:t2} ON ${4:t1.id} = ${5:t2.id}\nWHERE ${6:condition}$0;", + isBuiltin: true, + }, + { + id: "builtin-ins", + prefix: "ins", + name: "INSERT", + description: "INSERT 插入数据模板", + body: "INSERT INTO ${1:table_name} (${2:columns})\nVALUES (${3:values})$0;", + isBuiltin: true, + }, + { + id: "builtin-upd", + prefix: "upd", + name: "UPDATE", + description: "UPDATE 更新数据模板", + body: "UPDATE ${1:table_name}\nSET ${2:column} = ${3:value}\nWHERE ${4:condition}$0;", + isBuiltin: true, + }, + { + id: "builtin-del", + prefix: "del", + name: "DELETE", + description: "DELETE 删除数据模板", + body: "DELETE FROM ${1:table_name}\nWHERE ${2:condition}$0;", + isBuiltin: true, + }, + { + id: "builtin-ct", + prefix: "ct", + name: "CREATE TABLE", + description: "CREATE TABLE 建表模板", + body: "CREATE TABLE ${1:table_name} (\n ${2:id} INT PRIMARY KEY AUTO_INCREMENT,\n ${3:col} ${4:VARCHAR(255)} NOT NULL\n)$0;", + isBuiltin: true, + }, + { + id: "builtin-alt", + prefix: "alt", + name: "ALTER TABLE", + description: "ALTER TABLE 添加列模板", + body: "ALTER TABLE ${1:table_name}\nADD COLUMN ${2:col} ${3:VARCHAR(255)}$0;", + isBuiltin: true, + }, + { + id: "builtin-dro", + prefix: "dro", + name: "DROP TABLE", + description: "DROP TABLE 删表模板", + body: "DROP TABLE IF EXISTS ${1:table_name}$0;", + isBuiltin: true, + }, + { + id: "builtin-grp", + prefix: "grp", + name: "GROUP BY", + description: "带 GROUP BY 的聚合查询模板", + body: "SELECT ${1:col}, COUNT(*)\nFROM ${2:table_name}\nGROUP BY ${1:col}$0;", + isBuiltin: true, + }, + { + id: "builtin-ljo", + prefix: "ljo", + name: "LEFT JOIN", + description: "LEFT JOIN 左连接模板", + body: "LEFT JOIN ${1:t} ON ${2:left.col} = ${3:right.col}$0", + isBuiltin: true, + }, + { + id: "builtin-sub", + prefix: "sub", + name: "子查询", + description: "IN 子查询模板", + body: "SELECT ${1:cols}\nFROM ${2:t1}\nWHERE ${3:col} IN (\n SELECT ${4:col} FROM ${5:t2} WHERE ${6:cond}\n)$0;", + isBuiltin: true, + }, + { + id: "builtin-lim", + prefix: "lim", + name: "LIMIT 查询", + description: "带 LIMIT 的分页查询模板", + body: "SELECT ${1:cols} FROM ${2:table_name} LIMIT ${3:10}$0;", + isBuiltin: true, + }, + { + id: "builtin-ord", + prefix: "ord", + name: "ORDER BY", + description: "带排序的查询模板", + body: "SELECT ${1:cols} FROM ${2:table_name} ORDER BY ${3:col} ${4|ASC,DESC|}$0;", + isBuiltin: true, + }, + { + id: "builtin-seld", + prefix: "seld", + name: "SELECT 按日期查询", + description: "按日期条件过滤的 SELECT 查询,自动填入当天日期", + body: "SELECT ${1:cols} FROM ${2:table_name}\nWHERE ${3:date_col} >= '${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}'$0;", + isBuiltin: true, + }, + { + id: "builtin-ctt", + prefix: "ctt", + name: "CREATE TABLE(含时间列)", + description: "建表模板,含 created_at / updated_at 时间列", + body: "CREATE TABLE ${1:table_name} (\n ${2:id} INT PRIMARY KEY AUTO_INCREMENT,\n ${3:col} ${4:VARCHAR(255)},\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP\n)$0;", + isBuiltin: true, + }, + { + id: "builtin-inst", + prefix: "inst", + name: "INSERT(含时间戳)", + description: "INSERT 模板,自动填入当前时间戳", + body: "INSERT INTO ${1:table_name} (${2:columns}, created_at)\nVALUES (${3:values}, '${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE} ${CURRENT_HOUR}:${CURRENT_MINUTE}:${CURRENT_SECOND}')$0;", + isBuiltin: true, + }, +]; + +const now = Date.now(); + +export const DEFAULT_SQL_SNIPPETS: SqlSnippet[] = builtinSnippets.map( + (s, i) => ({ + ...s, + createdAt: now + i, + }) +); + +export const BUILTIN_SNIPPET_MAP: Record = {}; +for (const s of DEFAULT_SQL_SNIPPETS) { + BUILTIN_SNIPPET_MAP[s.id] = { ...s }; +} diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts index 4445dac..3bbea84 100644 --- a/frontend/wailsjs/runtime/runtime.d.ts +++ b/frontend/wailsjs/runtime/runtime.d.ts @@ -246,4 +246,85 @@ export function OnFileDropOff() :void export function CanResolveFilePaths(): boolean; // Resolves file paths for an array of files -export function ResolveFilePaths(files: File[]): void \ No newline at end of file +export function ResolveFilePaths(files: File[]): void + +// Notification types +export interface NotificationOptions { + id: string; + title: string; + subtitle?: string; // macOS and Linux only + body?: string; + categoryId?: string; + data?: { [key: string]: any }; +} + +export interface NotificationAction { + id?: string; + title?: string; + destructive?: boolean; // macOS-specific +} + +export interface NotificationCategory { + id?: string; + actions?: NotificationAction[]; + hasReplyField?: boolean; + replyPlaceholder?: string; + replyButtonTitle?: string; +} + +// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications) +// Initializes the notification service for the application. +// This must be called before sending any notifications. +export function InitializeNotifications(): Promise; + +// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications) +// Cleans up notification resources and releases any held connections. +export function CleanupNotifications(): Promise; + +// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable) +// Checks if notifications are available on the current platform. +export function IsNotificationAvailable(): Promise; + +// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization) +// Requests notification authorization from the user (macOS only). +export function RequestNotificationAuthorization(): Promise; + +// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization) +// Checks the current notification authorization status (macOS only). +export function CheckNotificationAuthorization(): Promise; + +// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification) +// Sends a basic notification with the given options. +export function SendNotification(options: NotificationOptions): Promise; + +// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions) +// Sends a notification with action buttons. Requires a registered category. +export function SendNotificationWithActions(options: NotificationOptions): Promise; + +// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory) +// Registers a notification category that can be used with SendNotificationWithActions. +export function RegisterNotificationCategory(category: NotificationCategory): Promise; + +// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory) +// Removes a previously registered notification category. +export function RemoveNotificationCategory(categoryId: string): Promise; + +// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications) +// Removes all pending notifications from the notification center. +export function RemoveAllPendingNotifications(): Promise; + +// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification) +// Removes a specific pending notification by its identifier. +export function RemovePendingNotification(identifier: string): Promise; + +// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications) +// Removes all delivered notifications from the notification center. +export function RemoveAllDeliveredNotifications(): Promise; + +// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification) +// Removes a specific delivered notification by its identifier. +export function RemoveDeliveredNotification(identifier: string): Promise; + +// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification) +// Removes a notification by its identifier (cross-platform convenience function). +export function RemoveNotification(identifier: string): Promise; \ No newline at end of file diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js index 7cb89d7..556621e 100644 --- a/frontend/wailsjs/runtime/runtime.js +++ b/frontend/wailsjs/runtime/runtime.js @@ -239,4 +239,60 @@ export function CanResolveFilePaths() { export function ResolveFilePaths(files) { return window.runtime.ResolveFilePaths(files); +} + +export function InitializeNotifications() { + return window.runtime.InitializeNotifications(); +} + +export function CleanupNotifications() { + return window.runtime.CleanupNotifications(); +} + +export function IsNotificationAvailable() { + return window.runtime.IsNotificationAvailable(); +} + +export function RequestNotificationAuthorization() { + return window.runtime.RequestNotificationAuthorization(); +} + +export function CheckNotificationAuthorization() { + return window.runtime.CheckNotificationAuthorization(); +} + +export function SendNotification(options) { + return window.runtime.SendNotification(options); +} + +export function SendNotificationWithActions(options) { + return window.runtime.SendNotificationWithActions(options); +} + +export function RegisterNotificationCategory(category) { + return window.runtime.RegisterNotificationCategory(category); +} + +export function RemoveNotificationCategory(categoryId) { + return window.runtime.RemoveNotificationCategory(categoryId); +} + +export function RemoveAllPendingNotifications() { + return window.runtime.RemoveAllPendingNotifications(); +} + +export function RemovePendingNotification(identifier) { + return window.runtime.RemovePendingNotification(identifier); +} + +export function RemoveAllDeliveredNotifications() { + return window.runtime.RemoveAllDeliveredNotifications(); +} + +export function RemoveDeliveredNotification(identifier) { + return window.runtime.RemoveDeliveredNotification(identifier); +} + +export function RemoveNotification(identifier) { + return window.runtime.RemoveNotification(identifier); } \ No newline at end of file