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: 'var(--gn-font-mono)', fontSize: 13, resize: 'none', }} />
{draft.isBuiltin && draft.createdAt && ( handleReset(draft.id)} > )} {!draft.isBuiltin && !isCreating && ( handleDelete(draft.id)} > )}
) : (
选择左侧片段编辑,或点击「新建片段」
)}
); }