mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-24 15:20:03 +08:00
- SQL 编辑器连接和数据库选择器支持悬浮延迟显示完整名称 - SQL 片段管理嵌入工具中心时隐藏重复标题 - 缩小片段操作按钮并拆分滚动区域,避免底部内容被遮挡 - 补充工具栏和片段弹窗源码级回归断言
524 lines
18 KiB
TypeScript
524 lines
18 KiB
TypeScript
import { useState, useMemo, useCallback } from 'react';
|
|
import Modal from './common/ResizableDraggableModal';
|
|
import { 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 { useI18n } from '../i18n/provider';
|
|
import { BUILTIN_SNIPPET_MAP } from '../utils/sqlSnippetDefaults';
|
|
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
|
interface SnippetSettingsModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onBack?: () => void;
|
|
darkMode: boolean;
|
|
overlayTheme: OverlayWorkbenchTheme;
|
|
embedded?: boolean;
|
|
}
|
|
|
|
type DraftSnippet = Omit<SqlSnippet, 'createdAt'> & { createdAt?: number };
|
|
|
|
const emptyDraft = (): DraftSnippet => ({
|
|
id: uuidv4(),
|
|
prefix: '',
|
|
name: '',
|
|
description: '',
|
|
syntaxHelp: '',
|
|
body: '',
|
|
isBuiltin: false,
|
|
});
|
|
|
|
export default function SnippetSettingsModal({
|
|
open,
|
|
onClose,
|
|
onBack,
|
|
darkMode,
|
|
overlayTheme,
|
|
embedded = false,
|
|
}: SnippetSettingsModalProps) {
|
|
const { t } = useI18n();
|
|
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 newSnippetAction = t('snippet_settings.action.new');
|
|
const snippetModalBodyMaxHeight = 'calc(100vh - 128px)';
|
|
const snippetModalEmbeddedBodyMaxHeight = '100%';
|
|
const snippetSyntaxReferenceMaxHeight = 'min(220px, 32vh)';
|
|
|
|
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(t('snippet_settings.message.prefix_required'));
|
|
return;
|
|
}
|
|
if (!draft.name.trim()) {
|
|
void message.warning(t('snippet_settings.message.name_required'));
|
|
return;
|
|
}
|
|
if (!draft.body.trim()) {
|
|
void message.warning(t('snippet_settings.message.body_required'));
|
|
return;
|
|
}
|
|
|
|
const duplicate = sqlSnippets.find(
|
|
(s) => s.prefix.toLowerCase() === prefix && s.id !== draft.id,
|
|
);
|
|
if (duplicate) {
|
|
void message.warning(t('snippet_settings.message.prefix_duplicate', { prefix }));
|
|
return;
|
|
}
|
|
|
|
const toSave: SqlSnippet = {
|
|
id: draft.id,
|
|
prefix,
|
|
name: draft.name.trim(),
|
|
description: draft.description?.trim() || undefined,
|
|
syntaxHelp: draft.syntaxHelp?.trim() || undefined,
|
|
body: draft.body,
|
|
isBuiltin: draft.isBuiltin,
|
|
createdAt: draft.createdAt ?? Date.now(),
|
|
};
|
|
|
|
saveSqlSnippet(toSave);
|
|
setSelectedId(toSave.id);
|
|
setIsCreating(false);
|
|
void message.success(t('snippet_settings.message.saved'));
|
|
}, [draft, sqlSnippets, saveSqlSnippet, t]);
|
|
|
|
const handleDelete = useCallback(
|
|
(id: string) => {
|
|
deleteSqlSnippet(id);
|
|
if (selectedId === id) {
|
|
setSelectedId(null);
|
|
setDraft(emptyDraft());
|
|
}
|
|
void message.success(t('snippet_settings.message.deleted'));
|
|
},
|
|
[deleteSqlSnippet, selectedId, t],
|
|
);
|
|
|
|
const handleReset = useCallback(
|
|
(id: string) => {
|
|
resetBuiltinSqlSnippet(id);
|
|
const original = BUILTIN_SNIPPET_MAP[id];
|
|
if (original && selectedId === id) {
|
|
setDraft({ ...original, syntaxHelp: original.syntaxHelp || '' });
|
|
}
|
|
void message.success(t('snippet_settings.message.reset_default'));
|
|
},
|
|
[resetBuiltinSqlSnippet, selectedId, t],
|
|
);
|
|
|
|
const syntaxHelpItems = useMemo(
|
|
() => [
|
|
{
|
|
key: 'snippet-help',
|
|
label: t('snippet_settings.syntax_help.label'),
|
|
children: (
|
|
<Input.TextArea
|
|
data-sql-snippet-syntax-help-editor="true"
|
|
value={draft.syntaxHelp || ''}
|
|
onChange={(e) => setDraft((d) => ({ ...d, syntaxHelp: e.target.value }))}
|
|
placeholder={t('snippet_settings.syntax_help.placeholder')}
|
|
maxLength={1000}
|
|
autoSize={{ minRows: 4, maxRows: 8 }}
|
|
style={{
|
|
fontSize: 12,
|
|
resize: 'none',
|
|
fontFamily: 'var(--gn-font-mono)',
|
|
}}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
key: 'syntax',
|
|
label: t('snippet_settings.syntax_reference.label'),
|
|
children: (
|
|
<div
|
|
data-sql-snippet-syntax-reference-scroll-region="true"
|
|
style={{
|
|
maxHeight: snippetSyntaxReferenceMaxHeight,
|
|
overflowY: 'auto',
|
|
overflowX: 'hidden',
|
|
overscrollBehavior: 'contain',
|
|
paddingRight: 6,
|
|
fontSize: 12,
|
|
lineHeight: 1.8,
|
|
color: mutedColor,
|
|
fontFamily: 'var(--gn-font-mono)',
|
|
}}
|
|
>
|
|
<div>{t('snippet_settings.syntax_reference.first_tabstop')}</div>
|
|
<div>{t('snippet_settings.syntax_reference.second_tabstop')}</div>
|
|
<div>{t('snippet_settings.syntax_reference.final_cursor')}</div>
|
|
<div>{t('snippet_settings.syntax_reference.linked_tabstop')}</div>
|
|
<div style={{ marginTop: 6, fontWeight: 600, color: textColor }}>{t('snippet_settings.syntax_reference.builtin_variables')}</div>
|
|
<div>{t('snippet_settings.syntax_reference.current_date')}</div>
|
|
<div>{t('snippet_settings.syntax_reference.current_time')}</div>
|
|
<div>{t('snippet_settings.syntax_reference.unix_seconds')}</div>
|
|
<div>{t('snippet_settings.syntax_reference.uuid')}</div>
|
|
<div>{t('snippet_settings.syntax_reference.random')}</div>
|
|
<div style={{ marginTop: 8, fontFamily: 'inherit', color: textColor }}>
|
|
{t('snippet_settings.syntax_reference.example')}
|
|
</div>
|
|
</div>
|
|
),
|
|
},
|
|
],
|
|
[draft.syntaxHelp, mutedColor, snippetSyntaxReferenceMaxHeight, t, textColor],
|
|
);
|
|
|
|
const showEditor = isCreating || selectedSnippet;
|
|
|
|
return (
|
|
<Modal
|
|
title={embedded ? null : (
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, minWidth: 0 }}>
|
|
<div
|
|
style={{
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 12,
|
|
display: 'grid',
|
|
placeItems: 'center',
|
|
background: overlayTheme.iconBg,
|
|
color: overlayTheme.iconColor,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<CodeOutlined />
|
|
</div>
|
|
<div style={{ minWidth: 0 }}>
|
|
<div style={{ fontSize: 16, fontWeight: 600, color: textColor }}>{t('app.tools.entry.snippets.title')}</div>
|
|
<div style={{ fontSize: 12, color: mutedColor, lineHeight: 1.5 }}>
|
|
{t('app.tools.entry.snippets.description')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
open={open}
|
|
embedded={embedded}
|
|
closable={embedded ? false : undefined}
|
|
onCancel={onClose}
|
|
width={820}
|
|
styles={{
|
|
content: shellStyle,
|
|
header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 },
|
|
body: {
|
|
paddingTop: 8,
|
|
paddingBottom: 0,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
height: embedded ? '100%' : undefined,
|
|
maxHeight: embedded ? snippetModalEmbeddedBodyMaxHeight : snippetModalBodyMaxHeight,
|
|
minHeight: 0,
|
|
overflow: 'hidden',
|
|
},
|
|
}}
|
|
footer={null}
|
|
>
|
|
<div
|
|
data-sql-snippet-content-region="true"
|
|
style={{
|
|
display: 'flex',
|
|
gap: 16,
|
|
flex: embedded ? '1 1 0' : '1 1 420px',
|
|
minHeight: 0,
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{/* Left: snippet list */}
|
|
<div
|
|
style={{
|
|
width: 220,
|
|
flexShrink: 0,
|
|
minHeight: 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 }}>
|
|
{t('snippet_settings.list.title')}
|
|
</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"
|
|
>
|
|
{t('snippet_settings.tag.builtin')}
|
|
</Tag>
|
|
)}
|
|
</div>
|
|
</List.Item>
|
|
)}
|
|
/>
|
|
</div>
|
|
<div style={{ padding: 8 }}>
|
|
<Button type="dashed" icon={<PlusOutlined />} block size="small" onClick={handleNew}>
|
|
{newSnippetAction}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right: editor */}
|
|
<div style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex', overflow: 'hidden' }}>
|
|
{showEditor ? (
|
|
<div
|
|
style={{
|
|
...panelStyle,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
height: '100%',
|
|
minHeight: 0,
|
|
overflowY: 'auto',
|
|
overflowX: 'hidden',
|
|
overscrollBehavior: 'contain',
|
|
paddingRight: 12,
|
|
}}
|
|
data-sql-snippet-editor-panel-scroll-region="true"
|
|
>
|
|
<div style={{ display: 'flex', gap: 12, flex: '0 0 auto' }}>
|
|
<div style={{ flex: 0.4 }}>
|
|
<div style={{ fontSize: 12, color: mutedColor, marginBottom: 4 }}>{t('snippet_settings.field.prefix.label')}</div>
|
|
<Input
|
|
value={draft.prefix}
|
|
onChange={(e) =>
|
|
setDraft((d) => ({ ...d, prefix: e.target.value.toLowerCase() }))
|
|
}
|
|
placeholder={t('snippet_settings.field.prefix.placeholder')}
|
|
maxLength={20}
|
|
size="small"
|
|
/>
|
|
</div>
|
|
<div style={{ flex: 0.6 }}>
|
|
<div style={{ fontSize: 12, color: mutedColor, marginBottom: 4 }}>{t('snippet_settings.field.name.label')}</div>
|
|
<Input
|
|
value={draft.name}
|
|
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
|
placeholder={t('snippet_settings.field.name.placeholder')}
|
|
maxLength={60}
|
|
size="small"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ flex: '0 0 auto', marginTop: 10 }}>
|
|
<div style={{ fontSize: 12, color: mutedColor, marginBottom: 4 }}>{t('snippet_settings.field.description.label')}</div>
|
|
<Input
|
|
value={draft.description || ''}
|
|
onChange={(e) => setDraft((d) => ({ ...d, description: e.target.value }))}
|
|
placeholder={t('snippet_settings.field.description.placeholder')}
|
|
maxLength={200}
|
|
size="small"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
data-sql-snippet-editor-scroll-region="true"
|
|
style={{
|
|
flex: '0 0 auto',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
minHeight: 0,
|
|
marginTop: 10,
|
|
}}
|
|
>
|
|
<div style={{ fontSize: 12, color: mutedColor, marginBottom: 4 }}>{t('snippet_settings.field.body.label')}</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={{
|
|
height: 220,
|
|
minHeight: 160,
|
|
maxHeight: 260,
|
|
fontFamily: 'var(--gn-font-mono)',
|
|
fontSize: 13,
|
|
resize: 'none',
|
|
}}
|
|
/>
|
|
<Collapse
|
|
size="small"
|
|
defaultActiveKey={['snippet-help']}
|
|
items={syntaxHelpItems}
|
|
style={{ marginTop: 8, background: 'transparent', flex: '0 0 auto' }}
|
|
/>
|
|
</div>
|
|
|
|
</div>
|
|
) : (
|
|
<div
|
|
style={{
|
|
...panelStyle,
|
|
display: 'grid',
|
|
placeItems: 'center',
|
|
height: '100%',
|
|
color: mutedColor,
|
|
fontSize: 13,
|
|
}}
|
|
>
|
|
{t('snippet_settings.empty_state', { action: newSnippetAction })}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div
|
|
data-sql-snippet-action-row="true"
|
|
style={{
|
|
display: 'flex',
|
|
flex: '0 0 auto',
|
|
gap: 10,
|
|
justifyContent: 'flex-end',
|
|
alignItems: 'center',
|
|
paddingTop: 8,
|
|
marginTop: 8,
|
|
borderTop: overlayTheme.sectionBorder,
|
|
}}
|
|
>
|
|
{showEditor && draft.isBuiltin && draft.createdAt && (
|
|
<Popconfirm
|
|
title={t('snippet_settings.confirm.reset.title')}
|
|
description={t('snippet_settings.confirm.reset.description')}
|
|
onConfirm={() => handleReset(draft.id)}
|
|
>
|
|
<Button icon={<UndoOutlined />} size="middle" style={{ minWidth: 104 }}>
|
|
{t('snippet_settings.action.reset')}
|
|
</Button>
|
|
</Popconfirm>
|
|
)}
|
|
{showEditor && !draft.isBuiltin && !isCreating && (
|
|
<Popconfirm
|
|
title={t('snippet_settings.confirm.delete.title')}
|
|
description={t('snippet_settings.confirm.delete.description')}
|
|
onConfirm={() => handleDelete(draft.id)}
|
|
>
|
|
<Button danger icon={<DeleteOutlined />} size="middle" style={{ minWidth: 84 }}>
|
|
{t('snippet_settings.action.delete')}
|
|
</Button>
|
|
</Popconfirm>
|
|
)}
|
|
{showEditor && (
|
|
<Button type="primary" icon={<SaveOutlined />} size="middle" style={{ minWidth: 84 }} onClick={handleSave}>
|
|
{t('snippet_settings.action.save')}
|
|
</Button>
|
|
)}
|
|
<Button size="middle" style={{ minWidth: 84 }} onClick={onClose}>
|
|
{t('snippet_settings.action.close')}
|
|
</Button>
|
|
{onBack ? (
|
|
<Button size="middle" style={{ minWidth: 104 }} onClick={onBack}>
|
|
{t('common.back_to_previous')}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|