Files
MyGoNavi/frontend/src/components/SnippetSettingsModal.tsx
Syngnat 8a56367d02 🐛 fix(query-editor): 优化工具栏长名称提示与片段弹窗布局
- SQL 编辑器连接和数据库选择器支持悬浮延迟显示完整名称
- SQL 片段管理嵌入工具中心时隐藏重复标题
- 缩小片段操作按钮并拆分滚动区域,避免底部内容被遮挡
- 补充工具栏和片段弹窗源码级回归断言
2026-06-25 18:42:11 +08:00

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>
);
}