Merge pull request #451 from TonyJiangWJ/feature/sql-snippets

# Conflicts:
#	frontend/package.json.md5
This commit is contained in:
Syngnat
2026-05-10 12:46:45 +08:00
10 changed files with 937 additions and 4 deletions

View File

@@ -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: '快捷键管理...',

View File

@@ -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<SqlSnippet, 'createdAt'> & { 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<string | null>(null);
const [draft, setDraft] = useState<DraftSnippet>(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: (
<div style={{ fontSize: 12, lineHeight: 1.8, color: mutedColor, fontFamily: 'monospace' }}>
<div>{'${1:占位符} 第一个 Tab 位,占位符为提示文字'}</div>
<div>{'${2:默认值} 第二个 Tab 位,默认值可直接确认'}</div>
<div>{'$0 最终光标位置'}</div>
<div>{'${1:表名} 同一数字在多处出现时会同步编辑'}</div>
<div style={{ marginTop: 6, fontWeight: 600, color: textColor }}>{'内置变量(展开时自动替换为实际值):'}</div>
<div>{'${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE} 当前日期'}</div>
<div>{'${CURRENT_HOUR}:${CURRENT_MINUTE}:${CURRENT_SECOND} 当前时间'}</div>
<div>{'${CURRENT_SECONDS_UNIX} Unix 时间戳'}</div>
<div>{'${UUID} 随机 UUID'}</div>
<div>{'${RANDOM} 6 位随机数'}</div>
<div style={{ marginTop: 8, fontFamily: 'inherit', color: textColor }}>
{'示例SELECT ${1:列名} FROM ${2:表名} WHERE date >= \'${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}\';$0'}
</div>
</div>
),
},
];
const showEditor = isCreating || selectedSnippet;
return (
<Modal
title={
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div
style={{
width: 36,
height: 36,
borderRadius: 12,
display: 'grid',
placeItems: 'center',
background: overlayTheme.iconBg,
color: overlayTheme.iconColor,
flexShrink: 0,
}}
>
<CodeOutlined />
</div>
<div>
<div style={{ fontSize: 16, fontWeight: 600, color: textColor }}></div>
<div style={{ fontSize: 12, color: mutedColor, lineHeight: 1.5 }}>
SQL Tab
</div>
</div>
</div>
}
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={[
<Button key="close" type="primary" onClick={onClose}>
</Button>,
]}
>
<div style={{ display: 'flex', gap: 16, minHeight: 420 }}>
{/* Left: snippet list */}
<div
style={{
width: 220,
flexShrink: 0,
borderRadius: 14,
border: overlayTheme.sectionBorder,
background: overlayTheme.sectionBg,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<div style={{ padding: '8px 12px 4px', fontSize: 12, color: mutedColor, fontWeight: 600 }}>
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
<List
size="small"
dataSource={sortedSnippets}
renderItem={(snippet) => (
<List.Item
onClick={() => 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',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, overflow: 'hidden' }}>
<Typography.Text
code
style={{ fontSize: 12, flexShrink: 0, color: textColor }}
>
{snippet.prefix}
</Typography.Text>
<span
style={{
fontSize: 12,
color: textColor,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{snippet.name}
</span>
{snippet.isBuiltin && (
<Tag
style={{
fontSize: 10,
lineHeight: '16px',
padding: '0 4px',
margin: 0,
borderRadius: 4,
}}
color="blue"
>
</Tag>
)}
</div>
</List.Item>
)}
/>
</div>
<div style={{ padding: 8 }}>
<Button type="dashed" icon={<PlusOutlined />} block size="small" onClick={handleNew}>
</Button>
</div>
</div>
{/* Right: editor */}
<div style={{ flex: 1, minWidth: 0 }}>
{showEditor ? (
<div
style={{
...panelStyle,
display: 'flex',
flexDirection: 'column',
gap: 12,
height: '100%',
}}
>
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ flex: 0.4 }}>
<div style={{ fontSize: 12, color: mutedColor, marginBottom: 4 }}></div>
<Input
value={draft.prefix}
onChange={(e) =>
setDraft((d) => ({ ...d, prefix: e.target.value.toLowerCase() }))
}
placeholder="如 sel, ins"
maxLength={20}
size="small"
/>
</div>
<div style={{ flex: 0.6 }}>
<div style={{ fontSize: 12, color: mutedColor, marginBottom: 4 }}></div>
<Input
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
placeholder="片段显示名称"
maxLength={60}
size="small"
/>
</div>
</div>
<div>
<div style={{ fontSize: 12, color: mutedColor, marginBottom: 4 }}></div>
<Input
value={draft.description || ''}
onChange={(e) => setDraft((d) => ({ ...d, description: e.target.value }))}
placeholder="补全详情中的描述文字"
maxLength={200}
size="small"
/>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ fontSize: 12, color: mutedColor, marginBottom: 4 }}></div>
<Input.TextArea
value={draft.body}
onChange={(e) => 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',
}}
/>
<Collapse
size="small"
items={syntaxHelpItems}
style={{ marginTop: 8, background: 'transparent' }}
/>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4 }}>
{draft.isBuiltin && draft.createdAt && (
<Popconfirm
title="重置为默认"
description="将恢复此内置片段的原始内容"
onConfirm={() => handleReset(draft.id)}
>
<Button icon={<UndoOutlined />} size="small">
</Button>
</Popconfirm>
)}
{!draft.isBuiltin && !isCreating && (
<Popconfirm
title="删除片段"
description="确定要删除此片段吗?"
onConfirm={() => handleDelete(draft.id)}
>
<Button danger icon={<DeleteOutlined />} size="small">
</Button>
</Popconfirm>
)}
<Button type="primary" icon={<SaveOutlined />} size="small" onClick={handleSave}>
</Button>
</div>
</div>
) : (
<div
style={{
...panelStyle,
display: 'grid',
placeItems: 'center',
height: '100%',
color: mutedColor,
fontSize: 13,
}}
>
</div>
)}
</div>
</div>
</Modal>
);
}