mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-01 07:01:24 +08:00
✨ feat(ui): 完成新版 UI 全量改造
- 整体布局:按新版 UI 重构左侧导航、对象树、连接分组和右键菜单体系 - 数据视图:优化 DDL 侧栏、横向滚动、筛选输入、编辑入口和虚拟表格体验 - AI 面板:重构新版入口、输入区、模型选择、快捷键和悬浮布局 - 标签与快捷键:补齐 Tab 悬浮信息、复制交互和 Mac/Windows 快捷键配置 - 工程质量:新增 v2 主题样式、菜单组件、外观工具和回归测试覆盖
This commit is contained in:
@@ -10,7 +10,7 @@ import type {
|
||||
JVMAIPlanContext,
|
||||
JVMDiagnosticPlanContext,
|
||||
} from '../types';
|
||||
import { DownOutlined } from '@ant-design/icons';
|
||||
import { DatabaseOutlined, DownOutlined, HistoryOutlined, TableOutlined, WarningOutlined } from '@ant-design/icons';
|
||||
import './AIChatPanel.css';
|
||||
|
||||
import { AIChatHeader } from './ai/AIChatHeader';
|
||||
@@ -29,6 +29,8 @@ import { buildAIReadonlyPreviewSQL } from '../utils/aiSqlLimit';
|
||||
import { resolveAITableSchemaToolResult } from '../utils/aiTableSchemaTool';
|
||||
import { consumeAIChatSendShortcutOnKeyDown } from '../utils/aiChatSendShortcut';
|
||||
import { toAIRequestMessage } from '../utils/aiMessagePayload';
|
||||
import { getShortcutPlatform, resolveShortcutBinding } from '../utils/shortcuts';
|
||||
import { isMacLikePlatform } from '../utils/appearance';
|
||||
|
||||
interface AIChatPanelProps {
|
||||
width?: number;
|
||||
@@ -230,6 +232,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
const [panelWidth, setPanelWidth] = useState(width);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [activePanelMode, setActivePanelMode] = useState<'chat' | 'insights' | 'history'>('chat');
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -245,6 +248,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
|
||||
const aiChatHistory = useStore(state => state.aiChatHistory);
|
||||
const aiActiveSessionId = useStore(state => state.aiActiveSessionId);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const createNewAISession = useStore(state => state.createNewAISession);
|
||||
const addAIChatMessage = useStore(state => state.addAIChatMessage);
|
||||
const updateAIChatMessage = useStore(state => state.updateAIChatMessage);
|
||||
@@ -257,8 +261,17 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
const connections = useStore(state => state.connections);
|
||||
const tabs = useStore(state => state.tabs);
|
||||
const activeTabId = useStore(state => state.activeTabId);
|
||||
const sqlLogs = useStore(state => state.sqlLogs);
|
||||
const aiChatSessions = useStore(state => state.aiChatSessions);
|
||||
const setAIActiveSessionId = useStore(state => state.setAIActiveSessionId);
|
||||
const aiPanelVisible = useStore(state => state.aiPanelVisible);
|
||||
const aiChatSendShortcutBinding = useStore(state => state.shortcutOptions.sendAIChatMessage);
|
||||
const isV2Ui = appearance.uiVersion === 'v2';
|
||||
const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform());
|
||||
const aiChatSendShortcutBinding = useStore(state => resolveShortcutBinding(
|
||||
state.shortcutOptions,
|
||||
'sendAIChatMessage',
|
||||
activeShortcutPlatform,
|
||||
));
|
||||
|
||||
const getCurrentJVMPlanContext = useCallback((): JVMAIPlanContext | undefined => {
|
||||
const state = useStore.getState();
|
||||
@@ -476,6 +489,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) return;
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: sending ? 'auto' : 'smooth', block: 'end' });
|
||||
}, [messages.length, sending]);
|
||||
|
||||
@@ -1516,7 +1530,9 @@ SELECT * FROM users WHERE status = 1;
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(() => {
|
||||
const delta = resizeStartX.current - e.clientX;
|
||||
const newWidth = Math.min(Math.max(resizeStartWidth.current + delta, 280), 700);
|
||||
const minWidth = isV2Ui ? 300 : 280;
|
||||
const maxWidth = isV2Ui ? 520 : 700;
|
||||
const newWidth = Math.min(Math.max(resizeStartWidth.current + delta, minWidth), maxWidth);
|
||||
dragWidthRef.current = newWidth;
|
||||
|
||||
// 仅更新 ghost 虚线位置,通过绝对定位规避重排
|
||||
@@ -1552,7 +1568,7 @@ SELECT * FROM users WHERE status = 1;
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.pointerEvents = '';
|
||||
};
|
||||
}, [isResizing, onWidthChange]);
|
||||
}, [isResizing, isV2Ui, onWidthChange]);
|
||||
|
||||
// 回推幽灵上下文:基于 get_tables 记录进行表级精确匹配(useMemo 缓存,避免每帧重算)
|
||||
const { inferredConnectionId, inferredDbName } = useMemo(() => {
|
||||
@@ -1595,9 +1611,67 @@ SELECT * FROM users WHERE status = 1;
|
||||
const ck = activeContext?.connectionId ? `${activeContext.connectionId}:${activeContext.dbName || ''}` : 'default';
|
||||
return (aiContexts[ck] || []).map(c => `${c.dbName}.${c.tableName}`);
|
||||
}, [activeContext?.connectionId, activeContext?.dbName, aiContexts]);
|
||||
const aiInsights = useMemo(() => {
|
||||
const recentLogs = sqlLogs.slice(0, 24);
|
||||
const slowest = recentLogs
|
||||
.filter((log) => log.status === 'success')
|
||||
.sort((a, b) => b.duration - a.duration)[0];
|
||||
const errors = recentLogs.filter((log) => log.status === 'error');
|
||||
const writeCount = recentLogs.filter((log) => /\b(INSERT|UPDATE|DELETE|ALTER|DROP|CREATE)\b/i.test(log.sql)).length;
|
||||
const contextCount = contextTableNames.length;
|
||||
return [
|
||||
{
|
||||
tone: 'info',
|
||||
title: contextCount > 0 ? `已关联 ${contextCount} 张表` : '尚未关联表结构',
|
||||
body: contextCount > 0
|
||||
? `当前对话会带上 ${contextTableNames.slice(0, 3).join('、')}${contextCount > 3 ? ' 等表' : ''} 的结构上下文。`
|
||||
: '在表页打开 AI 后会自动关联当前表,也可以在输入框上方手动添加上下文。',
|
||||
},
|
||||
{
|
||||
tone: slowest && slowest.duration > 1000 ? 'warn' : 'accent',
|
||||
title: slowest ? `最近最慢查询 ${Math.round(slowest.duration).toLocaleString()}ms` : '暂无查询耗时样本',
|
||||
body: slowest ? slowest.sql.slice(0, 140) : '执行查询后这里会显示可用于优化分析的 SQL 线索。',
|
||||
},
|
||||
{
|
||||
tone: errors.length > 0 ? 'warn' : 'info',
|
||||
title: errors.length > 0 ? `${errors.length} 条最近查询失败` : '最近查询状态正常',
|
||||
body: errors[0]?.message || (recentLogs.length > 0 ? `已记录 ${recentLogs.length} 条最近 SQL,可直接让 AI 解释或优化。` : '暂无 SQL 日志。'),
|
||||
},
|
||||
{
|
||||
tone: writeCount > 0 ? 'warn' : 'accent',
|
||||
title: writeCount > 0 ? `检测到 ${writeCount} 条写操作` : '当前以只读分析为主',
|
||||
body: writeCount > 0 ? '涉及写入的 SQL 建议先生成预览与回滚语句,再执行提交。' : 'AI 默认优先解释、生成 SELECT、分析 Schema 与优化索引。',
|
||||
},
|
||||
];
|
||||
}, [contextTableNames, sqlLogs]);
|
||||
|
||||
const renderPanelHistoryList = () => {
|
||||
const sessions = aiChatSessions.slice(0, 8);
|
||||
if (sessions.length === 0) {
|
||||
return <div className="gn-v2-ai-empty-note">暂无历史会话</div>;
|
||||
}
|
||||
return sessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
className={`gn-v2-ai-history-card${session.id === sid ? ' is-active' : ''}`}
|
||||
onClick={() => {
|
||||
setAIActiveSessionId(session.id);
|
||||
setActivePanelMode('chat');
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<HistoryOutlined />
|
||||
<strong>{session.title || '新对话'}</strong>
|
||||
</span>
|
||||
<small>{new Date(session.updatedAt).toLocaleString(undefined, { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</small>
|
||||
</button>
|
||||
));
|
||||
};
|
||||
const effectivePanelMode = isV2Ui ? activePanelMode : 'chat';
|
||||
|
||||
return (
|
||||
<div ref={panelRef} className="ai-chat-panel" style={{ width: panelWidth, background: bgColor || 'transparent', color: textColor, borderLeft: overlayTheme.shellBorder, position: 'relative' }}>
|
||||
<div ref={panelRef} className={`ai-chat-panel${isV2Ui ? ' gn-v2-ai-panel' : ''}`} style={{ width: panelWidth, background: bgColor || 'transparent', color: textColor, borderLeft: overlayTheme.shellBorder, position: 'relative' }}>
|
||||
<div className={`ai-resize-handle${isResizing ? ' active' : ''}`} onMouseDown={handleResizeStart} />
|
||||
|
||||
{isResizing && panelRect.current && createPortal(
|
||||
@@ -1622,53 +1696,96 @@ SELECT * FROM users WHERE status = 1;
|
||||
mutedColor={mutedColor}
|
||||
textColor={textColor}
|
||||
overlayTheme={overlayTheme}
|
||||
onHistoryClick={() => setHistoryOpen(true)}
|
||||
onClear={createNewAISession}
|
||||
isV2Ui={isV2Ui}
|
||||
onHistoryClick={() => {
|
||||
if (isV2Ui) {
|
||||
setActivePanelMode('history');
|
||||
} else {
|
||||
setHistoryOpen(true);
|
||||
}
|
||||
}}
|
||||
onClear={() => {
|
||||
createNewAISession();
|
||||
setActivePanelMode('chat');
|
||||
}}
|
||||
onSettingsClick={() => { onOpenSettings?.(); setTimeout(loadActiveProvider, 500); }}
|
||||
onClose={onClose}
|
||||
messages={messages}
|
||||
sessionTitle={useStore.getState().aiChatSessions.find(s => s.id === sid)?.title || '新对话'}
|
||||
activeMode={effectivePanelMode}
|
||||
onModeChange={(mode) => {
|
||||
if (!isV2Ui) return;
|
||||
setActivePanelMode(mode);
|
||||
if (mode === 'history') {
|
||||
setHistoryOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="ai-chat-messages" onScroll={handleScrollMessages}>
|
||||
{messages.length === 0 ? (
|
||||
<AIChatWelcome
|
||||
overlayTheme={overlayTheme}
|
||||
quickActionBg={quickActionBg}
|
||||
quickActionBorder={quickActionBorder}
|
||||
textColor={textColor}
|
||||
mutedColor={mutedColor}
|
||||
onQuickAction={(prompt: string, autoSend?: boolean) => {
|
||||
setInput(prompt);
|
||||
if (autoSend) {
|
||||
// Use setTimeout to let setInput render, then trigger send
|
||||
setTimeout(() => {
|
||||
const el = textareaRef.current;
|
||||
if (el) el.focus();
|
||||
// Dispatch a synthetic enter to trigger handleSend
|
||||
// Simpler: just call handleSend directly with the prompt
|
||||
}, 50);
|
||||
}
|
||||
}}
|
||||
contextTableNames={contextTableNames}
|
||||
/>
|
||||
) : (
|
||||
messages.map(msg => (
|
||||
<AIMessageBubble
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
darkMode={darkMode}
|
||||
{effectivePanelMode === 'chat' && (
|
||||
messages.length === 0 ? (
|
||||
<AIChatWelcome
|
||||
overlayTheme={overlayTheme}
|
||||
quickActionBg={quickActionBg}
|
||||
quickActionBorder={quickActionBorder}
|
||||
textColor={textColor}
|
||||
onEdit={handleEditMessage}
|
||||
onRetry={handleRetryMessage}
|
||||
onDelete={handleDeleteMessage}
|
||||
activeConnectionId={inferredConnectionId}
|
||||
activeConnectionConfig={activeConnectionConfig}
|
||||
activeDbName={inferredDbName}
|
||||
allMessages={messages}
|
||||
mutedColor={mutedColor}
|
||||
onQuickAction={(prompt: string, autoSend?: boolean) => {
|
||||
setInput(prompt);
|
||||
if (autoSend) {
|
||||
// Use setTimeout to let setInput render, then trigger send
|
||||
setTimeout(() => {
|
||||
const el = textareaRef.current;
|
||||
if (el) el.focus();
|
||||
// Dispatch a synthetic enter to trigger handleSend
|
||||
// Simpler: just call handleSend directly with the prompt
|
||||
}, 50);
|
||||
}
|
||||
}}
|
||||
contextTableNames={contextTableNames}
|
||||
isV2Ui={isV2Ui}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
messages.map(msg => (
|
||||
<AIMessageBubble
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
textColor={textColor}
|
||||
onEdit={handleEditMessage}
|
||||
onRetry={handleRetryMessage}
|
||||
onDelete={handleDeleteMessage}
|
||||
activeConnectionId={inferredConnectionId}
|
||||
activeConnectionConfig={activeConnectionConfig}
|
||||
activeDbName={inferredDbName}
|
||||
allMessages={messages}
|
||||
/>
|
||||
))
|
||||
)
|
||||
)}
|
||||
|
||||
{effectivePanelMode === 'insights' && (
|
||||
<div className="gn-v2-ai-insights-list">
|
||||
{aiInsights.map((item) => (
|
||||
<div className={`gn-v2-ai-insight-card tone-${item.tone}`} key={item.title}>
|
||||
<span className="gn-v2-ai-insight-icon">
|
||||
{item.tone === 'warn' ? <WarningOutlined /> : item.tone === 'accent' ? <DatabaseOutlined /> : <TableOutlined />}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{item.title}</strong>
|
||||
<p>{item.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectivePanelMode === 'history' && (
|
||||
<div className="gn-v2-ai-history-list">
|
||||
{renderPanelHistoryList()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1706,6 +1823,7 @@ SELECT * FROM users WHERE status = 1;
|
||||
dynamicModels={dynamicModels}
|
||||
loadingModels={loadingModels}
|
||||
sendShortcutBinding={aiChatSendShortcutBinding}
|
||||
shortcutPlatform={activeShortcutPlatform}
|
||||
composerNotice={composerNotice}
|
||||
onModelChange={handleModelChange}
|
||||
onFetchModels={fetchDynamicModels}
|
||||
@@ -1716,6 +1834,7 @@ SELECT * FROM users WHERE status = 1;
|
||||
overlayTheme={overlayTheme}
|
||||
contextUsageChars={contextUsageChars}
|
||||
maxContextChars={getDynamicMaxContextChars(activeProvider?.model)}
|
||||
isV2Ui={isV2Ui}
|
||||
/>
|
||||
|
||||
<AIHistoryDrawer
|
||||
|
||||
@@ -565,7 +565,7 @@ const ConnectionModal: React.FC<{
|
||||
buildOverlayWorkbenchTheme(darkMode, {
|
||||
disableBackdropFilter: disableLocalBackdropFilter,
|
||||
}),
|
||||
[darkMode, disableLocalBackdropFilter],
|
||||
[darkMode, disableLocalBackdropFilter, appearance.uiVersion],
|
||||
);
|
||||
|
||||
const tunnelSectionStyle: React.CSSProperties = {
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function ConnectionPackagePasswordModal({
|
||||
confirmLoading={confirmLoading}
|
||||
onOk={onConfirm}
|
||||
onCancel={onCancel}
|
||||
destroyOnClose={false}
|
||||
destroyOnHidden={false}
|
||||
maskClosable={false}
|
||||
>
|
||||
{isExportMode ? (
|
||||
|
||||
@@ -26,6 +26,7 @@ const storeState = vi.hoisted(() => ({
|
||||
enabled: true,
|
||||
opacity: 1,
|
||||
blur: 0,
|
||||
uiVersion: 'v2',
|
||||
showDataTableVerticalBorders: false,
|
||||
dataTableDensity: 'comfortable',
|
||||
},
|
||||
@@ -74,7 +75,16 @@ vi.mock('../store', () => ({
|
||||
vi.mock('../../wailsjs/go/app/App', () => backendApp);
|
||||
|
||||
vi.mock('@monaco-editor/react', () => ({
|
||||
default: ({ value }: { value?: string }) => <pre>{value}</pre>,
|
||||
default: (props: { value?: string; language?: string; theme?: string; options?: Record<string, unknown> }) => (
|
||||
<div
|
||||
data-monaco-editor="true"
|
||||
data-language={props.language}
|
||||
data-theme={props.theme}
|
||||
data-read-only={String(Boolean(props.options?.readOnly))}
|
||||
>
|
||||
{props.value}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./ImportPreviewModal', () => ({
|
||||
@@ -83,6 +93,7 @@ vi.mock('./ImportPreviewModal', () => ({
|
||||
|
||||
vi.mock('@ant-design/icons', () => {
|
||||
const Icon = () => <span />;
|
||||
|
||||
return {
|
||||
ReloadOutlined: Icon,
|
||||
ImportOutlined: Icon,
|
||||
@@ -104,6 +115,10 @@ vi.mock('@ant-design/icons', () => {
|
||||
RightOutlined: Icon,
|
||||
RobotOutlined: Icon,
|
||||
SearchOutlined: Icon,
|
||||
TableOutlined: Icon,
|
||||
DatabaseOutlined: Icon,
|
||||
NodeIndexOutlined: Icon,
|
||||
ThunderboltOutlined: Icon,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -181,6 +196,20 @@ vi.mock('antd', () => {
|
||||
Modal.useModal = () => [{ info: vi.fn(() => ({ destroy: vi.fn() })) }, null];
|
||||
|
||||
const passthrough = ({ children }: any) => <>{children}</>;
|
||||
const Segmented = ({ value, options, onChange }: any) => (
|
||||
<div data-segmented-value={value}>
|
||||
{(options || []).map((option: any) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
data-segmented-option={option.value}
|
||||
onClick={() => onChange?.(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return {
|
||||
Table: () => <table />,
|
||||
@@ -193,7 +222,7 @@ vi.mock('antd', () => {
|
||||
Select: () => null,
|
||||
Modal,
|
||||
Checkbox: ({ checked, onChange }: any) => <input type="checkbox" checked={checked} onChange={onChange} />,
|
||||
Segmented: () => null,
|
||||
Segmented,
|
||||
Tooltip: passthrough,
|
||||
Popover: passthrough,
|
||||
DatePicker: () => null,
|
||||
@@ -518,4 +547,232 @@ describe('DataGrid DDL interactions', () => {
|
||||
expect(textContent(renderer!.root)).not.toContain('CREATE TABLE users');
|
||||
expect(renderer!.root.findAll((node) => node.props['data-modal-title'] === 'DDL - orders')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('switches the v2 footer field tab into the main fields view', async () => {
|
||||
storeState.appearance.uiVersion = 'v2';
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<DataGrid
|
||||
data={[{ __gonavi_row_key__: 'row-1', id: 1, name: 'alpha' }]}
|
||||
columnNames={['id', 'name']}
|
||||
loading={false}
|
||||
tableName="users"
|
||||
dbName="main"
|
||||
connectionId="conn-1"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer!, '字段信息').props.onClick();
|
||||
});
|
||||
|
||||
const content = textContent(renderer!.root);
|
||||
expect(content).toContain('FIELDS');
|
||||
expect(content).toContain('2 个字段');
|
||||
expect(content).toContain('id');
|
||||
expect(content).toContain('name');
|
||||
});
|
||||
|
||||
it('returns to the legacy table view when v2-only footer views are active during UI switch', async () => {
|
||||
storeState.appearance.uiVersion = 'v2';
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<DataGrid
|
||||
data={[{ __gonavi_row_key__: 'row-1', id: 1, name: 'alpha' }]}
|
||||
columnNames={['id', 'name']}
|
||||
loading={false}
|
||||
tableName="users"
|
||||
dbName="main"
|
||||
connectionId="conn-1"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer!, '字段信息').props.onClick();
|
||||
});
|
||||
expect(textContent(renderer!.root)).toContain('FIELDS');
|
||||
|
||||
storeState.appearance.uiVersion = 'legacy';
|
||||
await act(async () => {
|
||||
renderer!.update(
|
||||
<DataGrid
|
||||
data={[{ __gonavi_row_key__: 'row-1', id: 1, name: 'alpha' }]}
|
||||
columnNames={['id', 'name']}
|
||||
loading={false}
|
||||
tableName="users"
|
||||
dbName="main"
|
||||
connectionId="conn-1"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
const content = textContent(renderer!.root);
|
||||
expect(content).not.toContain('FIELDS');
|
||||
expect(content).not.toContain('gn-v2-data-grid-fields-view');
|
||||
expect(content).toContain('数据预览');
|
||||
expect(content).toContain('结果视图');
|
||||
expect(content).toContain('字段信息');
|
||||
});
|
||||
|
||||
it('renders the v2 footer DDL view with the Monaco SQL editor', async () => {
|
||||
storeState.appearance.uiVersion = 'v2';
|
||||
backendApp.DBShowCreateTable.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: 'CREATE TABLE users (`id` bigint)',
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<DataGrid
|
||||
data={[{ __gonavi_row_key__: 'row-1', id: 1 }]}
|
||||
columnNames={['id']}
|
||||
loading={false}
|
||||
tableName="users"
|
||||
dbName="main"
|
||||
connectionId="conn-1"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer!, '查看 DDL').props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
const editors = renderer!.root.findAll((node) => node.props['data-monaco-editor'] === 'true');
|
||||
expect(editors).toHaveLength(1);
|
||||
expect(editors[0].props['data-language']).toBe('sql');
|
||||
expect(editors[0].props['data-read-only']).toBe('true');
|
||||
expect(textContent(editors[0])).toContain('CREATE TABLE users');
|
||||
expect(renderer!.root.findAll((node) => node.type === 'pre' && textContent(node).includes('CREATE TABLE users'))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('opens the v2 DDL view as a right sidebar while keeping the table visible', async () => {
|
||||
storeState.appearance.uiVersion = 'v2';
|
||||
backendApp.DBShowCreateTable.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: 'CREATE TABLE users (`id` bigint)',
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<DataGrid
|
||||
data={[{ __gonavi_row_key__: 'row-1', id: 1 }]}
|
||||
columnNames={['id']}
|
||||
loading={false}
|
||||
tableName="users"
|
||||
dbName="main"
|
||||
connectionId="conn-1"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer!, '查看 DDL').props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
await act(async () => {
|
||||
renderer!.root.findByProps({ 'data-segmented-option': 'side' }).props.onClick();
|
||||
});
|
||||
|
||||
const sideWorkspace = renderer!.root.findByProps({ 'data-grid-ddl-layout': 'side' });
|
||||
expect(sideWorkspace.props.className).toBe('gn-v2-data-grid-split-workspace');
|
||||
expect(renderer!.root.findByProps({ 'aria-label': '表 DDL 侧栏' }).props.className).toBe('gn-v2-data-grid-ddl-sidebar');
|
||||
expect(renderer!.root.findByProps({ 'data-grid-ddl-view': 'side' }).props.className).toContain('is-side');
|
||||
expect(renderer!.root.findAllByType('table')).toHaveLength(1);
|
||||
expect(sideWorkspace.props.style.gridTemplateColumns).toBe('minmax(0, 1fr) 8px 420px');
|
||||
expect(sideWorkspace.props.style['--gn-v2-ddl-sidebar-width']).toBe('420px');
|
||||
expect(renderer!.root.findByProps({ 'data-grid-ddl-resizer': 'true' }).props['aria-valuenow']).toBe(420);
|
||||
|
||||
const editors = renderer!.root.findAll((node) => node.props['data-monaco-editor'] === 'true');
|
||||
expect(editors).toHaveLength(1);
|
||||
expect(editors[0].props['data-language']).toBe('sql');
|
||||
expect(textContent(editors[0])).toContain('CREATE TABLE users');
|
||||
});
|
||||
|
||||
it('previews and commits the v2 DDL sidebar width after dragging the separator', async () => {
|
||||
storeState.appearance.uiVersion = 'v2';
|
||||
backendApp.DBShowCreateTable.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: 'CREATE TABLE users (`id` bigint)',
|
||||
});
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<DataGrid
|
||||
data={[{ __gonavi_row_key__: 'row-1', id: 1 }]}
|
||||
columnNames={['id']}
|
||||
loading={false}
|
||||
tableName="users"
|
||||
dbName="main"
|
||||
connectionId="conn-1"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
await act(async () => {
|
||||
findButton(renderer!, '查看 DDL').props.onClick();
|
||||
});
|
||||
await waitForEffects();
|
||||
|
||||
await act(async () => {
|
||||
renderer!.root.findByProps({ 'data-segmented-option': 'side' }).props.onClick();
|
||||
});
|
||||
|
||||
const container = renderer!.root.findByProps({ 'data-grid-ddl-layout': 'side' });
|
||||
expect(container.props.style.gridTemplateColumns).toBe('minmax(0, 1fr) 8px 420px');
|
||||
expect(renderer!.root.findByProps({ 'data-grid-ddl-resize-preview': 'true' }).props.className).toBe('gn-v2-data-grid-ddl-resize-preview');
|
||||
|
||||
const addEventListenerMock = vi.mocked(document.addEventListener);
|
||||
const removeEventListenerMock = vi.mocked(document.removeEventListener);
|
||||
const resizer = renderer!.root.findByProps({ 'data-grid-ddl-resizer': 'true' });
|
||||
await act(async () => {
|
||||
resizer.props.onMouseDown({
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
clientX: 900,
|
||||
});
|
||||
});
|
||||
|
||||
const mouseMoveHandler = addEventListenerMock.mock.calls.find(([eventName]) => eventName === 'mousemove')?.[1] as ((event: MouseEvent) => void) | undefined;
|
||||
const mouseUpHandler = addEventListenerMock.mock.calls.find(([eventName]) => eventName === 'mouseup')?.[1] as (() => void) | undefined;
|
||||
expect(mouseMoveHandler).toBeTypeOf('function');
|
||||
expect(mouseUpHandler).toBeTypeOf('function');
|
||||
|
||||
await act(async () => {
|
||||
mouseMoveHandler?.({ clientX: 780 } as MouseEvent);
|
||||
});
|
||||
|
||||
const movingContainer = renderer!.root.findByProps({ 'data-grid-ddl-layout': 'side' });
|
||||
expect(movingContainer.props.style.gridTemplateColumns).toBe('minmax(0, 1fr) 8px 420px');
|
||||
expect(movingContainer.props.style['--gn-v2-ddl-sidebar-width']).toBe('420px');
|
||||
expect(renderer!.root.findByProps({ 'data-grid-ddl-resizer': 'true' }).props['aria-valuenow']).toBe(420);
|
||||
|
||||
await act(async () => {
|
||||
mouseUpHandler?.();
|
||||
});
|
||||
|
||||
const resizedContainer = renderer!.root.findByProps({ 'data-grid-ddl-layout': 'side' });
|
||||
expect(resizedContainer.props.style.gridTemplateColumns).toBe('minmax(0, 1fr) 8px 540px');
|
||||
expect(resizedContainer.props.style['--gn-v2-ddl-sidebar-width']).toBe('540px');
|
||||
expect(renderer!.root.findByProps({ 'data-grid-ddl-resizer': 'true' }).props['aria-valuenow']).toBe(540);
|
||||
expect(removeEventListenerMock).toHaveBeenCalledWith('mousemove', mouseMoveHandler);
|
||||
expect(removeEventListenerMock).toHaveBeenCalledWith('mouseup', mouseUpHandler);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -84,6 +85,12 @@ const createRows = (count: number) => Array.from({ length: count }, (_, i) => ({
|
||||
}));
|
||||
|
||||
describe('DataViewer safe editing locator', () => {
|
||||
it('memoizes the table data viewer so parent-only modal state does not repaint loaded data', () => {
|
||||
const source = readFileSync(new URL('./DataViewer.tsx', import.meta.url), 'utf8');
|
||||
|
||||
expect(source).toContain('React.memo(({ tab, isActive = true }) => {');
|
||||
});
|
||||
|
||||
const renderAndReload = async (tab: TabData = createTab()) => {
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
|
||||
@@ -255,6 +255,7 @@ type ViewerScrollSnapshot = {
|
||||
};
|
||||
|
||||
const viewerFilterSnapshotsByTab = new Map<string, ViewerFilterSnapshot>();
|
||||
const VIEWER_SCROLL_SNAPSHOT_PERSIST_DELAY_MS = 160;
|
||||
|
||||
const normalizeViewerFilterConditions = (conditions: FilterCondition[] | undefined): FilterCondition[] => {
|
||||
if (!Array.isArray(conditions)) return [];
|
||||
@@ -289,7 +290,7 @@ const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => {
|
||||
};
|
||||
};
|
||||
|
||||
const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => {
|
||||
const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ tab, isActive = true }) => {
|
||||
const initialViewerSnapshot = useMemo(() => getViewerFilterSnapshot(tab.id), [tab.id]);
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [columnNames, setColumnNames] = useState<string[]>([]);
|
||||
@@ -298,6 +299,8 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
const [loading, setLoading] = useState(false);
|
||||
const connections = useStore(state => state.connections);
|
||||
const addSqlLog = useStore(state => state.addSqlLog);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const isV2Ui = appearance?.uiVersion === 'v2';
|
||||
const fetchSeqRef = useRef(0);
|
||||
const countSeqRef = useRef(0);
|
||||
const countKeyRef = useRef<string>('');
|
||||
@@ -318,6 +321,8 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
top: initialViewerSnapshot.scrollTop,
|
||||
left: initialViewerSnapshot.scrollLeft,
|
||||
});
|
||||
const pendingScrollSnapshotPersistRef = useRef<ViewerScrollSnapshot | null>(null);
|
||||
const scrollSnapshotPersistTimerRef = useRef<number | null>(null);
|
||||
const initialLoadRef = useRef(false);
|
||||
const skipNextAutoFetchRef = useRef(false);
|
||||
|
||||
@@ -375,7 +380,16 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
persistViewerSnapshot(tab.id);
|
||||
if (scrollSnapshotPersistTimerRef.current !== null) {
|
||||
window.clearTimeout(scrollSnapshotPersistTimerRef.current);
|
||||
scrollSnapshotPersistTimerRef.current = null;
|
||||
}
|
||||
const pendingScrollSnapshot = pendingScrollSnapshotPersistRef.current;
|
||||
pendingScrollSnapshotPersistRef.current = null;
|
||||
persistViewerSnapshot(tab.id, pendingScrollSnapshot ? {
|
||||
scrollTop: pendingScrollSnapshot.top,
|
||||
scrollLeft: pendingScrollSnapshot.left,
|
||||
} : undefined);
|
||||
};
|
||||
}, [tab.id, persistViewerSnapshot]);
|
||||
|
||||
@@ -412,10 +426,18 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
|
||||
const handleTableScrollSnapshotChange = useCallback((snapshot: ViewerScrollSnapshot) => {
|
||||
scrollSnapshotRef.current = snapshot;
|
||||
persistViewerSnapshot(tab.id, {
|
||||
scrollTop: snapshot.top,
|
||||
scrollLeft: snapshot.left,
|
||||
});
|
||||
pendingScrollSnapshotPersistRef.current = snapshot;
|
||||
if (scrollSnapshotPersistTimerRef.current !== null) return;
|
||||
scrollSnapshotPersistTimerRef.current = window.setTimeout(() => {
|
||||
scrollSnapshotPersistTimerRef.current = null;
|
||||
const pendingScrollSnapshot = pendingScrollSnapshotPersistRef.current;
|
||||
pendingScrollSnapshotPersistRef.current = null;
|
||||
if (!pendingScrollSnapshot) return;
|
||||
persistViewerSnapshot(tab.id, {
|
||||
scrollTop: pendingScrollSnapshot.top,
|
||||
scrollLeft: pendingScrollSnapshot.left,
|
||||
});
|
||||
}, VIEWER_SCROLL_SNAPSHOT_PERSIST_DELAY_MS);
|
||||
}, [tab.id, persistViewerSnapshot]);
|
||||
|
||||
const handleManualTotalCount = useCallback(async () => {
|
||||
@@ -1092,7 +1114,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
}, [tab.id, tab.connectionId, tab.dbName, tab.tableName, sortInfo, filterConditions, quickWhereCondition]); // Initial load and re-load on sort/filter
|
||||
|
||||
return (
|
||||
<div style={{ flex: '1 1 auto', minHeight: 0, minWidth: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<div className={isV2Ui ? 'gn-v2-data-viewer' : undefined} style={{ flex: '1 1 auto', minHeight: 0, minWidth: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<DataGrid
|
||||
data={data}
|
||||
columnNames={columnNames}
|
||||
@@ -1123,6 +1145,6 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default DataViewer;
|
||||
|
||||
@@ -216,7 +216,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
|
||||
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
|
||||
const driverManagerTheme = useMemo(
|
||||
() => buildDriverManagerWorkbenchTheme(darkMode, opacity),
|
||||
[darkMode, opacity],
|
||||
[darkMode, opacity, appearance.uiVersion],
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [downloadDir, setDownloadDir] = useState('');
|
||||
|
||||
@@ -26,12 +26,20 @@ const storeState = vi.hoisted(() => ({
|
||||
savedQueries: [] as SavedQuery[],
|
||||
saveQuery: vi.fn(),
|
||||
theme: 'light',
|
||||
appearance: { uiVersion: 'legacy' as const },
|
||||
sqlFormatOptions: { keywordCase: 'upper' as const },
|
||||
setSqlFormatOptions: vi.fn(),
|
||||
queryOptions: { maxRows: 5000 },
|
||||
setQueryOptions: vi.fn(),
|
||||
shortcutOptions: {
|
||||
runQuery: { enabled: false, combo: '' },
|
||||
runQuery: {
|
||||
mac: { enabled: false, combo: '' },
|
||||
windows: { enabled: false, combo: '' },
|
||||
},
|
||||
selectCurrentStatement: {
|
||||
mac: { enabled: false, combo: '' },
|
||||
windows: { enabled: false, combo: '' },
|
||||
},
|
||||
},
|
||||
activeTabId: 'tab-1',
|
||||
aiPanelVisible: false,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import Editor, { type OnMount } from './MonacoEditor';
|
||||
import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select, Tabs } from 'antd';
|
||||
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined, RobotOutlined } from '@ant-design/icons';
|
||||
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined, RobotOutlined, DatabaseOutlined } from '@ant-design/icons';
|
||||
import { format } from 'sql-formatter';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { TabData, ColumnDefinition, IndexDefinition } from '../types';
|
||||
@@ -10,7 +10,7 @@ import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDat
|
||||
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
|
||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from "../utils/mongodb";
|
||||
import { getShortcutDisplay, isEditableElement, isShortcutMatch, comboToMonacoKeyBinding } from "../utils/shortcuts";
|
||||
import { getShortcutDisplayLabel, getShortcutPlatform, isEditableElement, isShortcutMatch, comboToMonacoKeyBinding, resolveShortcutBinding } from "../utils/shortcuts";
|
||||
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { isOracleLikeDialect, resolveSqlDialect, resolveSqlFunctions, resolveSqlKeywords } from '../utils/sqlDialect';
|
||||
@@ -18,6 +18,7 @@ import { applyQueryAutoLimit } from '../utils/queryAutoLimit';
|
||||
import { extractQueryResultTableRef, type QueryResultTableRef } from '../utils/queryResultTable';
|
||||
import { quoteIdentPart } from '../utils/sql';
|
||||
import { resolveCurrentSqlStatementRange } from '../utils/sqlStatementSelection';
|
||||
import { isMacLikePlatform } from '../utils/appearance';
|
||||
import { resolveUniqueKeyGroupsFromIndexes } from './dataGridCopyInsert';
|
||||
import { ORACLE_ROWID_LOCATOR_COLUMN, type EditRowLocator } from '../utils/rowLocator';
|
||||
|
||||
@@ -669,12 +670,23 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const columnsCacheRef = useRef<Record<string, ColumnDefinition[]>>({});
|
||||
const saveQuery = useStore(state => state.saveQuery);
|
||||
const theme = useStore(state => state.theme);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const darkMode = theme === 'dark';
|
||||
const isV2Ui = appearance.uiVersion === 'v2';
|
||||
const sqlFormatOptions = useStore(state => state.sqlFormatOptions);
|
||||
const setSqlFormatOptions = useStore(state => state.setSqlFormatOptions);
|
||||
const queryOptions = useStore(state => state.queryOptions);
|
||||
const setQueryOptions = useStore(state => state.setQueryOptions);
|
||||
const shortcutOptions = useStore(state => state.shortcutOptions);
|
||||
const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform());
|
||||
const runQueryShortcutBinding = useMemo(
|
||||
() => resolveShortcutBinding(shortcutOptions, 'runQuery', activeShortcutPlatform),
|
||||
[activeShortcutPlatform, shortcutOptions],
|
||||
);
|
||||
const selectCurrentStatementShortcutBinding = useMemo(
|
||||
() => resolveShortcutBinding(shortcutOptions, 'selectCurrentStatement', activeShortcutPlatform),
|
||||
[activeShortcutPlatform, shortcutOptions],
|
||||
);
|
||||
const activeTabId = useStore(state => state.activeTabId);
|
||||
const autoFetchVisible = useAutoFetchVisibility();
|
||||
|
||||
@@ -689,6 +701,16 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
return savedQueries.find((item) => item.id === tabId) || null;
|
||||
}, [savedQueries, tab.id, tab.savedQueryId]);
|
||||
const activeConnectionName = useMemo(
|
||||
() => connections.find(c => c.id === currentConnectionId)?.name || '未选择连接',
|
||||
[connections, currentConnectionId],
|
||||
);
|
||||
const queryResultSummary = useMemo(() => {
|
||||
if (loading) return '执行中';
|
||||
if (resultSets.length === 0) return executionError ? '执行失败' : '未执行';
|
||||
const totalRows = resultSets.reduce((sum, rs) => sum + (Array.isArray(rs.rows) ? rs.rows.length : 0), 0);
|
||||
return `${resultSets.length} 组结果 / ${totalRows.toLocaleString()} 行`;
|
||||
}, [executionError, loading, resultSets]);
|
||||
|
||||
useEffect(() => {
|
||||
currentConnectionIdRef.current = currentConnectionId;
|
||||
@@ -960,7 +982,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
});
|
||||
|
||||
// Register runQuery shortcut inside Monaco so it overrides Monaco's default keybinding
|
||||
const runBinding = shortcutOptions.runQuery;
|
||||
const runBinding = runQueryShortcutBinding;
|
||||
if (runBinding?.enabled && runBinding.combo) {
|
||||
const keyBinding = comboToMonacoKeyBinding(
|
||||
runBinding.combo, monaco.KeyMod, monaco.KeyCode
|
||||
@@ -977,7 +999,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
}
|
||||
|
||||
const selectStatementBinding = shortcutOptions.selectCurrentStatement;
|
||||
const selectStatementBinding = selectCurrentStatementShortcutBinding;
|
||||
if (selectStatementBinding?.enabled && selectStatementBinding.combo) {
|
||||
const keyBinding = comboToMonacoKeyBinding(
|
||||
selectStatementBinding.combo, monaco.KeyMod, monaco.KeyCode
|
||||
@@ -2215,7 +2237,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}, [activeTabId, tab.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const binding = shortcutOptions.runQuery;
|
||||
const binding = runQueryShortcutBinding;
|
||||
if (!binding?.enabled || !binding.combo) {
|
||||
return;
|
||||
}
|
||||
@@ -2240,7 +2262,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleRunShortcut, true);
|
||||
};
|
||||
}, [activeTabId, tab.id, shortcutOptions.runQuery, handleRun]);
|
||||
}, [activeTabId, tab.id, runQueryShortcutBinding, handleRun]);
|
||||
|
||||
// Re-register Monaco internal keybinding when runQuery shortcut changes
|
||||
useEffect(() => {
|
||||
@@ -2253,7 +2275,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const monaco = monacoRef.current;
|
||||
if (!editor || !monaco) return;
|
||||
|
||||
const binding = shortcutOptions.runQuery;
|
||||
const binding = runQueryShortcutBinding;
|
||||
if (!binding?.enabled || !binding.combo) return;
|
||||
|
||||
const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode);
|
||||
@@ -2274,7 +2296,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
runQueryActionRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [shortcutOptions.runQuery]);
|
||||
}, [runQueryShortcutBinding]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectCurrentStatementActionRef.current) {
|
||||
@@ -2286,7 +2308,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const monaco = monacoRef.current;
|
||||
if (!editor || !monaco) return;
|
||||
|
||||
const binding = shortcutOptions.selectCurrentStatement;
|
||||
const binding = selectCurrentStatementShortcutBinding;
|
||||
if (!binding?.enabled || !binding.combo) return;
|
||||
|
||||
const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode);
|
||||
@@ -2305,7 +2327,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
selectCurrentStatementActionRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [shortcutOptions.selectCurrentStatement]);
|
||||
}, [selectCurrentStatementShortcutBinding, handleSelectCurrentStatement]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleRunActiveQuery = () => {
|
||||
@@ -2505,7 +2527,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={queryEditorRootRef} style={{ flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<div ref={queryEditorRootRef} className={isV2Ui ? 'gn-v2-query-editor' : undefined} style={{ flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<style>{`
|
||||
.query-result-tabs {
|
||||
flex: 1 1 auto;
|
||||
@@ -2549,8 +2571,20 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
transition: none !important;
|
||||
}
|
||||
`}</style>
|
||||
<div ref={editorPaneRef}>
|
||||
<div style={{ padding: '4px 8px 8px', display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}>
|
||||
<div ref={editorPaneRef} className={isV2Ui ? 'gn-v2-query-editor-pane' : undefined}>
|
||||
{isV2Ui && (
|
||||
<div className="gn-v2-query-header">
|
||||
<div className="gn-v2-query-title">
|
||||
<span>SQL WORKSPACE</span>
|
||||
<strong>{currentDb || '未选择数据库'}</strong>
|
||||
</div>
|
||||
<div className="gn-v2-query-context">
|
||||
<span><DatabaseOutlined /> {activeConnectionName}</span>
|
||||
<span>{queryResultSummary}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={isV2Ui ? 'gn-v2-query-toolbar' : undefined} style={{ padding: '4px 8px 8px', display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}>
|
||||
<Select
|
||||
style={{ width: 150 }}
|
||||
placeholder="选择连接"
|
||||
@@ -2587,9 +2621,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
<Button.Group>
|
||||
<Tooltip
|
||||
title={
|
||||
shortcutOptions.runQuery?.enabled && shortcutOptions.runQuery?.combo
|
||||
? `运行(${getShortcutDisplay(shortcutOptions.runQuery.combo)})`
|
||||
: '运行'
|
||||
runQueryShortcutBinding.enabled && runQueryShortcutBinding.combo
|
||||
? `运行(${getShortcutDisplayLabel(runQueryShortcutBinding.combo, activeShortcutPlatform)})`
|
||||
: '运行'
|
||||
}
|
||||
>
|
||||
<Button type="primary" icon={<PlayCircleOutlined />} onClick={handleRun} loading={loading}>
|
||||
@@ -2626,7 +2660,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div style={{ height: editorHeight, minHeight: '100px' }}>
|
||||
<div className={isV2Ui ? 'gn-v2-query-monaco-shell' : undefined} style={{ height: editorHeight, minHeight: '100px' }}>
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="sql"
|
||||
@@ -2644,6 +2678,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={isV2Ui ? 'gn-v2-query-resizer' : undefined}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{
|
||||
height: '5px',
|
||||
@@ -2656,7 +2691,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div className={isV2Ui ? 'gn-v2-query-results' : undefined} style={{ flex: 1, minHeight: 0, overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
{resultSets.length > 0 ? (
|
||||
<Tabs
|
||||
className="query-result-tabs"
|
||||
@@ -2695,7 +2730,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
if (isAffectedResult) {
|
||||
const affected = Number(rs.rows[0]?.affectedRows ?? 0);
|
||||
return (
|
||||
<div style={{
|
||||
<div className={isV2Ui ? 'gn-v2-query-success' : undefined} style={{
|
||||
flex: 1, minHeight: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexDirection: 'column', gap: 8, color: '#666', userSelect: 'text',
|
||||
}}>
|
||||
@@ -2727,7 +2762,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}))}
|
||||
/>
|
||||
) : executionError ? (
|
||||
<div style={{ flex: 1, minHeight: 0, padding: 24, display: 'flex', flexDirection: 'column', gap: 16, background: darkMode ? '#1e1e1e' : '#fafafa', overflow: 'auto' }}>
|
||||
<div className={isV2Ui ? 'gn-v2-query-error' : undefined} style={{ flex: 1, minHeight: 0, padding: 24, display: 'flex', flexDirection: 'column', gap: 16, background: darkMode ? '#1e1e1e' : '#fafafa', overflow: 'auto' }}>
|
||||
<div style={{ color: '#ff4d4f', fontWeight: 'bold', fontSize: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<CloseOutlined />
|
||||
<span>执行失败</span>
|
||||
@@ -2756,7 +2791,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ flex: 1, minHeight: 0 }} />
|
||||
<div className={isV2Ui ? 'gn-v2-query-empty' : undefined} style={{ flex: 1, minHeight: 0 }}>
|
||||
{isV2Ui && (
|
||||
<div>
|
||||
<strong>等待执行 SQL</strong>
|
||||
<span>运行查询后,结果会在下方以新版数据网格展示。</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -156,12 +156,13 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const connection = connections.find(c => c.id === connectionId);
|
||||
const workbenchTheme = useMemo(
|
||||
() => buildRedisWorkbenchTheme({ darkMode, opacity, blur, disableBackdropFilter: disableLocalBackdropFilter }),
|
||||
[blur, darkMode, disableLocalBackdropFilter, opacity],
|
||||
[blur, darkMode, disableLocalBackdropFilter, opacity, appearance.uiVersion],
|
||||
);
|
||||
const workbenchBackdropFilter = useMemo(
|
||||
() => resolveTextInputSafeBackdropFilter(blurToFilter(blur), disableLocalBackdropFilter),
|
||||
[blur, disableLocalBackdropFilter],
|
||||
);
|
||||
const isV2Ui = appearance.uiVersion === 'v2';
|
||||
const keyAccentColor = workbenchTheme.accent;
|
||||
const jsonAccentColor = darkMode ? '#f6c453' : '#1890ff';
|
||||
const valueToolbarBg = workbenchTheme.panelBgStrong;
|
||||
@@ -975,6 +976,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
if (!keyValue || !selectedKey) {
|
||||
return (
|
||||
<div
|
||||
className={isV2Ui ? 'gn-v2-redis-empty-value' : undefined}
|
||||
style={{
|
||||
...workbenchCardStyle,
|
||||
height: '100%',
|
||||
@@ -997,7 +999,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{
|
||||
<div className={isV2Ui ? 'gn-v2-redis-value-subtoolbar' : undefined} style={{
|
||||
padding: '4px 8px',
|
||||
background: valueToolbarBg,
|
||||
borderBottom: valueToolbarBorder,
|
||||
@@ -1090,8 +1092,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div className={isV2Ui ? 'gn-v2-redis-data-section' : undefined} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div className={isV2Ui ? 'gn-v2-redis-value-actionbar' : undefined} style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '添加字段',
|
||||
@@ -1228,8 +1230,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div className={isV2Ui ? 'gn-v2-redis-data-section' : undefined} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div className={isV2Ui ? 'gn-v2-redis-value-actionbar' : undefined} style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Space>
|
||||
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => {
|
||||
Modal.confirm({
|
||||
@@ -1375,8 +1377,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div className={isV2Ui ? 'gn-v2-redis-data-section' : undefined} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div className={isV2Ui ? 'gn-v2-redis-value-actionbar' : undefined} style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '添加成员',
|
||||
@@ -1489,8 +1491,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div className={isV2Ui ? 'gn-v2-redis-data-section' : undefined} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div className={isV2Ui ? 'gn-v2-redis-value-actionbar' : undefined} style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '添加成员',
|
||||
@@ -1671,8 +1673,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div className={isV2Ui ? 'gn-v2-redis-data-section' : undefined} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div className={isV2Ui ? 'gn-v2-redis-value-actionbar' : undefined} style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '添加 Stream 消息',
|
||||
@@ -1772,8 +1774,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ ...workbenchCardStyle, padding: 18, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexShrink: 0 }}>
|
||||
<div className={isV2Ui ? 'gn-v2-redis-value-layout' : undefined} style={{ height: '100%', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div className={isV2Ui ? 'gn-v2-redis-value-header' : undefined} style={{ ...workbenchCardStyle, padding: 18, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: '.08em', color: workbenchTheme.textMuted, fontWeight: 600 }}>
|
||||
Active Key
|
||||
@@ -1804,7 +1806,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
{keyValue.length > 0 && <Tag style={mutedPillTagStyle}>长度: {keyValue.length}</Tag>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ ...workbenchSubCardStyle, padding: 4, display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
<div className={isV2Ui ? 'gn-v2-redis-value-actions' : undefined} style={{ ...workbenchSubCardStyle, padding: 4, display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
<Button size="small" style={actionButtonStyle} onClick={() => {
|
||||
ttlForm.setFieldsValue({ ttl: keyValue.ttl > 0 ? keyValue.ttl : -1 });
|
||||
setTtlModalOpen(true);
|
||||
@@ -1815,7 +1817,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ ...workbenchSubCardStyle, padding: 6, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
||||
<div className={isV2Ui ? 'gn-v2-redis-view-mode' : undefined} style={{ ...workbenchSubCardStyle, padding: 6, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
||||
<span style={{ paddingInline: 10, fontSize: 12, color: workbenchTheme.textMuted }}>查看模式</span>
|
||||
<Radio.Group size="small" value={viewMode} onChange={(e) => setViewMode(e.target.value)}>
|
||||
<Radio.Button value="auto">自动</Radio.Button>
|
||||
@@ -1824,7 +1826,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
<Radio.Button value="hex">十六进制</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<div style={{ ...workbenchCardStyle, padding: 14, flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
<div className={isV2Ui ? 'gn-v2-redis-value-card' : undefined} style={{ ...workbenchCardStyle, padding: 14, flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', height: '100%' }}>
|
||||
{keyValue.type === 'string' && renderStringValue()}
|
||||
{keyValue.type === 'hash' && renderHashValue()}
|
||||
@@ -1843,10 +1845,10 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="redis-viewer-workbench" style={{ display: 'flex', height: '100%', gap: 12, padding: 12, background: workbenchTheme.appBg, backdropFilter: workbenchBackdropFilter, WebkitBackdropFilter: workbenchBackdropFilter }}>
|
||||
<div className={`redis-viewer-workbench${isV2Ui ? ' gn-v2-redis-workbench' : ''}`} style={{ display: 'flex', height: '100%', gap: 12, padding: 12, background: workbenchTheme.appBg, backdropFilter: workbenchBackdropFilter, WebkitBackdropFilter: workbenchBackdropFilter }}>
|
||||
{/* Left: Key List */}
|
||||
<div ref={leftPanelRef} style={{ width: leftPanelWidth, minWidth: 300, display: 'flex', flexDirection: 'column', flexShrink: 0, gap: 12 }}>
|
||||
<div style={{ ...workbenchCardStyle, padding: 12 }}>
|
||||
<div ref={leftPanelRef} className={isV2Ui ? 'gn-v2-redis-sidebar' : undefined} style={{ width: leftPanelWidth, minWidth: 300, display: 'flex', flexDirection: 'column', flexShrink: 0, gap: 12 }}>
|
||||
<div className={isV2Ui ? 'gn-v2-redis-header' : undefined} style={{ ...workbenchCardStyle, padding: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, marginBottom: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: '.08em', color: workbenchTheme.textMuted, fontWeight: 600 }}>Key Explorer</div>
|
||||
@@ -1875,7 +1877,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
enterButton={<SearchOutlined />}
|
||||
/>
|
||||
</Space.Compact>
|
||||
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div className={isV2Ui ? 'gn-v2-redis-toolbar' : undefined} style={{ marginTop: 12, display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Space wrap size={8}>
|
||||
<Button size="small" style={actionButtonStyle} icon={<ReloadOutlined />} onClick={handleRefresh}>刷新</Button>
|
||||
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => setNewKeyModalOpen(true)}>新建</Button>
|
||||
@@ -1893,7 +1895,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ ...workbenchCardStyle, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', padding: 10 }}>
|
||||
<div className={isV2Ui ? 'gn-v2-redis-tree-card' : undefined} style={{ ...workbenchCardStyle, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', padding: 10 }}>
|
||||
{isLargeKeyspace && (
|
||||
<div style={{ padding: '8px 10px', fontSize: 12, color: workbenchTheme.textMuted, marginBottom: 8, borderRadius: 12, background: workbenchTheme.panelBgSubtle, border: workbenchTheme.panelBorder }}>
|
||||
已启用大数据量性能模式(简化节点渲染,最多保留 {REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS} 个展开分组)
|
||||
@@ -1903,7 +1905,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
<span>命名空间 / Key</span>
|
||||
<span>类型 / TTL</span>
|
||||
</div>
|
||||
<div ref={treeContainerRef} style={{ ...workbenchSubCardStyle, flex: 1, minHeight: 0, overflow: 'hidden', padding: 6 }}>
|
||||
<div ref={treeContainerRef} className={isV2Ui ? 'gn-v2-redis-tree-shell' : undefined} style={{ ...workbenchSubCardStyle, flex: 1, minHeight: 0, overflow: 'hidden', padding: 6 }}>
|
||||
<Spin spinning={loading} size="small" style={{ width: '100%' }}>
|
||||
<Tree
|
||||
blockNode
|
||||
@@ -1939,7 +1941,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
<ResizableDivider targetRef={leftPanelRef} onResizeEnd={setLeftPanelWidth} />
|
||||
|
||||
{/* Right: Value Viewer */}
|
||||
<div style={{ flex: 1, overflow: 'hidden', minWidth: 300 }}>
|
||||
<div className={isV2Ui ? 'gn-v2-redis-value-pane' : undefined} style={{ flex: 1, overflow: 'hidden', minWidth: 300 }}>
|
||||
{valueLoading ? (
|
||||
<div style={{ ...workbenchCardStyle, padding: 20, textAlign: 'center', color: workbenchTheme.textMuted }}>加载中...</div>
|
||||
) : (
|
||||
@@ -2066,6 +2068,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
</Modal>
|
||||
{treeContextMenu && typeof document !== 'undefined' && createPortal((
|
||||
<div
|
||||
className={isV2Ui ? 'gn-v2-context-menu gn-v2-redis-context-menu' : undefined}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: typeof window !== 'undefined' ? Math.min(treeContextMenu.x + 4, Math.max(16, window.innerWidth - 220)) : treeContextMenu.x,
|
||||
|
||||
@@ -1,23 +1,43 @@
|
||||
import React from 'react';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import Sidebar, { resolveSidebarTableNameForCopy } from './Sidebar';
|
||||
import Sidebar, {
|
||||
buildV2RailConnectionGroups,
|
||||
filterV2ExplorerTreeByKind,
|
||||
getV2RailConnectionGroupBadgeText,
|
||||
hasSidebarLazyChildren,
|
||||
parseV2CommandSearchQuery,
|
||||
resolveSidebarNodeConnectionId,
|
||||
resolveV2ActiveConnectionId,
|
||||
isSidebarTablePinned,
|
||||
resolveSidebarTableNameForCopy,
|
||||
shouldClearSidebarActiveContextOnEmptySelect,
|
||||
shouldLoadSidebarNodeOnExpand,
|
||||
sortSidebarTableEntries,
|
||||
} from './Sidebar';
|
||||
import { buildSidebarTablePinKey } from '../store';
|
||||
import {
|
||||
DEFAULT_SHORTCUT_OPTIONS,
|
||||
cloneShortcutOptions,
|
||||
} from '../utils/shortcuts';
|
||||
import {
|
||||
V2ConnectionGroupContextMenuView,
|
||||
V2ConnectionContextMenuView,
|
||||
V2DatabaseContextMenuView,
|
||||
V2TableContextMenuView,
|
||||
V2TableGroupContextMenuView,
|
||||
formatV2TableContextMenuRows,
|
||||
formatV2TableContextMenuSize,
|
||||
} from './V2TableContextMenu';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
noop: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useStore: (selector: (state: any) => any) => selector({
|
||||
connections: [],
|
||||
savedQueries: [],
|
||||
externalSQLDirectories: [],
|
||||
deleteQuery: mocks.noop,
|
||||
saveExternalSQLDirectory: mocks.noop,
|
||||
deleteExternalSQLDirectory: mocks.noop,
|
||||
addConnection: mocks.noop,
|
||||
addTab: mocks.noop,
|
||||
state: {
|
||||
connections: [] as any[],
|
||||
activeContext: null as any,
|
||||
activeTabId: 'conn-1-main-users',
|
||||
tabs: [{
|
||||
id: 'conn-1-main-users',
|
||||
title: 'users',
|
||||
@@ -25,11 +45,43 @@ vi.mock('../store', () => ({
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
tableName: 'users',
|
||||
}],
|
||||
activeTabId: 'conn-1-main-users',
|
||||
}] as any[],
|
||||
connectionTags: [] as any[],
|
||||
appearance: {
|
||||
enabled: true,
|
||||
opacity: 1,
|
||||
blur: 0,
|
||||
uiVersion: 'legacy',
|
||||
} as any,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
buildSidebarTablePinKey: (
|
||||
connectionId: string,
|
||||
dbName: string,
|
||||
tableName: string,
|
||||
schemaName = '',
|
||||
) => JSON.stringify([
|
||||
connectionId.trim(),
|
||||
dbName.trim(),
|
||||
schemaName.trim(),
|
||||
tableName.trim(),
|
||||
]),
|
||||
useStore: (selector: (state: any) => any) => selector({
|
||||
connections: mocks.state.connections,
|
||||
savedQueries: [],
|
||||
externalSQLDirectories: [],
|
||||
deleteQuery: mocks.noop,
|
||||
saveExternalSQLDirectory: mocks.noop,
|
||||
deleteExternalSQLDirectory: mocks.noop,
|
||||
addConnection: mocks.noop,
|
||||
addTab: mocks.noop,
|
||||
tabs: mocks.state.tabs,
|
||||
activeTabId: mocks.state.activeTabId,
|
||||
setActiveContext: mocks.noop,
|
||||
removeConnection: mocks.noop,
|
||||
connectionTags: [],
|
||||
connectionTags: mocks.state.connectionTags,
|
||||
addConnectionTag: mocks.noop,
|
||||
updateConnectionTag: mocks.noop,
|
||||
removeConnectionTag: mocks.noop,
|
||||
@@ -38,16 +90,17 @@ vi.mock('../store', () => ({
|
||||
closeTabsByConnection: mocks.noop,
|
||||
closeTabsByDatabase: mocks.noop,
|
||||
theme: 'light',
|
||||
appearance: {
|
||||
enabled: true,
|
||||
opacity: 1,
|
||||
blur: 0,
|
||||
},
|
||||
appearance: mocks.state.appearance,
|
||||
activeContext: mocks.state.activeContext,
|
||||
tableAccessCount: {},
|
||||
tableSortPreference: {},
|
||||
pinnedSidebarTables: [],
|
||||
recordTableAccess: mocks.noop,
|
||||
setTableSortPreference: mocks.noop,
|
||||
setSidebarTablePinned: mocks.noop,
|
||||
addSqlLog: mocks.noop,
|
||||
shortcutOptions: cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS),
|
||||
setAIPanelVisible: mocks.noop,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -80,6 +133,27 @@ vi.mock('../../wailsjs/runtime/runtime', () => ({
|
||||
}));
|
||||
|
||||
describe('Sidebar locate toolbar', () => {
|
||||
beforeEach(() => {
|
||||
mocks.state.connections = [];
|
||||
mocks.state.activeContext = null;
|
||||
mocks.state.activeTabId = 'conn-1-main-users';
|
||||
mocks.state.tabs = [{
|
||||
id: 'conn-1-main-users',
|
||||
title: 'users',
|
||||
type: 'table',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
tableName: 'users',
|
||||
}];
|
||||
mocks.state.connectionTags = [];
|
||||
mocks.state.appearance = {
|
||||
enabled: true,
|
||||
opacity: 1,
|
||||
blur: 0,
|
||||
uiVersion: 'legacy',
|
||||
};
|
||||
});
|
||||
|
||||
it('resolves the table name used by the sidebar copy action', () => {
|
||||
expect(resolveSidebarTableNameForCopy({
|
||||
title: 'users',
|
||||
@@ -91,6 +165,113 @@ describe('Sidebar locate toolbar', () => {
|
||||
})).toBe('users');
|
||||
});
|
||||
|
||||
it('treats empty lazy children as unloaded for sidebar expansion', () => {
|
||||
expect(hasSidebarLazyChildren(undefined)).toBe(false);
|
||||
expect(hasSidebarLazyChildren([])).toBe(false);
|
||||
expect(hasSidebarLazyChildren([{ key: 'child', title: 'child' }])).toBe(true);
|
||||
expect(shouldLoadSidebarNodeOnExpand({ type: 'database', children: [] })).toBe(true);
|
||||
expect(shouldLoadSidebarNodeOnExpand({ type: 'database', children: [{ key: 'tables', title: '表' }] })).toBe(false);
|
||||
expect(shouldLoadSidebarNodeOnExpand({ type: 'object-group', children: [] })).toBe(false);
|
||||
});
|
||||
|
||||
it('wires tree expand and double-click expansion to lazy loading', () => {
|
||||
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
|
||||
|
||||
expect(source).toContain('if (hasSidebarLazyChildren(children)) return;');
|
||||
expect(source).toContain('if (info?.expanded && shouldLoadSidebarNodeOnExpand(info.node))');
|
||||
expect(source).toContain('if (shouldLoadSidebarNodeOnExpand(node))');
|
||||
});
|
||||
|
||||
it('parses v2 command search prefixes into real search modes', () => {
|
||||
expect(parseV2CommandSearchQuery('@ payment_order')).toMatchObject({
|
||||
mode: 'object',
|
||||
keyword: 'payment_order',
|
||||
normalizedKeyword: 'payment_order',
|
||||
aiPrompt: '',
|
||||
});
|
||||
expect(parseV2CommandSearchQuery('@fs_mkefu_server_info')).toMatchObject({
|
||||
mode: 'object',
|
||||
keyword: 'fs_mkefu_server_info',
|
||||
});
|
||||
expect(parseV2CommandSearchQuery('? 帮我分析订单表')).toMatchObject({
|
||||
mode: 'ai',
|
||||
keyword: '帮我分析订单表',
|
||||
normalizedKeyword: '帮我分析订单表',
|
||||
aiPrompt: '帮我分析订单表',
|
||||
});
|
||||
expect(parseV2CommandSearchQuery('payment')).toMatchObject({
|
||||
mode: 'default',
|
||||
keyword: 'payment',
|
||||
normalizedKeyword: 'payment',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the v2 active host on the selected database connection', () => {
|
||||
const connectionIds = ['local', 'dev240', 'dev241'];
|
||||
const databaseNode = {
|
||||
key: 'dev240-manage_admin',
|
||||
dataRef: {
|
||||
id: 'dev240',
|
||||
dbName: 'manage_admin',
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveSidebarNodeConnectionId(databaseNode, connectionIds)).toBe('dev240');
|
||||
expect(resolveV2ActiveConnectionId({
|
||||
activeContextConnectionId: '',
|
||||
activeTabConnectionId: 'local',
|
||||
selectedKeys: [databaseNode.key],
|
||||
connectionIds,
|
||||
})).toBe('dev240');
|
||||
});
|
||||
|
||||
it('keeps the v2 active host on the pinned rail connection after tree deselect', () => {
|
||||
expect(resolveV2ActiveConnectionId({
|
||||
activeContextConnectionId: '',
|
||||
activeTabConnectionId: 'local',
|
||||
selectedKeys: [],
|
||||
connectionIds: ['local', 'dev240', 'dev241'],
|
||||
fallbackConnectionId: 'dev240',
|
||||
})).toBe('dev240');
|
||||
});
|
||||
|
||||
it('does not clear v2 active context when rc-tree emits an empty deselect', () => {
|
||||
expect(shouldClearSidebarActiveContextOnEmptySelect(true)).toBe(false);
|
||||
expect(shouldClearSidebarActiveContextOnEmptySelect(false)).toBe(true);
|
||||
});
|
||||
|
||||
it('builds v2 rail groups from existing connection tags while preserving ungrouped hosts', () => {
|
||||
const connections = [
|
||||
{ id: 'dev240', name: 'dev240', config: { type: 'mysql', host: '10.0.0.240' } },
|
||||
{ id: 'dev241', name: 'dev241', config: { type: 'postgres', host: '10.0.0.241' } },
|
||||
{ id: 'local', name: 'local', config: { type: 'mysql', host: 'localhost' } },
|
||||
] as any[];
|
||||
|
||||
const groups = buildV2RailConnectionGroups(connections, [{
|
||||
id: 'prod',
|
||||
name: '生产环境',
|
||||
connectionIds: ['dev241', 'missing', 'dev240'],
|
||||
}]);
|
||||
|
||||
expect(groups.map((group) => ({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
isUngrouped: group.isUngrouped,
|
||||
connectionIds: group.connections.map((conn) => conn.id),
|
||||
}))).toEqual([
|
||||
{ id: 'prod', name: '生产环境', isUngrouped: undefined, connectionIds: ['dev241', 'dev240'] },
|
||||
{ id: '__gonavi-v2-ungrouped-connections__', name: '未分组', isUngrouped: true, connectionIds: ['local'] },
|
||||
]);
|
||||
expect(getV2RailConnectionGroupBadgeText('Production')).toBe('PR');
|
||||
expect(getV2RailConnectionGroupBadgeText('生产环境')).toBe('生');
|
||||
});
|
||||
|
||||
it('keeps the sidebar memoized so parent-only button state does not repaint the tree', () => {
|
||||
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
|
||||
|
||||
expect(source).toContain('}> = React.memo(({');
|
||||
});
|
||||
|
||||
it('renders the current table locate action in the sidebar toolbar', () => {
|
||||
const markup = renderToStaticMarkup(<Sidebar />);
|
||||
const externalSqlActionIndex = markup.indexOf('data-sidebar-open-external-sql-file-action="true"');
|
||||
@@ -100,4 +281,424 @@ describe('Sidebar locate toolbar', () => {
|
||||
expect(markup).toContain('aria-label="定位当前打开表"');
|
||||
expect(locateActionIndex).toBeGreaterThan(externalSqlActionIndex);
|
||||
});
|
||||
|
||||
it('renders the v2 sidebar rail, command search hint, filter tabs and log footer', () => {
|
||||
const markup = renderToStaticMarkup(<Sidebar uiVersion="v2" sqlLogCount={2341} />);
|
||||
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
|
||||
|
||||
expect(markup).toContain('gn-v2-sidebar-redesign');
|
||||
expect(markup).toContain('gn-v2-connection-rail');
|
||||
expect(markup).toContain('gn-v2-object-explorer');
|
||||
expect(markup).toContain('gn-v2-active-connection-header');
|
||||
expect(markup).toContain('gn-v2-explorer-search');
|
||||
expect(markup).toContain('gn-v2-explorer-command-trigger');
|
||||
expect(markup).toContain('搜索表、连接、动作... 或问 AI');
|
||||
expect(markup).toContain('gn-v2-search-shortcut');
|
||||
expect(markup).toContain('<kbd>⌘</kbd>');
|
||||
expect(markup).toContain('<kbd>K</kbd>');
|
||||
expect(markup).toContain('gn-v2-explorer-filter-tabs');
|
||||
expect(markup).toContain('全部');
|
||||
expect(markup).toContain('视图');
|
||||
expect(markup).toContain('函数');
|
||||
expect(markup).toContain('aria-pressed="true"');
|
||||
expect(source).toContain("const [v2ExplorerFilter, setV2ExplorerFilter] = useState<V2ExplorerFilter>('all');");
|
||||
expect(source).toContain('onClick={() => setV2ExplorerFilter(item.key)}');
|
||||
expect(source).toContain('treeData={isV2Ui ? v2VisibleTreeData : displayTreeData}');
|
||||
expect(markup).toContain('gn-v2-sidebar-log-footer');
|
||||
expect(markup).toContain('SQL 执行日志');
|
||||
expect(markup).toContain('2,341');
|
||||
expect(markup).toContain('gn-v2-rail-action-group');
|
||||
expect(markup).toContain('data-sidebar-create-group-action="true"');
|
||||
expect(markup).toContain('data-sidebar-batch-table-action="true"');
|
||||
expect(markup).toContain('data-sidebar-batch-database-action="true"');
|
||||
expect(markup).toContain('data-sidebar-open-external-sql-file-action="true"');
|
||||
expect(markup).toContain('data-sidebar-locate-current-tab-action="true"');
|
||||
expect(markup).toContain('aria-label="AI 助手"');
|
||||
expect(markup).toContain('data-gonavi-ai-entry-action="true"');
|
||||
expect(markup).toContain('aria-label="工具"');
|
||||
expect(markup).toContain('data-gonavi-open-tools-action="true"');
|
||||
expect(markup).toContain('aria-label="设置"');
|
||||
expect(source).toContain('buildV2RailConnectionGroups(connections, connectionTags)');
|
||||
expect(source).toContain('data-v2-rail-connection-group="true"');
|
||||
expect(source).toContain('data-v2-rail-connection-group-header="true"');
|
||||
expect(source).toContain("kind: 'v2-connection-group'");
|
||||
expect(source).toContain('data-v2-rail-host-context-menu-trigger="true"');
|
||||
expect(source).toContain('onContextMenu={(event) => openV2ConnectionContextMenu(event, conn)}');
|
||||
expect(source).toContain("kind: 'v2-connection'");
|
||||
expect(source).toContain("if (contextMenu.kind === 'v2-connection') return () => renderV2ConnectionContextMenu(contextMenu.node);");
|
||||
const contextMenuFunction = source.slice(
|
||||
source.indexOf('const openV2ConnectionContextMenu = ('),
|
||||
source.indexOf('const getV2TreeMetaText = (node: any): string => {'),
|
||||
);
|
||||
expect(contextMenuFunction).not.toContain('setSelectedKeys');
|
||||
expect(contextMenuFunction).not.toContain('selectedNodesRef.current');
|
||||
expect(contextMenuFunction).not.toContain('setActiveContext');
|
||||
});
|
||||
|
||||
it('keeps the v2 command search footer hints tied to real prefix actions', () => {
|
||||
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
|
||||
|
||||
expect(source).toContain("const v2CommandSearchObjectMode = v2CommandSearchQuery.mode === 'object';");
|
||||
expect(source).toContain("const v2CommandSearchAiMode = v2CommandSearchQuery.mode === 'ai';");
|
||||
expect(source).toContain("key: 'action-ask-ai'");
|
||||
expect(source).toContain("window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt'");
|
||||
expect(source).toContain('<TableOutlined /> <kbd>@</kbd>只搜表对象');
|
||||
expect(source).toContain('<RobotOutlined /> <kbd>?</kbd>发送给 AI');
|
||||
expect(source).not.toContain('提示 · 以「@」开头按表名搜索,以「?」开头让 AI 回答');
|
||||
});
|
||||
|
||||
it('renders v2 command action shortcuts from the shared shortcut options', () => {
|
||||
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
|
||||
|
||||
expect(source).toContain("shortcut: resolveShortcutDisplay(shortcutOptions, 'newQueryTab', activeShortcutPlatform)");
|
||||
expect(source).toContain("shortcut: resolveShortcutDisplay(shortcutOptions, 'newConnection', activeShortcutPlatform)");
|
||||
expect(source).toContain("shortcut: resolveShortcutDisplay(shortcutOptions, 'toggleAIPanel', activeShortcutPlatform)");
|
||||
expect(source).toContain("shortcut: resolveShortcutDisplay(shortcutOptions, 'toggleLogPanel', activeShortcutPlatform)");
|
||||
expect(source).not.toContain("shortcut: '⌘N'");
|
||||
expect(source).not.toContain("shortcut: '⌘⇧N'");
|
||||
expect(source).not.toContain("shortcut: '⌘J'");
|
||||
expect(source).not.toContain("shortcut: '⌘L'");
|
||||
});
|
||||
|
||||
it('scales the v2 rail and footer tools from global appearance tokens', () => {
|
||||
const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8');
|
||||
|
||||
expect(css).toMatch(/\.gn-v2-rail-action-group,\s*body\[data-ui-version="v2"\] \.gn-v2-rail-system-actions \{[^}]*flex-direction: column;/s);
|
||||
expect(css).toMatch(/\.gn-v2-rail-action-group \{[^}]*border-bottom: 0\.5px solid var\(--gn-br-1\);/s);
|
||||
expect(css).toMatch(/\.gn-v2-explorer-toolbar\s*\{[^}]*display:\s*none\s*!important/s);
|
||||
expect(css).toMatch(/\.ant-tree \{[^}]*font-size: var\(--gn-sidebar-tree-font-size, var\(--gn-font-size-sm, 12px\)\);/s);
|
||||
expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree \{[^}]*font-size: var\(--gn-sidebar-tree-font-size, var\(--gn-font-size-sm, 12px\)\);/s);
|
||||
expect(css).toMatch(/\.gn-v2-tree-title \{[^}]*font-size: var\(--gn-sidebar-tree-font-size, var\(--gn-font-size-sm, 12px\)\);/s);
|
||||
expect(css).toMatch(/\.gn-v2-tree-title\.is-mono \.gn-v2-tree-label \{[^}]*font-size: clamp\(9px, calc\(var\(--gn-sidebar-tree-font-size, var\(--gn-font-size-sm, 12px\)\) - 1px\), 17px\);/s);
|
||||
expect(css).toMatch(/\.gn-v2-tree-count \{[^}]*font-size: clamp\(9px, calc\(var\(--gn-sidebar-tree-font-size, var\(--gn-font-size-sm, 12px\)\) - 2px\), 16px\);/s);
|
||||
expect(css).toMatch(/\.gn-v2-connection-rail \{[^}]*width: calc\(54px \* var\(--gn-ui-scale, 1\)\);[^}]*flex: 0 0 calc\(54px \* var\(--gn-ui-scale, 1\)\);/s);
|
||||
expect(css).toMatch(/\.gn-v2-rail-items \{[^}]*padding-top: calc\(4px \* var\(--gn-ui-scale, 1\)\);/s);
|
||||
expect(css).toMatch(/\.gn-v2-rail-group-header \{[^}]*overflow: visible;/s);
|
||||
expect(css).toMatch(/\.gn-v2-rail-group-chevron \{[^}]*font-size: 10px;/s);
|
||||
expect(css).toMatch(/\.gn-v2-rail-group-count \{[^}]*top: -1px;[^}]*right: -1px;[^}]*min-width: 16px;[^}]*height: 16px;[^}]*font-size: 9px;/s);
|
||||
expect(css).toMatch(/\.gn-v2-rail-item,[^}]*\.gn-v2-rail-tool \{[^}]*width: calc\(38px \* var\(--gn-ui-scale, 1\)\);[^}]*height: calc\(38px \* var\(--gn-ui-scale, 1\)\);[^}]*font-size: var\(--gn-font-size-sm, 12px\);/s);
|
||||
expect(css).toMatch(/\.gn-v2-rail-tool \{[^}]*height: calc\(32px \* var\(--gn-ui-scale, 1\)\);/s);
|
||||
});
|
||||
|
||||
it('keeps v2 tree status dots circular while truncating only the label text', () => {
|
||||
const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8');
|
||||
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
|
||||
|
||||
expect(source).toContain('gn-v2-tree-status is-${status}');
|
||||
expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-title \{[^}]*overflow: visible;/s);
|
||||
expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-title > \.gn-v2-tree-title \{[^}]*overflow: visible;/s);
|
||||
expect(css).toMatch(/\.gn-v2-tree-status \{[^}]*width: 14px;[^}]*height: 14px;[^}]*flex: 0 0 14px;[^}]*overflow: visible;/s);
|
||||
expect(css).toMatch(/\.gn-v2-tree-status::before \{[^}]*width: 7px;[^}]*height: 7px;[^}]*border-radius: 50%;/s);
|
||||
expect(css).toMatch(/\.gn-v2-tree-status\.is-success::before \{[^}]*background: #22c55e;[^}]*box-shadow: 0 0 0 4px rgba\(34, 197, 94, 0\.18\);/s);
|
||||
expect(css).toMatch(/\.gn-v2-tree-label \{[^}]*overflow: hidden;[^}]*text-overflow: ellipsis;/s);
|
||||
});
|
||||
|
||||
it('does not repeat the active connection as an object-tree root in v2', () => {
|
||||
mocks.state.connections = [{
|
||||
id: 'conn-local',
|
||||
name: '本地',
|
||||
config: {
|
||||
type: 'mysql',
|
||||
host: 'localhost',
|
||||
port: 3306,
|
||||
},
|
||||
}];
|
||||
mocks.state.activeContext = { connectionId: 'conn-local', dbName: '' };
|
||||
mocks.state.activeTabId = '';
|
||||
mocks.state.tabs = [];
|
||||
mocks.state.appearance = {
|
||||
enabled: true,
|
||||
opacity: 1,
|
||||
blur: 0,
|
||||
uiVersion: 'v2',
|
||||
};
|
||||
|
||||
const markup = renderToStaticMarkup(<Sidebar uiVersion="v2" />);
|
||||
|
||||
expect(markup).toContain('gn-v2-connection-rail');
|
||||
expect(markup).toContain('gn-v2-active-connection-copy');
|
||||
expect(markup).toContain('<strong>本地</strong>');
|
||||
expect(markup).toContain('<span>localhost</span>');
|
||||
expect(markup).not.toContain('gn-v2-db-icon-label');
|
||||
});
|
||||
|
||||
it('renders existing connection tags as collapsible groups in the v2 rail', () => {
|
||||
mocks.state.connections = [
|
||||
{
|
||||
id: 'dev240',
|
||||
name: 'dev240',
|
||||
config: {
|
||||
type: 'mysql',
|
||||
host: '10.0.0.240',
|
||||
port: 3306,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'local',
|
||||
name: '本地',
|
||||
config: {
|
||||
type: 'postgres',
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
},
|
||||
},
|
||||
];
|
||||
mocks.state.connectionTags = [{
|
||||
id: 'prod',
|
||||
name: '生产环境',
|
||||
connectionIds: ['dev240'],
|
||||
}];
|
||||
mocks.state.activeContext = { connectionId: 'dev240', dbName: '' };
|
||||
mocks.state.activeTabId = '';
|
||||
mocks.state.tabs = [];
|
||||
mocks.state.appearance = {
|
||||
enabled: true,
|
||||
opacity: 1,
|
||||
blur: 0,
|
||||
uiVersion: 'v2',
|
||||
};
|
||||
|
||||
const markup = renderToStaticMarkup(<Sidebar uiVersion="v2" />);
|
||||
|
||||
expect(markup).toContain('data-v2-rail-connection-group="true"');
|
||||
expect(markup).toContain('data-v2-rail-connection-group-header="true"');
|
||||
expect(markup).toContain('title="生产环境 · 1 个连接"');
|
||||
expect(markup).toContain('title="未分组 · 1 个连接"');
|
||||
expect(markup).toContain('aria-label="折叠连接分组 生产环境"');
|
||||
expect(markup).toContain('aria-label="切换到连接 dev240"');
|
||||
expect(markup).toContain('aria-label="切换到连接 本地"');
|
||||
expect(markup).toContain('data-v2-rail-host-context-menu-trigger="true"');
|
||||
});
|
||||
|
||||
it('renders the v2 connection group context menu for rail group management', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<V2ConnectionGroupContextMenuView
|
||||
groupName="生产环境"
|
||||
count={2}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-v2-connection-group-context-menu="true"');
|
||||
expect(markup).toContain('生产环境');
|
||||
expect(markup).toContain('2 个连接 · 连接分组');
|
||||
expect(markup).toContain('GROUP');
|
||||
expect(markup).toContain('编辑分组');
|
||||
expect(markup).toContain('删除分组');
|
||||
});
|
||||
|
||||
it('filters the v2 explorer tree by object kind tabs', () => {
|
||||
const tree = [{
|
||||
title: 'front_end_sys',
|
||||
key: 'conn-main',
|
||||
type: 'database' as const,
|
||||
children: [
|
||||
{
|
||||
title: '已存查询 · saved',
|
||||
key: 'conn-main-queries',
|
||||
type: 'queries-folder' as const,
|
||||
children: [{ title: '日常查询', key: 'query-1', type: 'saved-query' as const }],
|
||||
},
|
||||
{
|
||||
title: '表',
|
||||
key: 'conn-main-tables',
|
||||
type: 'object-group' as const,
|
||||
dataRef: { groupKey: 'tables' },
|
||||
children: [{ title: 'users', key: 'users', type: 'table' as const }],
|
||||
},
|
||||
{
|
||||
title: '视图',
|
||||
key: 'conn-main-views',
|
||||
type: 'object-group' as const,
|
||||
dataRef: { groupKey: 'views' },
|
||||
children: [{ title: 'v_users', key: 'v_users', type: 'view' as const }],
|
||||
},
|
||||
{
|
||||
title: '函数',
|
||||
key: 'conn-main-routines',
|
||||
type: 'object-group' as const,
|
||||
dataRef: { groupKey: 'routines' },
|
||||
children: [{ title: 'calc_total', key: 'calc_total', type: 'routine' as const }],
|
||||
},
|
||||
],
|
||||
}];
|
||||
|
||||
expect(filterV2ExplorerTreeByKind(tree, 'all')[0].children?.map((node) => node.key)).toEqual([
|
||||
'conn-main-queries',
|
||||
'conn-main-tables',
|
||||
'conn-main-views',
|
||||
'conn-main-routines',
|
||||
]);
|
||||
expect(filterV2ExplorerTreeByKind(tree, 'tables')[0].children?.map((node) => node.key)).toEqual(['conn-main-tables']);
|
||||
expect(filterV2ExplorerTreeByKind(tree, 'views')[0].children?.map((node) => node.key)).toEqual(['conn-main-views']);
|
||||
expect(filterV2ExplorerTreeByKind(tree, 'routines')[0].children?.map((node) => node.key)).toEqual(['conn-main-routines']);
|
||||
});
|
||||
|
||||
it('renders the v2 table context menu with the redesigned table layout', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<V2TableContextMenuView
|
||||
tableName="fs_mkefu_server_info"
|
||||
stats={{
|
||||
rowCount: 2,
|
||||
dataLength: 16 * 1024,
|
||||
indexLength: 16 * 1024,
|
||||
engine: 'InnoDB',
|
||||
}}
|
||||
supportsTruncate
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-v2-table-context-menu="true"');
|
||||
expect(markup).toContain('fs_mkefu_server_info');
|
||||
expect(markup).toContain('InnoDB');
|
||||
expect(markup).toContain('2 行 · 16 KB 数据 · 16 KB 索引');
|
||||
expect(markup).toContain('查看数据');
|
||||
expect(markup).toContain('↵');
|
||||
expect(markup).toContain('置顶表');
|
||||
expect(markup).toContain('字段 / 索引 / 外键');
|
||||
expect(markup).toContain('在新标签打开');
|
||||
expect(markup).toContain('⌘↵');
|
||||
expect(markup).toContain('元信息');
|
||||
expect(markup).toContain('查看 DDL · CREATE TABLE');
|
||||
expect(markup).toContain('在 ER 图中查看');
|
||||
expect(markup).toContain('复制');
|
||||
expect(markup).toContain('复制表名');
|
||||
expect(markup).toContain('复制表结构 · DDL');
|
||||
expect(markup).toContain('复制全表为 INSERT');
|
||||
expect(markup).toContain('维护');
|
||||
expect(markup).toContain('重命名…');
|
||||
expect(markup).toContain('备份 · SQL Dump');
|
||||
expect(markup).toContain('刷新统计信息');
|
||||
expect(markup).toContain('导出表数据');
|
||||
expect(markup).toContain('Excel · .xlsx');
|
||||
expect(markup).toContain('CSV · .csv');
|
||||
expect(markup).toContain('JSON · .json');
|
||||
expect(markup).not.toContain('Markdown · .md');
|
||||
expect(markup).not.toContain('HTML · .html');
|
||||
expect(markup).toContain('用 AI 解释这张表');
|
||||
expect(markup).toContain('用 AI 生成查询');
|
||||
expect(markup).toContain('截断表 · TRUNCATE');
|
||||
expect(markup).toContain('删除表 · DROP');
|
||||
expect(markup).not.toContain('清空表');
|
||||
});
|
||||
|
||||
it('renders the v2 table context menu pinned state', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<V2TableContextMenuView
|
||||
tableName="fs_mkefu_server_info"
|
||||
isPinned
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('取消置顶');
|
||||
expect(markup).toContain('已置顶');
|
||||
expect(markup).not.toContain('置顶表');
|
||||
});
|
||||
|
||||
it('sorts pinned sidebar tables before the active sort mode', () => {
|
||||
const pinnedSidebarTables = [
|
||||
buildSidebarTablePinKey('conn-1', 'main', 'orders', 'public'),
|
||||
];
|
||||
const entries = [
|
||||
{ tableName: 'users', schemaName: 'public', displayName: 'users' },
|
||||
{ tableName: 'orders', schemaName: 'public', displayName: 'orders' },
|
||||
{ tableName: 'audit', schemaName: 'public', displayName: 'audit' },
|
||||
];
|
||||
|
||||
expect(isSidebarTablePinned(pinnedSidebarTables, 'conn-1', 'main', 'orders', 'public')).toBe(true);
|
||||
expect(sortSidebarTableEntries(entries, {
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
sortBy: 'frequency',
|
||||
tableAccessCount: {
|
||||
'conn-1-main-users': 10,
|
||||
'conn-1-main-orders': 1,
|
||||
'conn-1-main-audit': 3,
|
||||
},
|
||||
pinnedSidebarTables,
|
||||
}).map((entry) => entry.tableName)).toEqual(['orders', 'users', 'audit']);
|
||||
});
|
||||
|
||||
it('formats v2 table context menu stats like the prototype header', () => {
|
||||
expect(formatV2TableContextMenuRows(2)).toBe('2 行');
|
||||
expect(formatV2TableContextMenuSize(16 * 1024)).toBe('16 KB');
|
||||
});
|
||||
|
||||
it('renders the v2 database context menu with the redesigned grouped layout', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<V2DatabaseContextMenuView
|
||||
dbName="mkefu_ai_dev"
|
||||
dialect="starrocks"
|
||||
supportsStarRocksActions
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-v2-database-context-menu="true"');
|
||||
expect(markup).toContain('mkefu_ai_dev');
|
||||
expect(markup).toContain('DB');
|
||||
expect(markup).toContain('新建表');
|
||||
expect(markup).toContain('新建查询');
|
||||
expect(markup).toContain('运行外部 SQL 文件');
|
||||
expect(markup).toContain('StarRocks');
|
||||
expect(markup).toContain('新建物化视图');
|
||||
expect(markup).toContain('新建外部 Catalog');
|
||||
expect(markup).toContain('维护');
|
||||
expect(markup).toContain('重命名数据库');
|
||||
expect(markup).toContain('刷新对象树');
|
||||
expect(markup).toContain('关闭数据库');
|
||||
expect(markup).toContain('导出与备份');
|
||||
expect(markup).toContain('导出全部表结构 · SQL');
|
||||
expect(markup).toContain('备份全部表 · 结构 + 数据');
|
||||
expect(markup).toContain('删除数据库 · DROP');
|
||||
});
|
||||
|
||||
it('renders the v2 connection context menu for host rail actions', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<V2ConnectionContextMenuView
|
||||
connectionName="dev240"
|
||||
hostSummary="10.0.0.240:3306"
|
||||
driverLabel="mysql"
|
||||
tags={[
|
||||
{ id: 'prod', name: '生产环境', selected: true },
|
||||
{ id: 'debug', name: '临时调试' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-v2-connection-context-menu="true"');
|
||||
expect(markup).toContain('dev240');
|
||||
expect(markup).toContain('mysql · 10.0.0.240:3306');
|
||||
expect(markup).toContain('HOST');
|
||||
expect(markup).toContain('新建数据库');
|
||||
expect(markup).toContain('刷新连接');
|
||||
expect(markup).toContain('新建查询');
|
||||
expect(markup).toContain('运行外部 SQL 文件');
|
||||
expect(markup).toContain('编辑连接');
|
||||
expect(markup).toContain('复制连接');
|
||||
expect(markup).toContain('断开连接');
|
||||
expect(markup).toContain('分组');
|
||||
expect(markup).toContain('生产环境');
|
||||
expect(markup).toContain('临时调试');
|
||||
expect(markup).toContain('移出分组');
|
||||
expect(markup).toContain('删除连接');
|
||||
});
|
||||
|
||||
it('renders the v2 table group menu with sort state', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<V2TableGroupContextMenuView
|
||||
dbName="mkefu_ai_dev"
|
||||
count={15}
|
||||
currentSort="frequency"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-v2-table-group-context-menu="true"');
|
||||
expect(markup).toContain('表 · tables');
|
||||
expect(markup).toContain('15 张表');
|
||||
expect(markup).toContain('当前按使用频率排序');
|
||||
expect(markup).toContain('新建表');
|
||||
expect(markup).toContain('排序');
|
||||
expect(markup).toContain('按名称排序');
|
||||
expect(markup).toContain('按使用频率排序');
|
||||
expect(markup).toContain('当前');
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
111
frontend/src/components/TabManager.hover.test.tsx
Normal file
111
frontend/src/components/TabManager.hover.test.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { resolveTabHoverOpen, TabHoverInfo, stopTabHoverDragPropagation } from './TabManager';
|
||||
import type { TabData } from '../types';
|
||||
|
||||
describe('TabManager hover info', () => {
|
||||
it('memoizes the tab workbench so parent-only modal state does not repaint open tabs', () => {
|
||||
const source = readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8');
|
||||
|
||||
expect(source).toContain('const TabManager: React.FC = React.memo(() => {');
|
||||
});
|
||||
|
||||
it('renders full v2 tab hover context for table tabs', () => {
|
||||
const tab: TabData = {
|
||||
id: 'conn-1-main-users',
|
||||
title: 'users',
|
||||
type: 'table',
|
||||
connectionId: 'conn-1',
|
||||
dbName: 'main',
|
||||
tableName: 'users',
|
||||
};
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<TabHoverInfo
|
||||
tab={tab}
|
||||
displayTitle="[开发240] 表概览"
|
||||
connectionLabel="开发240"
|
||||
hostSummary="192.168.1.240"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-tab-hover-info="true"');
|
||||
expect(markup).toContain('[开发240] 表概览');
|
||||
expect(markup).toContain('类型');
|
||||
expect(markup).toContain('表数据');
|
||||
expect(markup).toContain('连接');
|
||||
expect(markup).toContain('开发240');
|
||||
expect(markup).toContain('Host');
|
||||
expect(markup).toContain('192.168.1.240');
|
||||
expect(markup).toContain('数据库');
|
||||
expect(markup).toContain('main');
|
||||
expect(markup).toContain('对象');
|
||||
expect(markup).toContain('users');
|
||||
});
|
||||
|
||||
it('renders db identity for redis tabs without a database name', () => {
|
||||
const tab: TabData = {
|
||||
id: 'redis-keys-conn-1-db2',
|
||||
title: 'db2',
|
||||
type: 'redis-keys',
|
||||
connectionId: 'conn-1',
|
||||
redisDB: 2,
|
||||
};
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<TabHoverInfo
|
||||
tab={tab}
|
||||
displayTitle="[缓存 | 10.0.0.8] db2"
|
||||
connectionLabel="缓存"
|
||||
hostSummary="10.0.0.8"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('REDIS');
|
||||
expect(markup).toContain('Redis Key');
|
||||
expect(markup).toContain('未指定');
|
||||
expect(markup).toContain('db2');
|
||||
});
|
||||
|
||||
it('stops hover card pointer events from reaching tab drag listeners without blocking text selection', () => {
|
||||
const event = {
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
} as unknown as React.SyntheticEvent<HTMLElement>;
|
||||
|
||||
stopTabHoverDragPropagation(event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
expect(event.stopPropagation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps tab hover hidden while the tab context menu is open', () => {
|
||||
expect(resolveTabHoverOpen(true, false)).toBe(true);
|
||||
expect(resolveTabHoverOpen(true, true)).toBe(false);
|
||||
expect(resolveTabHoverOpen(false, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('wires hover card tab-switch and drag-blocking handlers with selectable text styles', () => {
|
||||
const source = readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8');
|
||||
|
||||
expect(source).toContain('onPointerDown={stopTabHoverDragPropagation}');
|
||||
expect(source).toContain('onPointerUp={stopTabHoverDragPropagation}');
|
||||
expect(source).toContain('onPointerDownCapture={stopTabHoverDragPropagation}');
|
||||
expect(source).toContain('onMouseDown={stopTabHoverDragPropagation}');
|
||||
expect(source).toContain('onMouseUp={stopTabHoverDragPropagation}');
|
||||
expect(source).toContain('onClick={stopTabHoverDragPropagation}');
|
||||
expect(source).toContain('onClickCapture={stopTabHoverDragPropagation}');
|
||||
expect(source).toContain('onTouchStart={stopTabHoverDragPropagation}');
|
||||
expect(source).toContain('onTouchEnd={stopTabHoverDragPropagation}');
|
||||
expect(source).toContain('setIsHoverInfoOpen(false);');
|
||||
expect(source).toContain('setIsTabMenuOpen(true);');
|
||||
expect(source).toContain('open={resolveTabHoverOpen(isHoverInfoOpen, isTabMenuOpen)}');
|
||||
expect(source).toContain('onOpenChange={handleHoverInfoOpenChange}');
|
||||
expect(source).toContain('onOpenChange={handleTabMenuOpenChange}');
|
||||
expect(source).toMatch(/\.gn-v2-tab-hover-card \{[^}]*cursor: text;[^}]*user-select: text;/s);
|
||||
expect(source).toMatch(/\.gn-v2-tab-hover-card \* \{[^}]*user-select: text;/s);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import { Tabs, Dropdown } from 'antd';
|
||||
import { Button, Dropdown, Tabs, Tooltip } from 'antd';
|
||||
import { AppstoreOutlined, CloseOutlined, ConsoleSqlOutlined, DatabaseOutlined, PlusOutlined, RobotOutlined } from '@ant-design/icons';
|
||||
import type { MenuProps, TabsProps } from 'antd';
|
||||
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core';
|
||||
import type { DragStartEvent, DragEndEvent } from '@dnd-kit/core';
|
||||
@@ -23,34 +24,222 @@ import JVMDiagnosticConsole from './JVMDiagnosticConsole';
|
||||
import JVMMonitoringDashboard from './JVMMonitoringDashboard';
|
||||
import type { TabData } from '../types';
|
||||
import { buildTabDisplayTitle } from '../utils/tabDisplay';
|
||||
import { resolveConnectionHostSummary } from '../utils/tabDisplay';
|
||||
import { resolveConnectionAccentColor } from '../utils/connectionVisual';
|
||||
|
||||
const getTabKindLabel = (tab: TabData): string => {
|
||||
if (tab.type === 'query') return 'SQL';
|
||||
if (tab.type === 'table') return 'TABLE';
|
||||
if (tab.type === 'design') return 'DESIGN';
|
||||
if (tab.type === 'table-overview') return 'DB';
|
||||
if (tab.type.startsWith('redis')) return 'REDIS';
|
||||
if (tab.type.startsWith('jvm')) return 'JVM';
|
||||
if (tab.type === 'trigger') return 'TRG';
|
||||
if (tab.type === 'view-def') return tab.viewKind === 'materialized' ? 'MV' : 'VIEW';
|
||||
if (tab.type === 'routine-def') return 'FUNC';
|
||||
return 'TAB';
|
||||
};
|
||||
|
||||
const getTabKindIcon = (tab: TabData): React.ReactNode => {
|
||||
if (tab.type === 'query') return <ConsoleSqlOutlined />;
|
||||
if (tab.type === 'table-overview') return <DatabaseOutlined />;
|
||||
if (tab.type.startsWith('redis')) return <DatabaseOutlined />;
|
||||
if (tab.type.startsWith('jvm')) return <AppstoreOutlined />;
|
||||
return <DatabaseOutlined />;
|
||||
};
|
||||
|
||||
const getTabKindTooltipLabel = (tab: TabData): string => {
|
||||
if (tab.type === 'query') return 'SQL 查询';
|
||||
if (tab.type === 'table') return '表数据';
|
||||
if (tab.type === 'design') return '表设计';
|
||||
if (tab.type === 'table-overview') return '表概览';
|
||||
if (tab.type === 'redis-keys') return 'Redis Key';
|
||||
if (tab.type === 'redis-command') return 'Redis 命令';
|
||||
if (tab.type === 'redis-monitor') return 'Redis 监控';
|
||||
if (tab.type === 'jvm-overview') return 'JVM 概览';
|
||||
if (tab.type === 'jvm-resource') return 'JVM 资源';
|
||||
if (tab.type === 'jvm-audit') return 'JVM 审计';
|
||||
if (tab.type === 'jvm-diagnostic') return 'JVM 诊断';
|
||||
if (tab.type === 'jvm-monitoring') return 'JVM 监控';
|
||||
if (tab.type === 'trigger') return '触发器';
|
||||
if (tab.type === 'view-def') return tab.viewKind === 'materialized' ? '物化视图' : '视图';
|
||||
if (tab.type === 'routine-def') return '函数 / 过程';
|
||||
return '标签页';
|
||||
};
|
||||
|
||||
const getTabObjectLabel = (tab: TabData): string => {
|
||||
if (tab.tableName) return tab.tableName;
|
||||
if (tab.viewName) return tab.viewName;
|
||||
if (tab.routineName) return tab.routineName;
|
||||
if (tab.triggerName) return tab.triggerName;
|
||||
if (tab.resourcePath) return tab.resourcePath;
|
||||
if (tab.filePath) return tab.filePath;
|
||||
if (tab.type.startsWith('redis')) return `db${tab.redisDB ?? 0}`;
|
||||
return '';
|
||||
};
|
||||
|
||||
export const stopTabHoverDragPropagation = (event: React.SyntheticEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
export const resolveTabHoverOpen = (isHoverInfoOpen: boolean, isTabMenuOpen: boolean) =>
|
||||
isHoverInfoOpen && !isTabMenuOpen;
|
||||
|
||||
type TabHoverInfoProps = {
|
||||
tab: TabData;
|
||||
displayTitle: string;
|
||||
connectionLabel?: string;
|
||||
hostSummary?: string;
|
||||
};
|
||||
|
||||
export const TabHoverInfo: React.FC<TabHoverInfoProps> = ({
|
||||
tab,
|
||||
displayTitle,
|
||||
connectionLabel,
|
||||
hostSummary,
|
||||
}) => {
|
||||
const objectLabel = getTabObjectLabel(tab);
|
||||
const rows = [
|
||||
['类型', getTabKindTooltipLabel(tab)],
|
||||
['连接', connectionLabel || '未绑定连接'],
|
||||
['Host', hostSummary || '未配置'],
|
||||
['数据库', tab.dbName || '未指定'],
|
||||
['对象', objectLabel],
|
||||
].filter(([, value]) => Boolean(value));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="gn-v2-tab-hover-card"
|
||||
data-tab-hover-info="true"
|
||||
onPointerDown={stopTabHoverDragPropagation}
|
||||
onPointerMove={stopTabHoverDragPropagation}
|
||||
onPointerUp={stopTabHoverDragPropagation}
|
||||
onPointerDownCapture={stopTabHoverDragPropagation}
|
||||
onPointerUpCapture={stopTabHoverDragPropagation}
|
||||
onMouseDown={stopTabHoverDragPropagation}
|
||||
onMouseMove={stopTabHoverDragPropagation}
|
||||
onMouseUp={stopTabHoverDragPropagation}
|
||||
onClick={stopTabHoverDragPropagation}
|
||||
onClickCapture={stopTabHoverDragPropagation}
|
||||
onTouchStart={stopTabHoverDragPropagation}
|
||||
onTouchMove={stopTabHoverDragPropagation}
|
||||
onTouchEnd={stopTabHoverDragPropagation}
|
||||
>
|
||||
<div className="gn-v2-tab-hover-head">
|
||||
<span>{getTabKindLabel(tab)}</span>
|
||||
<strong>{displayTitle}</strong>
|
||||
</div>
|
||||
<div className="gn-v2-tab-hover-rows">
|
||||
{rows.map(([label, value]) => (
|
||||
<div className="gn-v2-tab-hover-row" key={label}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SortableTabLabelProps = {
|
||||
tab: TabData;
|
||||
displayTitle: string;
|
||||
menuItems: MenuProps['items'];
|
||||
accentColor?: string;
|
||||
connectionLabel?: string;
|
||||
hostSummary?: string;
|
||||
isV2Ui?: boolean;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
|
||||
tab,
|
||||
displayTitle,
|
||||
menuItems,
|
||||
accentColor,
|
||||
connectionLabel,
|
||||
hostSummary,
|
||||
isV2Ui,
|
||||
onClose,
|
||||
}) => {
|
||||
const [isHoverInfoOpen, setIsHoverInfoOpen] = useState(false);
|
||||
const [isTabMenuOpen, setIsTabMenuOpen] = useState(false);
|
||||
const labelStyle = accentColor
|
||||
? ({ '--connection-accent': accentColor } as React.CSSProperties)
|
||||
: undefined;
|
||||
|
||||
const handleTabLabelContextMenu = (event: React.MouseEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
setIsHoverInfoOpen(false);
|
||||
setIsTabMenuOpen(true);
|
||||
};
|
||||
|
||||
const handleTabMenuOpenChange = (open: boolean) => {
|
||||
setIsTabMenuOpen(open);
|
||||
setIsHoverInfoOpen(false);
|
||||
};
|
||||
|
||||
const handleHoverInfoOpenChange = (open: boolean) => {
|
||||
setIsHoverInfoOpen(open && !isTabMenuOpen);
|
||||
};
|
||||
|
||||
const labelNode = (
|
||||
<span
|
||||
className={`tab-dnd-label${accentColor ? ' has-connection-accent' : ''}${isV2Ui ? ' gn-v2-tab-label' : ''}`}
|
||||
onContextMenu={handleTabLabelContextMenu}
|
||||
title={isV2Ui ? undefined : displayTitle}
|
||||
style={labelStyle}
|
||||
>
|
||||
{isV2Ui ? (
|
||||
<span className="gn-v2-tab-kind-icon" aria-hidden="true">
|
||||
{getTabKindIcon(tab)}
|
||||
</span>
|
||||
) : null}
|
||||
{isV2Ui ? <span className="gn-v2-tab-kind">{getTabKindLabel(tab)}</span> : null}
|
||||
{accentColor ? <span className="tab-connection-accent" aria-hidden="true" /> : null}
|
||||
<span className="tab-title-text">{displayTitle}</span>
|
||||
{isV2Ui && connectionLabel ? <span className="gn-v2-tab-conn">{connectionLabel}</span> : null}
|
||||
{isV2Ui && onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
className="gn-v2-tab-close"
|
||||
aria-label={`关闭 ${displayTitle}`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
|
||||
const wrappedLabel = isV2Ui ? (
|
||||
<Tooltip
|
||||
title={(
|
||||
<TabHoverInfo
|
||||
tab={tab}
|
||||
displayTitle={displayTitle}
|
||||
connectionLabel={connectionLabel}
|
||||
hostSummary={hostSummary}
|
||||
/>
|
||||
)}
|
||||
placement="bottomLeft"
|
||||
mouseEnterDelay={0.25}
|
||||
open={resolveTabHoverOpen(isHoverInfoOpen, isTabMenuOpen)}
|
||||
onOpenChange={handleHoverInfoOpenChange}
|
||||
destroyOnHidden
|
||||
rootClassName="gn-v2-tab-hover-tooltip"
|
||||
>
|
||||
{labelNode}
|
||||
</Tooltip>
|
||||
) : labelNode;
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
|
||||
<span
|
||||
className={`tab-dnd-label${accentColor ? ' has-connection-accent' : ''}`}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
title={displayTitle}
|
||||
style={labelStyle}
|
||||
>
|
||||
{accentColor ? <span className="tab-connection-accent" aria-hidden="true" /> : null}
|
||||
<span className="tab-title-text">{displayTitle}</span>
|
||||
</span>
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']} onOpenChange={handleTabMenuOpenChange}>
|
||||
{wrappedLabel}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
@@ -81,10 +270,11 @@ const DraggableTabNode: React.FC<DraggableTabNodeProps> = ({ node }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const TabManager: React.FC = () => {
|
||||
const TabManager: React.FC = React.memo(() => {
|
||||
const tabs = useStore(state => state.tabs);
|
||||
const connections = useStore(state => state.connections);
|
||||
const theme = useStore(state => state.theme);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const activeTabId = useStore(state => state.activeTabId);
|
||||
const setActiveTab = useStore(state => state.setActiveTab);
|
||||
const addTab = useStore(state => state.addTab);
|
||||
@@ -94,6 +284,7 @@ const TabManager: React.FC = () => {
|
||||
const closeTabsToRight = useStore(state => state.closeTabsToRight);
|
||||
const closeAllTabs = useStore(state => state.closeAllTabs);
|
||||
const moveTab = useStore(state => state.moveTab);
|
||||
const setAIPanelVisible = useStore(state => state.setAIPanelVisible);
|
||||
const tabsNavBorderColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.09)' : 'rgba(0, 0, 0, 0.08)';
|
||||
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
|
||||
const suppressClickUntilRef = useRef<number>(0);
|
||||
@@ -102,6 +293,8 @@ const TabManager: React.FC = () => {
|
||||
activationConstraint: { distance: 8 },
|
||||
})
|
||||
);
|
||||
const isV2Ui = appearance.uiVersion === 'v2';
|
||||
const hasTabs = tabs.length > 0;
|
||||
|
||||
const onChange = (newActiveKey: string) => {
|
||||
setActiveTab(newActiveKey);
|
||||
@@ -198,6 +391,7 @@ const TabManager: React.FC = () => {
|
||||
const connection = connections.find((conn) => conn.id === tab.connectionId);
|
||||
const displayTitle = buildTabDisplayTitle(tab, connection);
|
||||
const accentColor = connection ? resolveConnectionAccentColor(connection) : undefined;
|
||||
const hostSummary = resolveConnectionHostSummary(connection?.config);
|
||||
const tabIsActive = tab.id === activeTabId;
|
||||
let content;
|
||||
if (tab.type === 'query') {
|
||||
@@ -261,18 +455,84 @@ const TabManager: React.FC = () => {
|
||||
return {
|
||||
label: (
|
||||
<SortableTabLabel
|
||||
tab={tab}
|
||||
displayTitle={displayTitle}
|
||||
menuItems={menuItems}
|
||||
accentColor={accentColor}
|
||||
connectionLabel={connection?.name}
|
||||
hostSummary={hostSummary}
|
||||
isV2Ui={isV2Ui}
|
||||
onClose={() => closeTab(tab.id)}
|
||||
/>
|
||||
),
|
||||
key: tab.id,
|
||||
closable: !isV2Ui,
|
||||
children: content,
|
||||
};
|
||||
}), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
|
||||
}), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs, closeTab, isV2Ui]);
|
||||
|
||||
const handleOpenConnectionModal = () => {
|
||||
const target = document.querySelector<HTMLButtonElement>('[data-gonavi-create-connection-action="true"]');
|
||||
target?.click();
|
||||
};
|
||||
|
||||
const handleOpenAI = () => {
|
||||
setAIPanelVisible(true);
|
||||
};
|
||||
|
||||
const EmptyWorkbench = (
|
||||
<div className="gn-v2-empty-workbench">
|
||||
<section className="gn-v2-empty-hero" aria-label="GoNavi 起始工作台">
|
||||
<div className="gn-v2-empty-eyebrow">
|
||||
<span>WORKBENCH</span>
|
||||
<span>{connections.length} connections</span>
|
||||
</div>
|
||||
<h1>连接、查询和分析从同一个工作台开始。</h1>
|
||||
<p>选择数据源、打开查询编辑器,或把上下文交给 AI 面板继续处理。</p>
|
||||
<div className="gn-v2-empty-actions">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleOpenConnectionModal}>
|
||||
新建连接
|
||||
</Button>
|
||||
<Button icon={<ConsoleSqlOutlined />} onClick={() => window.dispatchEvent(new CustomEvent('gonavi:create-query-tab'))}>
|
||||
新建查询
|
||||
</Button>
|
||||
<Button icon={<RobotOutlined />} onClick={handleOpenAI}>
|
||||
打开 AI
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<section className="gn-v2-empty-panel" aria-label="快捷工作流">
|
||||
<div className="gn-v2-panel-heading">
|
||||
<span>快捷工作流</span>
|
||||
<AppstoreOutlined />
|
||||
</div>
|
||||
<button type="button" onClick={handleOpenConnectionModal}>
|
||||
<DatabaseOutlined />
|
||||
<span>
|
||||
<strong>配置数据源</strong>
|
||||
<small>URI、SSH、代理和驱动集中设置</small>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" onClick={() => window.dispatchEvent(new CustomEvent('gonavi:create-query-tab'))}>
|
||||
<ConsoleSqlOutlined />
|
||||
<span>
|
||||
<strong>启动 SQL 工作区</strong>
|
||||
<small>按当前上下文打开查询编辑器</small>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" onClick={handleOpenAI}>
|
||||
<RobotOutlined />
|
||||
<span>
|
||||
<strong>进入 AI 辅助</strong>
|
||||
<small>解释 SQL、生成查询、梳理结果</small>
|
||||
</span>
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={isV2Ui ? 'gn-v2-tab-workbench' : undefined}>
|
||||
<style>{`
|
||||
.main-tabs {
|
||||
height: 100%;
|
||||
@@ -369,11 +629,87 @@ const TabManager: React.FC = () => {
|
||||
background: rgba(24, 144, 255, 0.10) !important;
|
||||
border-color: rgba(24, 144, 255, 0.28) !important;
|
||||
}
|
||||
body[data-theme='dark'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
|
||||
body[data-theme='dark'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
|
||||
background: rgba(255, 214, 102, 0.12) !important;
|
||||
border-color: rgba(255, 214, 102, 0.4) !important;
|
||||
}
|
||||
body[data-ui-version='v2'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
|
||||
background: var(--gn-bg-panel) !important;
|
||||
border-color: var(--gn-br-2) !important;
|
||||
}
|
||||
body[data-ui-version='v2'] .gn-v2-tab-hover-tooltip .ant-tooltip-inner {
|
||||
min-width: 260px;
|
||||
padding: 0;
|
||||
}
|
||||
body[data-ui-version='v2'] .gn-v2-tab-hover-tooltip {
|
||||
pointer-events: auto;
|
||||
}
|
||||
body[data-ui-version='v2'] .gn-v2-tab-hover-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
color: var(--gn-fg-2);
|
||||
cursor: text;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
body[data-ui-version='v2'] .gn-v2-tab-hover-card * {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
body[data-ui-version='v2'] .gn-v2-tab-hover-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
body[data-ui-version='v2'] .gn-v2-tab-hover-head > span {
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 6px;
|
||||
border-radius: 5px;
|
||||
background: var(--gn-bg-active);
|
||||
color: var(--gn-accent-2);
|
||||
font-family: var(--gn-font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 14px;
|
||||
}
|
||||
body[data-ui-version='v2'] .gn-v2-tab-hover-head > strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--gn-fg-1);
|
||||
font-size: var(--gn-font-size-sm, 12px);
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
body[data-ui-version='v2'] .gn-v2-tab-hover-rows {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
body[data-ui-version='v2'] .gn-v2-tab-hover-row {
|
||||
display: grid;
|
||||
grid-template-columns: 52px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
font-size: var(--gn-font-size-sm, 12px);
|
||||
line-height: 18px;
|
||||
}
|
||||
body[data-ui-version='v2'] .gn-v2-tab-hover-row > span {
|
||||
color: var(--gn-fg-5);
|
||||
}
|
||||
body[data-ui-version='v2'] .gn-v2-tab-hover-row > strong {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--gn-fg-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
`}</style>
|
||||
{isV2Ui && !hasTabs ? (
|
||||
EmptyWorkbench
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
@@ -384,9 +720,9 @@ const TabManager: React.FC = () => {
|
||||
>
|
||||
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
|
||||
<Tabs
|
||||
className="main-tabs"
|
||||
className={`main-tabs${isV2Ui ? ' gn-v2-main-tabs' : ''}`}
|
||||
type="editable-card"
|
||||
destroyInactiveTabPane={false}
|
||||
destroyOnHidden={false}
|
||||
onChange={(newActiveKey) => {
|
||||
if (Date.now() < suppressClickUntilRef.current) return;
|
||||
onChange(newActiveKey);
|
||||
@@ -399,8 +735,9 @@ const TabManager: React.FC = () => {
|
||||
/>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default TabManager;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState, useContext, useMemo, useRef, useCallback } from 'react';
|
||||
import { Table, Tabs, Button, message, Input, Checkbox, Modal, AutoComplete, Tooltip, Select, Empty, Space, Tag, Radio } from 'antd';
|
||||
import { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutlined, FileTextOutlined, EyeOutlined, EditOutlined, ExclamationCircleOutlined, CopyOutlined } from '@ant-design/icons';
|
||||
import { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutlined, FileTextOutlined, EyeOutlined, EditOutlined, ExclamationCircleOutlined, CopyOutlined, TableOutlined } from '@ant-design/icons';
|
||||
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragOverlay } from '@dnd-kit/core';
|
||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
@@ -428,9 +428,14 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
const connections = useStore(state => state.connections);
|
||||
const theme = useStore(state => state.theme);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const darkMode = theme === 'dark';
|
||||
const isV2Ui = appearance.uiVersion === 'v2';
|
||||
const resizeGuideColor = darkMode ? '#f6c453' : '#1890ff';
|
||||
const readOnly = !!tab.readOnly;
|
||||
const designerTableTitle = tab.tableName || newTableName || '未命名表';
|
||||
const designerDbTitle = tab.dbName || '默认库';
|
||||
const designerColumnSummary = `${columns.length} 字段`;
|
||||
const panelRadius = 10;
|
||||
const panelFrameColor = darkMode ? 'rgba(0, 0, 0, 0.18)' : 'rgba(0, 0, 0, 0.12)';
|
||||
const panelToolbarBorder = darkMode ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.10)';
|
||||
@@ -2495,7 +2500,7 @@ END;`;
|
||||
const columnsTabContent = (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="table-designer-wrapper"
|
||||
className={`table-designer-wrapper${isV2Ui ? ' gn-v2-designer-table-shell' : ''}`}
|
||||
style={{
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
@@ -2553,7 +2558,7 @@ END;`;
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={shellRef} className="table-designer-shell" style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, padding: '6px 0', position: 'relative' }}>
|
||||
<div ref={shellRef} className={`table-designer-shell${isV2Ui ? ' gn-v2-table-designer' : ''}`} style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, padding: '6px 0', position: 'relative' }}>
|
||||
<style>{`
|
||||
.table-designer-shell .ant-table,
|
||||
.table-designer-shell .ant-table-wrapper,
|
||||
@@ -2644,7 +2649,21 @@ END;`;
|
||||
willChange: 'transform',
|
||||
}}
|
||||
/>
|
||||
{isV2Ui && (
|
||||
<div className="gn-v2-designer-header">
|
||||
<div className="gn-v2-designer-title">
|
||||
<span>SCHEMA DESIGNER</span>
|
||||
<strong>{designerTableTitle}</strong>
|
||||
</div>
|
||||
<div className="gn-v2-designer-meta">
|
||||
<span><TableOutlined /> {designerDbTitle}</span>
|
||||
<span>{designerColumnSummary}</span>
|
||||
{readOnly && <span>只读</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={isV2Ui ? 'gn-v2-designer-toolbar' : undefined}
|
||||
style={{
|
||||
padding: '10px 12px 8px 12px',
|
||||
borderBottom: `1px solid ${panelToolbarBorder}`,
|
||||
@@ -2716,6 +2735,7 @@ END;`;
|
||||
<div style={{ flex: 1 }} />
|
||||
</div>
|
||||
<Tabs
|
||||
className={isV2Ui ? 'gn-v2-designer-tabs' : undefined}
|
||||
activeKey={activeKey}
|
||||
onChange={(key) => React.startTransition(() => setActiveKey(key))}
|
||||
style={{
|
||||
@@ -2747,9 +2767,9 @@ END;`;
|
||||
key: 'indexes',
|
||||
label: '索引',
|
||||
children: (
|
||||
<div className="index-table-wrap" style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div className={`index-table-wrap${isV2Ui ? ' gn-v2-designer-tab-content' : ''}`} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{!readOnly && (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div className={isV2Ui ? 'gn-v2-designer-actionbar' : undefined} style={{ display: 'flex', gap: 8 }}>
|
||||
<Button size="small" icon={<PlusOutlined />} disabled={!supportsIndexSchemaOps()} onClick={openCreateIndexModal}>新增</Button>
|
||||
<Button size="small" icon={<EditOutlined />} disabled={!supportsIndexSchemaOps() || selectedIndexKeys.length !== 1} onClick={openEditIndexModal}>修改</Button>
|
||||
<Button size="small" icon={<DeleteOutlined />} danger disabled={!supportsIndexSchemaOps() || selectedIndexKeys.length === 0} onClick={handleDeleteIndex}>删除</Button>
|
||||
@@ -2765,7 +2785,7 @@ END;`;
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ color: '#888', fontSize: 12 }}>
|
||||
<div className={isV2Ui ? 'gn-v2-designer-section-note' : undefined} style={{ color: '#888', fontSize: 12 }}>
|
||||
索引数:{groupedIndexes.length},索引字段:{groupedIndexFieldCount}
|
||||
</div>
|
||||
<Table
|
||||
@@ -2801,9 +2821,9 @@ END;`;
|
||||
key: 'foreignKeys',
|
||||
label: '外键',
|
||||
children: (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div className={isV2Ui ? 'gn-v2-designer-tab-content' : undefined} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{!readOnly && (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div className={isV2Ui ? 'gn-v2-designer-actionbar' : undefined} style={{ display: 'flex', gap: 8 }}>
|
||||
<Button size="small" icon={<PlusOutlined />} disabled={!supportsForeignKeySchemaOps()} onClick={openCreateForeignKeyModal}>新增</Button>
|
||||
<Button size="small" icon={<EditOutlined />} disabled={!supportsForeignKeySchemaOps() || !selectedForeignKey} onClick={openEditForeignKeyModal}>修改</Button>
|
||||
<Button size="small" icon={<DeleteOutlined />} danger disabled={!supportsForeignKeySchemaOps() || !selectedForeignKey} onClick={handleDeleteForeignKey}>删除</Button>
|
||||
@@ -2865,8 +2885,8 @@ END;`;
|
||||
key: 'triggers',
|
||||
label: '触发器',
|
||||
children: (
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, display: 'flex', gap: 8 }}>
|
||||
<div className={isV2Ui ? 'gn-v2-designer-tab-content' : undefined}>
|
||||
<div className={isV2Ui ? 'gn-v2-designer-actionbar' : undefined} style={{ marginBottom: 8, display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
@@ -2929,7 +2949,7 @@ END;`;
|
||||
label: 'DDL',
|
||||
icon: <FileTextOutlined />,
|
||||
children: (
|
||||
<div style={{ height: 'calc(100vh - 200px)', border: `1px solid ${panelFrameColor}`, borderRadius: panelRadius, background: panelBodyBg }}>
|
||||
<div className={isV2Ui ? 'gn-v2-designer-ddl-shell' : undefined} style={{ height: 'calc(100vh - 200px)', border: `1px solid ${panelFrameColor}`, borderRadius: panelRadius, background: panelBodyBg }}>
|
||||
<Editor
|
||||
height="100%"
|
||||
language="sql"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback, useDeferredValue } from 'react';
|
||||
import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal, Button } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined, AppstoreOutlined, UnorderedListOutlined, WarningOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App';
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
type TableOverviewSortOrder,
|
||||
} from '../utils/tableOverviewFilter';
|
||||
import { normalizeOceanBaseProtocol } from '../utils/oceanBaseProtocol';
|
||||
import { V2TableContextMenuView, type V2TableContextMenuActionKey } from './V2TableContextMenu';
|
||||
|
||||
interface TableOverviewProps {
|
||||
tab: TabData;
|
||||
@@ -170,16 +172,21 @@ const parseTableStats = (dialect: string, rows: Record<string, any>[]): TableSta
|
||||
const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
const connections = useStore(state => state.connections);
|
||||
const theme = useStore(state => state.theme);
|
||||
const appearance = useStore(state => state.appearance);
|
||||
const addTab = useStore(state => state.addTab);
|
||||
const setActiveContext = useStore(state => state.setActiveContext);
|
||||
const setAIPanelVisible = useStore(state => state.setAIPanelVisible);
|
||||
const addAIContext = useStore(state => state.addAIContext);
|
||||
const darkMode = theme === 'dark';
|
||||
const isV2Ui = appearance.uiVersion === 'v2';
|
||||
|
||||
const [tables, setTables] = useState<TableStatRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [sortField, setSortField] = useState<SortField>('name');
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(isV2Ui ? 'card' : 'list');
|
||||
const [openContextMenuTable, setOpenContextMenuTable] = useState<string | null>(null);
|
||||
const [visibleTableLimit, setVisibleTableLimit] = useState(TABLE_OVERVIEW_RENDER_BATCH_SIZE);
|
||||
const deferredSearchText = useDeferredValue(searchText);
|
||||
const isSearchPending = searchText !== deferredSearchText;
|
||||
@@ -268,6 +275,49 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
});
|
||||
}, [connection, tab.dbName, addTab, setActiveContext]);
|
||||
|
||||
const openTableDdl = useCallback((tableName: string) => {
|
||||
if (!connection) return;
|
||||
setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' });
|
||||
addTab({
|
||||
id: `design-${connection.id}-${tab.dbName}-${tableName}`,
|
||||
title: `表结构 (${tableName})`,
|
||||
type: 'design',
|
||||
connectionId: connection.id,
|
||||
dbName: tab.dbName,
|
||||
tableName,
|
||||
initialTab: 'ddl',
|
||||
readOnly: true,
|
||||
});
|
||||
}, [connection, tab.dbName, addTab, setActiveContext]);
|
||||
|
||||
const openQueryForTable = useCallback((tableName: string) => {
|
||||
if (!connection) return;
|
||||
setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' });
|
||||
addTab({
|
||||
id: `query-${Date.now()}`,
|
||||
title: '新建查询',
|
||||
type: 'query',
|
||||
connectionId: connection.id,
|
||||
dbName: tab.dbName,
|
||||
query: buildTableSelectQuery(metadataDialect, tableName),
|
||||
});
|
||||
}, [addTab, connection, metadataDialect, setActiveContext, tab.dbName]);
|
||||
|
||||
const openTableInER = useCallback((tableName: string) => {
|
||||
if (!connection) return;
|
||||
openTable(tableName);
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent('gonavi:data-grid:set-view-mode', {
|
||||
detail: {
|
||||
connectionId: connection.id,
|
||||
dbName: tab.dbName,
|
||||
tableName,
|
||||
viewMode: 'er',
|
||||
},
|
||||
}));
|
||||
}, 0);
|
||||
}, [connection, openTable, tab.dbName]);
|
||||
|
||||
const buildConfig = useCallback(() => {
|
||||
if (!connection) return null;
|
||||
return {
|
||||
@@ -319,6 +369,10 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
}
|
||||
}, [buildConfig, tab.dbName]);
|
||||
|
||||
const handleCopyTableAsInsert = useCallback(async (tableName: string) => {
|
||||
await handleExport(tableName, 'sql');
|
||||
}, [handleExport]);
|
||||
|
||||
const handleDeleteTable = useCallback((tableName: string) => {
|
||||
const config = buildConfig();
|
||||
if (!config) return;
|
||||
@@ -403,6 +457,60 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
});
|
||||
}, [buildConfig, tab.dbName, loadData]);
|
||||
|
||||
const openCreateStarRocksRollup = useCallback((tableName: string) => {
|
||||
if (!connection) return;
|
||||
const safeTable = String(tableName || 'table_name').trim();
|
||||
const quotedTable = safeTable.includes('`') ? safeTable : safeTable.split('.').map(part => `\`${part.replace(/`/g, '``')}\``).join('.');
|
||||
addTab({
|
||||
id: `query-create-starrocks-rollup-${Date.now()}`,
|
||||
title: '新增 Rollup',
|
||||
type: 'query',
|
||||
connectionId: connection.id,
|
||||
dbName: tab.dbName,
|
||||
query: `ALTER TABLE ${quotedTable}\nADD ROLLUP rollup_name (column1, column2);`,
|
||||
});
|
||||
}, [addTab, connection, tab.dbName]);
|
||||
|
||||
const injectTablePromptToAI = useCallback(async (tableName: string, promptKind: 'explain' | 'query') => {
|
||||
const dbName = tab.dbName || '';
|
||||
if (!connection?.id || !dbName || !tableName) {
|
||||
message.warning('当前表缺少连接上下文,无法发送给 AI');
|
||||
return;
|
||||
}
|
||||
|
||||
let ddl = '';
|
||||
const config = buildConfig();
|
||||
if (config) {
|
||||
try {
|
||||
const res = await DBShowCreateTable(buildRpcConnectionConfig(config) as any, dbName, tableName);
|
||||
if (res.success) {
|
||||
ddl = String(res.data || '').trim();
|
||||
addAIContext(connection.id, { dbName, tableName, ddl });
|
||||
}
|
||||
} catch {
|
||||
// AI 入口仍可基于表名工作,DDL 获取失败不阻断打开面板。
|
||||
}
|
||||
}
|
||||
|
||||
const prompt = promptKind === 'explain'
|
||||
? [
|
||||
`请解释数据表 ${dbName}.${tableName} 的结构和业务含义。`,
|
||||
'重点说明字段含义、主键/索引、潜在关联关系、典型查询场景和风险点。',
|
||||
ddl ? `\n\`\`\`sql\n${ddl}\n\`\`\`` : '',
|
||||
].filter(Boolean).join('\n')
|
||||
: [
|
||||
`请基于数据表 ${dbName}.${tableName} 生成 3 条常用查询 SQL。`,
|
||||
'要求包含:数据预览查询、按关键字段过滤查询、一个聚合或统计查询。',
|
||||
ddl ? `\n\`\`\`sql\n${ddl}\n\`\`\`` : '',
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
const wasClosed = !useStore.getState().aiPanelVisible;
|
||||
if (wasClosed) setAIPanelVisible(true);
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } }));
|
||||
}, wasClosed ? 350 : 0);
|
||||
}, [addAIContext, buildConfig, connection?.id, setAIPanelVisible, tab.dbName]);
|
||||
|
||||
// --- Theme ---
|
||||
const cardBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)';
|
||||
const cardHoverBg = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)';
|
||||
@@ -435,22 +543,157 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
}, 0), [sortedFiltered]);
|
||||
const allowTruncate = supportsTableTruncateAction(connection?.config?.type || '', connection?.config?.driver);
|
||||
|
||||
const handleV2TableContextMenuAction = useCallback((table: TableStatRow, action: V2TableContextMenuActionKey) => {
|
||||
const tableName = table.name;
|
||||
switch (action) {
|
||||
case 'open-data':
|
||||
case 'open-new-tab':
|
||||
openTable(tableName);
|
||||
return;
|
||||
case 'design-table':
|
||||
openDesign(tableName);
|
||||
return;
|
||||
case 'new-query':
|
||||
openQueryForTable(tableName);
|
||||
return;
|
||||
case 'view-ddl':
|
||||
openTableDdl(tableName);
|
||||
return;
|
||||
case 'view-er':
|
||||
openTableInER(tableName);
|
||||
return;
|
||||
case 'copy-table-name':
|
||||
void handleCopyTableName(tableName);
|
||||
return;
|
||||
case 'copy-structure':
|
||||
void handleCopyStructure(tableName);
|
||||
return;
|
||||
case 'copy-insert':
|
||||
void handleCopyTableAsInsert(tableName);
|
||||
return;
|
||||
case 'rename-table':
|
||||
handleRenameTable(tableName);
|
||||
return;
|
||||
case 'new-rollup':
|
||||
openCreateStarRocksRollup(tableName);
|
||||
return;
|
||||
case 'backup-table':
|
||||
void handleExport(tableName, 'sql');
|
||||
return;
|
||||
case 'refresh-stats':
|
||||
void loadData();
|
||||
return;
|
||||
case 'export-xlsx':
|
||||
void handleExport(tableName, 'xlsx');
|
||||
return;
|
||||
case 'export-csv':
|
||||
void handleExport(tableName, 'csv');
|
||||
return;
|
||||
case 'export-json':
|
||||
void handleExport(tableName, 'json');
|
||||
return;
|
||||
case 'ai-explain':
|
||||
void injectTablePromptToAI(tableName, 'explain');
|
||||
return;
|
||||
case 'ai-generate-query':
|
||||
void injectTablePromptToAI(tableName, 'query');
|
||||
return;
|
||||
case 'truncate-table':
|
||||
void handleTableDataDangerAction(tableName, 'truncate');
|
||||
return;
|
||||
case 'drop-table':
|
||||
handleDeleteTable(tableName);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}, [
|
||||
handleCopyStructure,
|
||||
handleCopyTableAsInsert,
|
||||
handleCopyTableName,
|
||||
handleDeleteTable,
|
||||
handleExport,
|
||||
handleRenameTable,
|
||||
handleTableDataDangerAction,
|
||||
injectTablePromptToAI,
|
||||
loadData,
|
||||
openCreateStarRocksRollup,
|
||||
openDesign,
|
||||
openQueryForTable,
|
||||
openTable,
|
||||
openTableDdl,
|
||||
openTableInER,
|
||||
]);
|
||||
|
||||
const renderV2OverviewTableContextMenu = useCallback((table: TableStatRow) => (
|
||||
<V2TableContextMenuView
|
||||
tableName={table.name}
|
||||
stats={{
|
||||
rowCount: table.rows,
|
||||
dataLength: table.dataSize,
|
||||
indexLength: table.indexSize,
|
||||
engine: table.engine,
|
||||
}}
|
||||
supportsTruncate={allowTruncate}
|
||||
supportsStarRocksRollup={metadataDialect === 'starrocks'}
|
||||
onAction={(action) => {
|
||||
setOpenContextMenuTable(null);
|
||||
handleV2TableContextMenuAction(table, action);
|
||||
}}
|
||||
/>
|
||||
), [allowTruncate, handleV2TableContextMenuAction, metadataDialect]);
|
||||
|
||||
const buildLegacyTableContextMenuItems = useCallback((table: TableStatRow): MenuProps['items'] => [
|
||||
{ key: 'new-query', label: '新建查询', icon: <ConsoleSqlOutlined />, onClick: () => openQueryForTable(table.name) },
|
||||
{ type: 'divider' },
|
||||
{ key: 'design-table', label: '设计表', icon: <EditOutlined />, onClick: () => openDesign(table.name) },
|
||||
{ key: 'copy-table-name', label: '复制表名', icon: <CopyOutlined />, onClick: () => handleCopyTableName(table.name) },
|
||||
{ key: 'copy-structure', label: '复制表结构', icon: <CopyOutlined />, onClick: () => handleCopyStructure(table.name) },
|
||||
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(table.name, 'sql') },
|
||||
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(table.name) },
|
||||
{ key: 'danger-zone', label: '危险操作', icon: <WarningOutlined />, children: [
|
||||
...(allowTruncate ? [{ key: 'truncate-table', label: '截断表', danger: true, onClick: () => handleTableDataDangerAction(table.name, 'truncate') }] : []),
|
||||
{ key: 'clear-table', label: '清空表', danger: true, onClick: () => handleTableDataDangerAction(table.name, 'clear') },
|
||||
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(table.name) },
|
||||
]},
|
||||
{ type: 'divider' },
|
||||
{ key: 'export', label: '导出表数据', icon: <ExportOutlined />, children: [
|
||||
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(table.name, 'csv') },
|
||||
{ key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(table.name, 'xlsx') },
|
||||
{ key: 'export-json', label: '导出 JSON', onClick: () => handleExport(table.name, 'json') },
|
||||
{ key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(table.name, 'md') },
|
||||
{ key: 'export-html', label: '导出 HTML', onClick: () => handleExport(table.name, 'html') },
|
||||
]},
|
||||
], [
|
||||
allowTruncate,
|
||||
handleCopyStructure,
|
||||
handleCopyTableName,
|
||||
handleDeleteTable,
|
||||
handleExport,
|
||||
handleRenameTable,
|
||||
handleTableDataDangerAction,
|
||||
openDesign,
|
||||
openQueryForTable,
|
||||
]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', background: containerBg }}>
|
||||
<div className={isV2Ui ? 'gn-v2-table-overview gn-v2-table-overview-loading' : undefined} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', background: containerBg }}>
|
||||
<Spin size="large" tip="加载表信息..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: containerBg, overflow: 'hidden' }}>
|
||||
<div className={isV2Ui ? 'gn-v2-table-overview' : undefined} style={{ display: 'flex', flexDirection: 'column', height: '100%', background: containerBg, overflow: 'hidden' }}>
|
||||
{/* Toolbar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px', flexShrink: 0 }}>
|
||||
<DatabaseOutlined style={{ fontSize: 16, color: accentColor }} />
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: textPrimary }}>{tab.dbName}</span>
|
||||
<span style={{ fontSize: 12, color: textMuted }}>
|
||||
{tables.length} 张表 · {formatRows(totalRows)} 行 · {formatSize(totalSize)}
|
||||
<div className={isV2Ui ? 'gn-v2-table-overview-header' : undefined} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px', flexShrink: 0 }}>
|
||||
<span className={isV2Ui ? 'gn-v2-table-overview-icon' : undefined}>
|
||||
<DatabaseOutlined style={{ fontSize: 16, color: isV2Ui ? undefined : accentColor }} />
|
||||
</span>
|
||||
<span className={isV2Ui ? 'gn-v2-table-overview-title' : undefined} style={{ fontSize: 14, fontWeight: 600, color: textPrimary }}>{tab.dbName}</span>
|
||||
<span className={isV2Ui ? 'gn-v2-table-overview-summary' : undefined} style={{ fontSize: 12, color: textMuted }}>
|
||||
<strong>{tables.length}</strong> 张表 · <strong>{formatRows(totalRows)}</strong> 行 · <strong>{formatSize(totalSize)}</strong>
|
||||
</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Input
|
||||
@@ -498,7 +741,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px 16px' }}>
|
||||
<div className={isV2Ui ? 'gn-v2-table-overview-content' : undefined} style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px 16px' }}>
|
||||
{sortedFiltered.length > 0 && (isSearchPending || visibleOverview.hiddenCount > 0 || deferredSearchText.trim()) && (
|
||||
<div
|
||||
style={{
|
||||
@@ -528,7 +771,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
<Empty description={searchText ? '无匹配结果' : '暂无表'} style={{ marginTop: 80 }} />
|
||||
) : viewMode === 'card' ? (
|
||||
/* ========== 卡片视图 ========== */
|
||||
<div style={{
|
||||
<div className={isV2Ui ? 'gn-v2-table-card-grid' : undefined} style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
|
||||
gap: 12,
|
||||
@@ -537,42 +780,15 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
<Dropdown
|
||||
key={t.name}
|
||||
trigger={['contextMenu']}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'new-query', label: '新建查询', icon: <ConsoleSqlOutlined />, onClick: () => {
|
||||
setActiveContext({ connectionId: tab.connectionId, dbName: tab.dbName || '' });
|
||||
addTab({
|
||||
id: `query-${Date.now()}`,
|
||||
title: '新建查询',
|
||||
type: 'query',
|
||||
connectionId: tab.connectionId,
|
||||
dbName: tab.dbName,
|
||||
query: buildTableSelectQuery(metadataDialect, t.name),
|
||||
});
|
||||
}},
|
||||
{ type: 'divider' },
|
||||
{ key: 'design-table', label: '设计表', icon: <EditOutlined />, onClick: () => openDesign(t.name) },
|
||||
{ key: 'copy-table-name', label: '复制表名', icon: <CopyOutlined />, onClick: () => handleCopyTableName(t.name) },
|
||||
{ key: 'copy-structure', label: '复制表结构', icon: <CopyOutlined />, onClick: () => handleCopyStructure(t.name) },
|
||||
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(t.name, 'sql') },
|
||||
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(t.name) },
|
||||
{ key: 'danger-zone', label: '危险操作', icon: <WarningOutlined />, children: [
|
||||
...(allowTruncate ? [{ key: 'truncate-table', label: '截断表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'truncate') }] : []),
|
||||
{ key: 'clear-table', label: '清空表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'clear') },
|
||||
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) }
|
||||
]},
|
||||
{ type: 'divider' },
|
||||
{ key: 'export', label: '导出表数据', icon: <ExportOutlined />, children: [
|
||||
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(t.name, 'csv') },
|
||||
{ key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(t.name, 'xlsx') },
|
||||
{ key: 'export-json', label: '导出 JSON', onClick: () => handleExport(t.name, 'json') },
|
||||
{ key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(t.name, 'md') },
|
||||
{ key: 'export-html', label: '导出 HTML', onClick: () => handleExport(t.name, 'html') },
|
||||
]},
|
||||
],
|
||||
}}
|
||||
menu={{ items: isV2Ui ? [] : buildLegacyTableContextMenuItems(t) }}
|
||||
open={isV2Ui ? openContextMenuTable === t.name : undefined}
|
||||
onOpenChange={isV2Ui ? (open) => setOpenContextMenuTable(open ? t.name : null) : undefined}
|
||||
popupRender={isV2Ui ? () => renderV2OverviewTableContextMenu(t) : undefined}
|
||||
rootClassName={isV2Ui ? 'gn-v2-table-context-menu-popup' : undefined}
|
||||
overlayStyle={isV2Ui ? { width: 264, maxWidth: 'calc(100vw - 24px)' } : undefined}
|
||||
>
|
||||
<div
|
||||
className={isV2Ui ? 'gn-v2-table-card' : undefined}
|
||||
onDoubleClick={() => openTable(t.name)}
|
||||
style={{
|
||||
background: cardBg,
|
||||
@@ -580,13 +796,13 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
borderRadius: 10,
|
||||
padding: '14px 16px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
transition: isV2Ui ? undefined : 'all 0.15s ease',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }}
|
||||
onMouseEnter={isV2Ui ? undefined : e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }}
|
||||
onMouseLeave={isV2Ui ? undefined : e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<div className={isV2Ui ? 'gn-v2-table-card-name' : undefined} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<TableOutlined style={{ fontSize: 14, color: accentColor }} />
|
||||
<Tooltip title={t.name} mouseEnterDelay={0.4}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: textPrimary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, display: 'block' }}>
|
||||
@@ -601,18 +817,23 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: textMuted }}>
|
||||
<div className={isV2Ui ? 'gn-v2-table-card-meta' : undefined} style={{ display: 'flex', gap: 16, fontSize: 12, color: textMuted }}>
|
||||
<span title="行数" style={{ minWidth: 52 }}>📊 {formatRows(t.rows)}</span>
|
||||
<span title="数据大小" style={{ minWidth: 72 }}>💾 {formatSize(t.dataSize)}</span>
|
||||
{t.engine && <span title="引擎" style={{ marginLeft: 'auto', opacity: 0.7 }}>{t.engine}</span>}
|
||||
</div>
|
||||
{isV2Ui && (
|
||||
<div className="gn-v2-table-size-bar">
|
||||
<span style={{ width: `${Math.min(100, Math.max(4, maxCombinedSize > 0 ? Math.round(((t.dataSize + t.indexSize) / maxCombinedSize) * 100) : 4))}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dropdown>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* ========== 行视图 ========== */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div className={isV2Ui ? 'gn-v2-table-row-list' : undefined} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{visibleTables.map(t => {
|
||||
const combinedSize = t.dataSize + t.indexSize;
|
||||
const sizeRatio = maxCombinedSize > 0 ? combinedSize / maxCombinedSize : 0;
|
||||
@@ -624,42 +845,15 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
<Dropdown
|
||||
key={t.name}
|
||||
trigger={['contextMenu']}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'new-query', label: '新建查询', icon: <ConsoleSqlOutlined />, onClick: () => {
|
||||
setActiveContext({ connectionId: tab.connectionId, dbName: tab.dbName || '' });
|
||||
addTab({
|
||||
id: `query-${Date.now()}`,
|
||||
title: '新建查询',
|
||||
type: 'query',
|
||||
connectionId: tab.connectionId,
|
||||
dbName: tab.dbName,
|
||||
query: buildTableSelectQuery(metadataDialect, t.name),
|
||||
});
|
||||
}},
|
||||
{ type: 'divider' },
|
||||
{ key: 'design-table', label: '设计表', icon: <EditOutlined />, onClick: () => openDesign(t.name) },
|
||||
{ key: 'copy-table-name', label: '复制表名', icon: <CopyOutlined />, onClick: () => handleCopyTableName(t.name) },
|
||||
{ key: 'copy-structure', label: '复制表结构', icon: <CopyOutlined />, onClick: () => handleCopyStructure(t.name) },
|
||||
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(t.name, 'sql') },
|
||||
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(t.name) },
|
||||
{ key: 'danger-zone', label: '危险操作', icon: <WarningOutlined />, children: [
|
||||
...(allowTruncate ? [{ key: 'truncate-table', label: '截断表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'truncate') }] : []),
|
||||
{ key: 'clear-table', label: '清空表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'clear') },
|
||||
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) }
|
||||
]},
|
||||
{ type: 'divider' },
|
||||
{ key: 'export', label: '导出表数据', icon: <ExportOutlined />, children: [
|
||||
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(t.name, 'csv') },
|
||||
{ key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(t.name, 'xlsx') },
|
||||
{ key: 'export-json', label: '导出 JSON', onClick: () => handleExport(t.name, 'json') },
|
||||
{ key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(t.name, 'md') },
|
||||
{ key: 'export-html', label: '导出 HTML', onClick: () => handleExport(t.name, 'html') },
|
||||
]},
|
||||
],
|
||||
}}
|
||||
menu={{ items: isV2Ui ? [] : buildLegacyTableContextMenuItems(t) }}
|
||||
open={isV2Ui ? openContextMenuTable === t.name : undefined}
|
||||
onOpenChange={isV2Ui ? (open) => setOpenContextMenuTable(open ? t.name : null) : undefined}
|
||||
popupRender={isV2Ui ? () => renderV2OverviewTableContextMenu(t) : undefined}
|
||||
rootClassName={isV2Ui ? 'gn-v2-table-context-menu-popup' : undefined}
|
||||
overlayStyle={isV2Ui ? { width: 264, maxWidth: 'calc(100vw - 24px)' } : undefined}
|
||||
>
|
||||
<div
|
||||
className={isV2Ui ? 'gn-v2-table-row' : undefined}
|
||||
onDoubleClick={() => openTable(t.name)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
@@ -668,11 +862,11 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
border: `1px solid ${cardBorder}`,
|
||||
background: cardBg,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
transition: isV2Ui ? undefined : 'all 0.15s ease',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }}
|
||||
onMouseEnter={isV2Ui ? undefined : e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }}
|
||||
onMouseLeave={isV2Ui ? undefined : e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
|
||||
606
frontend/src/components/V2TableContextMenu.tsx
Normal file
606
frontend/src/components/V2TableContextMenu.tsx
Normal file
@@ -0,0 +1,606 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
CodeOutlined,
|
||||
ConsoleSqlOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
DisconnectOutlined,
|
||||
EditOutlined,
|
||||
ExportOutlined,
|
||||
FileAddOutlined,
|
||||
FolderOpenOutlined,
|
||||
FolderOutlined,
|
||||
SaveOutlined,
|
||||
LinkOutlined,
|
||||
ReloadOutlined,
|
||||
TableOutlined,
|
||||
ThunderboltOutlined,
|
||||
DatabaseOutlined,
|
||||
CheckSquareOutlined,
|
||||
CloudOutlined,
|
||||
ClearOutlined,
|
||||
DashboardOutlined,
|
||||
FileTextOutlined,
|
||||
HddOutlined,
|
||||
PushpinOutlined,
|
||||
VerticalAlignBottomOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
export type V2TableContextMenuActionKey =
|
||||
| 'pin-table'
|
||||
| 'unpin-table'
|
||||
| 'open-data'
|
||||
| 'design-table'
|
||||
| 'open-new-tab'
|
||||
| 'new-query'
|
||||
| 'view-ddl'
|
||||
| 'view-er'
|
||||
| 'copy-table-name'
|
||||
| 'copy-structure'
|
||||
| 'copy-insert'
|
||||
| 'rename-table'
|
||||
| 'new-rollup'
|
||||
| 'backup-table'
|
||||
| 'refresh-stats'
|
||||
| 'export-xlsx'
|
||||
| 'export-csv'
|
||||
| 'export-json'
|
||||
| 'ai-explain'
|
||||
| 'ai-generate-query'
|
||||
| 'truncate-table'
|
||||
| 'drop-table';
|
||||
|
||||
export type V2TableContextMenuStats = {
|
||||
rowCount?: number;
|
||||
dataLength?: number;
|
||||
indexLength?: number;
|
||||
engine?: string;
|
||||
loading?: boolean;
|
||||
unavailable?: boolean;
|
||||
};
|
||||
|
||||
type V2TableContextMenuItemConfig = {
|
||||
action: string;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
kbd?: string;
|
||||
featured?: boolean;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
tone?: 'default' | 'ai' | 'danger';
|
||||
};
|
||||
|
||||
export const formatV2TableContextMenuRows = (count?: number): string => {
|
||||
if (count === undefined || count === null || !Number.isFinite(count) || count < 0) return '— 行';
|
||||
return `${Math.round(count).toLocaleString()} 行`;
|
||||
};
|
||||
|
||||
export const formatV2TableContextMenuSize = (bytes?: number): string => {
|
||||
if (bytes === undefined || bytes === null || !Number.isFinite(bytes) || bytes < 0) return '—';
|
||||
if (bytes < 1024) return `${Math.round(bytes)} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
};
|
||||
|
||||
const resolveV2TableContextMenuMeta = (stats?: V2TableContextMenuStats): string => {
|
||||
if (!stats) return '点击刷新统计信息读取';
|
||||
if (stats?.loading) return '正在读取统计信息…';
|
||||
if (stats?.unavailable) return '统计信息不可用';
|
||||
return `${formatV2TableContextMenuRows(stats?.rowCount)} · ${formatV2TableContextMenuSize(stats?.dataLength)} 数据 · ${formatV2TableContextMenuSize(stats?.indexLength)} 索引`;
|
||||
};
|
||||
|
||||
const V2TableContextMenuItem: React.FC<{
|
||||
item: V2TableContextMenuItemConfig;
|
||||
onAction?: (action: string) => void;
|
||||
}> = ({ item, onAction }) => (
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
'gn-v2-context-menu-item',
|
||||
item.featured ? 'is-featured' : '',
|
||||
item.selected ? 'is-selected' : '',
|
||||
item.tone === 'ai' ? 'is-ai' : '',
|
||||
item.tone === 'danger' ? 'is-danger' : '',
|
||||
item.tone === 'default' ? 'is-default' : '',
|
||||
item.disabled ? 'is-disabled' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
role="menuitem"
|
||||
disabled={item.disabled}
|
||||
aria-disabled={item.disabled || undefined}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (item.disabled) return;
|
||||
onAction?.(item.action);
|
||||
}}
|
||||
>
|
||||
<span className="gn-v2-context-menu-item-icon">{item.icon}</span>
|
||||
<span className="gn-v2-context-menu-item-title">{item.title}</span>
|
||||
{item.kbd && <span className="gn-v2-context-menu-kbd">{item.kbd}</span>}
|
||||
</button>
|
||||
);
|
||||
|
||||
const V2ContextMenuHeader: React.FC<{
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
meta: string;
|
||||
pill?: string;
|
||||
}> = ({ icon, title, meta, pill }) => (
|
||||
<div className="gn-v2-context-menu-header">
|
||||
<span className="gn-v2-context-menu-table-icon">{icon}</span>
|
||||
<span className="gn-v2-context-menu-heading">
|
||||
<strong title={title}>{title}</strong>
|
||||
<small>{meta}</small>
|
||||
</span>
|
||||
{pill && (
|
||||
<span className="gn-v2-context-menu-engine-pill">{pill}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderV2ContextMenuItems = (
|
||||
items: V2TableContextMenuItemConfig[],
|
||||
onAction?: (action: string) => void,
|
||||
) => items.map((item) => (
|
||||
<V2TableContextMenuItem key={item.action} item={item} onAction={onAction} />
|
||||
));
|
||||
|
||||
export const V2TableContextMenuView: React.FC<{
|
||||
tableName: string;
|
||||
stats?: V2TableContextMenuStats;
|
||||
isPinned?: boolean;
|
||||
supportsTruncate?: boolean;
|
||||
supportsStarRocksRollup?: boolean;
|
||||
onAction?: (action: V2TableContextMenuActionKey) => void;
|
||||
}> = ({
|
||||
tableName,
|
||||
stats,
|
||||
isPinned = false,
|
||||
supportsTruncate = true,
|
||||
supportsStarRocksRollup = false,
|
||||
onAction,
|
||||
}) => {
|
||||
const renderItems = (items: V2TableContextMenuItemConfig[]) => renderV2ContextMenuItems(
|
||||
items,
|
||||
onAction as (action: string) => void,
|
||||
);
|
||||
|
||||
const maintenanceItems: V2TableContextMenuItemConfig[] = [
|
||||
{ action: 'rename-table', icon: <EditOutlined />, title: '重命名…', kbd: 'F2' },
|
||||
...(supportsStarRocksRollup ? [{ action: 'new-rollup' as const, icon: <ThunderboltOutlined />, title: '新增 Rollup' }] : []),
|
||||
{ action: 'backup-table', icon: <ExportOutlined />, title: '备份 · SQL Dump' },
|
||||
{ action: 'refresh-stats', icon: <ReloadOutlined />, title: '刷新统计信息' },
|
||||
];
|
||||
|
||||
const dangerItems: V2TableContextMenuItemConfig[] = [
|
||||
...(supportsTruncate ? [{ action: 'truncate-table' as const, icon: <DeleteOutlined />, title: '截断表 · TRUNCATE', tone: 'danger' as const }] : []),
|
||||
{ action: 'drop-table', icon: <DeleteOutlined />, title: '删除表 · DROP', kbd: '⌫', tone: 'danger' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="gn-v2-table-context-menu" data-v2-table-context-menu="true" role="menu">
|
||||
<V2ContextMenuHeader
|
||||
icon={<TableOutlined />}
|
||||
title={tableName}
|
||||
meta={resolveV2TableContextMenuMeta(stats)}
|
||||
pill={(stats?.engine || stats?.loading) ? (stats?.loading ? '...' : stats?.engine) : undefined}
|
||||
/>
|
||||
|
||||
<div className="gn-v2-context-menu-body">
|
||||
{renderItems([
|
||||
{ action: 'open-data', icon: <TableOutlined />, title: '查看数据', kbd: '↵', featured: true },
|
||||
{ action: isPinned ? 'unpin-table' : 'pin-table', icon: <PushpinOutlined />, title: isPinned ? '取消置顶' : '置顶表', kbd: isPinned ? '已置顶' : undefined, selected: isPinned },
|
||||
{ action: 'design-table', icon: <EditOutlined />, title: '设计表 · 字段 / 索引 / 外键', kbd: '⌘D' },
|
||||
{ action: 'open-new-tab', icon: <FileAddOutlined />, title: '在新标签打开', kbd: '⌘↵' },
|
||||
{ action: 'new-query', icon: <ConsoleSqlOutlined />, title: '新建查询' },
|
||||
])}
|
||||
|
||||
<div className="gn-v2-context-menu-section-title">元信息</div>
|
||||
{renderItems([
|
||||
{ action: 'view-ddl', icon: <CodeOutlined />, title: '查看 DDL · CREATE TABLE' },
|
||||
{ action: 'view-er', icon: <LinkOutlined />, title: '在 ER 图中查看' },
|
||||
])}
|
||||
|
||||
<div className="gn-v2-context-menu-section-title">复制</div>
|
||||
{renderItems([
|
||||
{ action: 'copy-table-name', icon: <CopyOutlined />, title: '复制表名', kbd: '⌘C' },
|
||||
{ action: 'copy-structure', icon: <CopyOutlined />, title: '复制表结构 · DDL' },
|
||||
{ action: 'copy-insert', icon: <CopyOutlined />, title: '复制全表为 INSERT' },
|
||||
])}
|
||||
|
||||
<div className="gn-v2-context-menu-section-title">维护</div>
|
||||
{renderItems(maintenanceItems)}
|
||||
|
||||
<div className="gn-v2-context-menu-section-title">导出表数据</div>
|
||||
{renderItems([
|
||||
{ action: 'export-xlsx', icon: <ExportOutlined />, title: 'Excel · .xlsx' },
|
||||
{ action: 'export-csv', icon: <ExportOutlined />, title: 'CSV · .csv' },
|
||||
{ action: 'export-json', icon: <ExportOutlined />, title: 'JSON · .json' },
|
||||
])}
|
||||
|
||||
<div className="gn-v2-context-menu-divider" />
|
||||
{renderItems([
|
||||
{ action: 'ai-explain', icon: <ThunderboltOutlined />, title: '用 AI 解释这张表', tone: 'ai', featured: true },
|
||||
{ action: 'ai-generate-query', icon: <ConsoleSqlOutlined />, title: '用 AI 生成查询', tone: 'ai' },
|
||||
])}
|
||||
|
||||
<div className="gn-v2-context-menu-divider" />
|
||||
{renderItems(dangerItems)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type V2TableGroupContextMenuActionKey =
|
||||
| 'new-table'
|
||||
| 'sort-by-name'
|
||||
| 'sort-by-frequency';
|
||||
|
||||
export const V2TableGroupContextMenuView: React.FC<{
|
||||
title?: string;
|
||||
dbName?: string;
|
||||
count?: number;
|
||||
currentSort?: 'name' | 'frequency';
|
||||
onAction?: (action: V2TableGroupContextMenuActionKey) => void;
|
||||
}> = ({
|
||||
title = '表 · tables',
|
||||
dbName,
|
||||
count,
|
||||
currentSort = 'name',
|
||||
onAction,
|
||||
}) => {
|
||||
const sortLabel = currentSort === 'frequency' ? '使用频率' : '名称';
|
||||
const renderItems = (items: V2TableContextMenuItemConfig[]) => renderV2ContextMenuItems(
|
||||
items,
|
||||
onAction as (action: string) => void,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="gn-v2-table-context-menu gn-v2-group-context-menu" data-v2-table-group-context-menu="true" role="menu">
|
||||
<V2ContextMenuHeader
|
||||
icon={<TableOutlined />}
|
||||
title={title}
|
||||
meta={`${dbName || '当前数据库'} · ${count ?? 0} 张表 · 当前按${sortLabel}排序`}
|
||||
pill="GROUP"
|
||||
/>
|
||||
|
||||
<div className="gn-v2-context-menu-body">
|
||||
{renderItems([
|
||||
{ action: 'new-table', icon: <TableOutlined />, title: '新建表', kbd: '⌘N', featured: true },
|
||||
])}
|
||||
|
||||
<div className="gn-v2-context-menu-section-title">排序</div>
|
||||
{renderItems([
|
||||
{ action: 'sort-by-name', icon: currentSort === 'name' ? <CheckSquareOutlined /> : <ReloadOutlined />, title: '按名称排序', kbd: currentSort === 'name' ? '当前' : undefined, selected: currentSort === 'name' },
|
||||
{ action: 'sort-by-frequency', icon: currentSort === 'frequency' ? <CheckSquareOutlined /> : <ReloadOutlined />, title: '按使用频率排序', kbd: currentSort === 'frequency' ? '当前' : undefined, selected: currentSort === 'frequency' },
|
||||
])}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type V2DatabaseContextMenuActionKey =
|
||||
| 'new-table'
|
||||
| 'new-materialized-view'
|
||||
| 'new-external-catalog'
|
||||
| 'rename-db'
|
||||
| 'refresh'
|
||||
| 'export-db-schema'
|
||||
| 'backup-db-sql'
|
||||
| 'disconnect-db'
|
||||
| 'new-query'
|
||||
| 'run-sql'
|
||||
| 'drop-db';
|
||||
|
||||
export const V2DatabaseContextMenuView: React.FC<{
|
||||
dbName: string;
|
||||
dialect?: string;
|
||||
supportsStarRocksActions?: boolean;
|
||||
onAction?: (action: V2DatabaseContextMenuActionKey) => void;
|
||||
}> = ({
|
||||
dbName,
|
||||
dialect,
|
||||
supportsStarRocksActions = false,
|
||||
onAction,
|
||||
}) => {
|
||||
const renderItems = (items: V2TableContextMenuItemConfig[]) => renderV2ContextMenuItems(
|
||||
items,
|
||||
onAction as (action: string) => void,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="gn-v2-table-context-menu gn-v2-database-context-menu" data-v2-database-context-menu="true" role="menu">
|
||||
<V2ContextMenuHeader
|
||||
icon={<DatabaseOutlined />}
|
||||
title={dbName}
|
||||
meta={`${dialect || 'database'} · 数据库操作`}
|
||||
pill="DB"
|
||||
/>
|
||||
|
||||
<div className="gn-v2-context-menu-body">
|
||||
{renderItems([
|
||||
{ action: 'new-table', icon: <TableOutlined />, title: '新建表', kbd: '⌘N', featured: true },
|
||||
{ action: 'new-query', icon: <ConsoleSqlOutlined />, title: '新建查询' },
|
||||
{ action: 'run-sql', icon: <FileAddOutlined />, title: '运行外部 SQL 文件' },
|
||||
])}
|
||||
|
||||
{supportsStarRocksActions && (
|
||||
<>
|
||||
<div className="gn-v2-context-menu-section-title">StarRocks</div>
|
||||
{renderItems([
|
||||
{ action: 'new-materialized-view', icon: <ThunderboltOutlined />, title: '新建物化视图' },
|
||||
{ action: 'new-external-catalog', icon: <CloudOutlined />, title: '新建外部 Catalog' },
|
||||
])}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="gn-v2-context-menu-section-title">维护</div>
|
||||
{renderItems([
|
||||
{ action: 'rename-db', icon: <EditOutlined />, title: '重命名数据库', kbd: 'F2' },
|
||||
{ action: 'refresh', icon: <ReloadOutlined />, title: '刷新对象树' },
|
||||
{ action: 'disconnect-db', icon: <DisconnectOutlined />, title: '关闭数据库' },
|
||||
])}
|
||||
|
||||
<div className="gn-v2-context-menu-section-title">导出与备份</div>
|
||||
{renderItems([
|
||||
{ action: 'export-db-schema', icon: <ExportOutlined />, title: '导出全部表结构 · SQL' },
|
||||
{ action: 'backup-db-sql', icon: <SaveOutlined />, title: '备份全部表 · 结构 + 数据' },
|
||||
])}
|
||||
|
||||
<div className="gn-v2-context-menu-divider" />
|
||||
{renderItems([
|
||||
{ action: 'drop-db', icon: <DeleteOutlined />, title: '删除数据库 · DROP', tone: 'danger', kbd: '⌫' },
|
||||
])}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type V2ConnectionContextMenuActionKey =
|
||||
| 'new-db'
|
||||
| 'refresh'
|
||||
| 'new-query'
|
||||
| 'open-sql-file'
|
||||
| 'new-command'
|
||||
| 'open-monitor'
|
||||
| 'edit'
|
||||
| 'copy-connection'
|
||||
| 'disconnect'
|
||||
| 'delete'
|
||||
| 'move-to-ungrouped'
|
||||
| `move-to-tag:${string}`;
|
||||
|
||||
export type V2ConnectionContextMenuTagItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
selected?: boolean;
|
||||
};
|
||||
|
||||
export type V2ConnectionGroupContextMenuActionKey =
|
||||
| 'edit-group'
|
||||
| 'delete-group';
|
||||
|
||||
export const V2ConnectionGroupContextMenuView: React.FC<{
|
||||
groupName: string;
|
||||
count?: number;
|
||||
onAction?: (action: V2ConnectionGroupContextMenuActionKey) => void;
|
||||
}> = ({
|
||||
groupName,
|
||||
count = 0,
|
||||
onAction,
|
||||
}) => {
|
||||
const renderItems = (items: V2TableContextMenuItemConfig[]) => renderV2ContextMenuItems(
|
||||
items,
|
||||
onAction as (action: string) => void,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="gn-v2-table-context-menu gn-v2-connection-group-context-menu" data-v2-connection-group-context-menu="true" role="menu">
|
||||
<V2ContextMenuHeader
|
||||
icon={<FolderOpenOutlined />}
|
||||
title={groupName || '未命名分组'}
|
||||
meta={`${count.toLocaleString()} 个连接 · 连接分组`}
|
||||
pill="GROUP"
|
||||
/>
|
||||
|
||||
<div className="gn-v2-context-menu-body">
|
||||
{renderItems([
|
||||
{ action: 'edit-group', icon: <EditOutlined />, title: '编辑分组', kbd: 'F2', featured: true },
|
||||
])}
|
||||
<div className="gn-v2-context-menu-divider" />
|
||||
{renderItems([
|
||||
{ action: 'delete-group', icon: <DeleteOutlined />, title: '删除分组', tone: 'danger', kbd: '⌫' },
|
||||
])}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const V2ConnectionContextMenuView: React.FC<{
|
||||
connectionName: string;
|
||||
hostSummary?: string;
|
||||
driverLabel?: string;
|
||||
isRedis?: boolean;
|
||||
tags?: V2ConnectionContextMenuTagItem[];
|
||||
onAction?: (action: V2ConnectionContextMenuActionKey) => void;
|
||||
}> = ({
|
||||
connectionName,
|
||||
hostSummary,
|
||||
driverLabel,
|
||||
isRedis = false,
|
||||
tags = [],
|
||||
onAction,
|
||||
}) => {
|
||||
const renderItems = (items: V2TableContextMenuItemConfig[]) => renderV2ContextMenuItems(
|
||||
items,
|
||||
onAction as (action: string) => void,
|
||||
);
|
||||
const hasSelectedTag = tags.some((tag) => tag.selected);
|
||||
const meta = [
|
||||
driverLabel || (isRedis ? 'redis' : 'database'),
|
||||
hostSummary || '未配置地址',
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
return (
|
||||
<div className="gn-v2-table-context-menu gn-v2-connection-context-menu" data-v2-connection-context-menu="true" role="menu">
|
||||
<V2ContextMenuHeader
|
||||
icon={isRedis ? <HddOutlined /> : <CloudOutlined />}
|
||||
title={connectionName}
|
||||
meta={meta}
|
||||
pill="HOST"
|
||||
/>
|
||||
|
||||
<div className="gn-v2-context-menu-body">
|
||||
{isRedis ? renderItems([
|
||||
{ action: 'refresh', icon: <ReloadOutlined />, title: '刷新连接', kbd: '⌘R', featured: true },
|
||||
{ action: 'new-command', icon: <ConsoleSqlOutlined />, title: '新建命令窗口', featured: true },
|
||||
{ action: 'open-monitor', icon: <DashboardOutlined />, title: 'Redis 实例监控' },
|
||||
]) : renderItems([
|
||||
{ action: 'new-db', icon: <DatabaseOutlined />, title: '新建数据库', kbd: '⌘N', featured: true },
|
||||
{ action: 'refresh', icon: <ReloadOutlined />, title: '刷新连接', kbd: '⌘R' },
|
||||
{ action: 'new-query', icon: <ConsoleSqlOutlined />, title: '新建查询' },
|
||||
{ action: 'open-sql-file', icon: <FileAddOutlined />, title: '运行外部 SQL 文件' },
|
||||
])}
|
||||
|
||||
<div className="gn-v2-context-menu-section-title">连接</div>
|
||||
{renderItems([
|
||||
{ action: 'edit', icon: <EditOutlined />, title: '编辑连接', kbd: 'F2' },
|
||||
{ action: 'copy-connection', icon: <CopyOutlined />, title: '复制连接' },
|
||||
{ action: 'disconnect', icon: <DisconnectOutlined />, title: '断开连接' },
|
||||
])}
|
||||
|
||||
{tags.length > 0 && (
|
||||
<>
|
||||
<div className="gn-v2-context-menu-section-title">分组</div>
|
||||
{renderItems([
|
||||
...tags.map((tag): V2TableContextMenuItemConfig => ({
|
||||
action: `move-to-tag:${tag.id}`,
|
||||
icon: tag.selected ? <CheckSquareOutlined /> : <FolderOutlined />,
|
||||
title: tag.name,
|
||||
kbd: tag.selected ? '当前' : undefined,
|
||||
selected: tag.selected,
|
||||
})),
|
||||
{
|
||||
action: 'move-to-ungrouped',
|
||||
icon: hasSelectedTag ? <FolderOpenOutlined /> : <CheckSquareOutlined />,
|
||||
title: '移出分组',
|
||||
kbd: hasSelectedTag ? undefined : '当前',
|
||||
selected: !hasSelectedTag,
|
||||
},
|
||||
])}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="gn-v2-context-menu-divider" />
|
||||
{renderItems([
|
||||
{ action: 'delete', icon: <DeleteOutlined />, title: '删除连接', tone: 'danger', kbd: '⌫' },
|
||||
])}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type V2CellContextMenuActionKey =
|
||||
| 'copy-field-name'
|
||||
| 'set-null'
|
||||
| 'edit-row'
|
||||
| 'fill-selected'
|
||||
| 'paste-copied-columns'
|
||||
| 'copy-insert'
|
||||
| 'copy-update'
|
||||
| 'copy-delete'
|
||||
| 'copy-json'
|
||||
| 'copy-csv'
|
||||
| 'copy-markdown'
|
||||
| 'export-csv'
|
||||
| 'export-xlsx'
|
||||
| 'export-json'
|
||||
| 'export-html';
|
||||
|
||||
export const V2CellContextMenuView: React.FC<{
|
||||
fieldName: string;
|
||||
tableName?: string;
|
||||
rowLabel?: string;
|
||||
selectedRowCount?: number;
|
||||
canModifyData?: boolean;
|
||||
canPasteCopiedColumns?: boolean;
|
||||
supportsCopyInsert?: boolean;
|
||||
onAction?: (action: V2CellContextMenuActionKey) => void;
|
||||
}> = ({
|
||||
fieldName,
|
||||
tableName,
|
||||
rowLabel,
|
||||
selectedRowCount = 0,
|
||||
canModifyData = false,
|
||||
canPasteCopiedColumns = false,
|
||||
supportsCopyInsert = true,
|
||||
onAction,
|
||||
}) => {
|
||||
const renderItems = (items: V2TableContextMenuItemConfig[]) => renderV2ContextMenuItems(
|
||||
items,
|
||||
onAction as (action: string) => void,
|
||||
);
|
||||
const selectedCountLabel = Math.max(0, selectedRowCount).toLocaleString();
|
||||
const menuTitle = fieldName || '未命名字段';
|
||||
const meta = [tableName, rowLabel || '当前行'].filter(Boolean).join(' · ') || '当前单元格';
|
||||
|
||||
return (
|
||||
<div className="gn-v2-table-context-menu gn-v2-cell-context-menu" data-v2-cell-context-menu="true" role="menu">
|
||||
<V2ContextMenuHeader
|
||||
icon={<TableOutlined />}
|
||||
title={menuTitle}
|
||||
meta={meta}
|
||||
pill="CELL"
|
||||
/>
|
||||
|
||||
<div className="gn-v2-context-menu-body">
|
||||
{renderItems([
|
||||
{ action: 'copy-field-name', icon: <CopyOutlined />, title: '复制字段名称', kbd: '⌘C', featured: true },
|
||||
])}
|
||||
|
||||
{canModifyData && (
|
||||
<>
|
||||
<div className="gn-v2-context-menu-section-title">编辑</div>
|
||||
{renderItems([
|
||||
{ action: 'set-null', icon: <ClearOutlined />, title: '设置为 NULL' },
|
||||
{ action: 'edit-row', icon: <EditOutlined />, title: '编辑本行', kbd: '↵' },
|
||||
{
|
||||
action: 'fill-selected',
|
||||
icon: <VerticalAlignBottomOutlined />,
|
||||
title: `填充到选中行 (${selectedCountLabel})`,
|
||||
disabled: selectedRowCount <= 0,
|
||||
},
|
||||
{
|
||||
action: 'paste-copied-columns',
|
||||
icon: <VerticalAlignBottomOutlined />,
|
||||
title: '粘贴已复制列 · 同名列',
|
||||
disabled: !canPasteCopiedColumns,
|
||||
},
|
||||
])}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="gn-v2-context-menu-section-title">复制</div>
|
||||
{renderItems([
|
||||
...(supportsCopyInsert ? [
|
||||
{ action: 'copy-insert' as const, icon: <ConsoleSqlOutlined />, title: '复制为 INSERT', kbd: 'SQL' },
|
||||
{ action: 'copy-update' as const, icon: <ConsoleSqlOutlined />, title: '复制为 UPDATE' },
|
||||
{ action: 'copy-delete' as const, icon: <ConsoleSqlOutlined />, title: '复制为 DELETE' },
|
||||
] : []),
|
||||
{ action: 'copy-json', icon: <FileTextOutlined />, title: '复制为 JSON' },
|
||||
{ action: 'copy-csv', icon: <FileTextOutlined />, title: '复制为 CSV' },
|
||||
{ action: 'copy-markdown', icon: <CopyOutlined />, title: '复制为 Markdown' },
|
||||
])}
|
||||
|
||||
<div className="gn-v2-context-menu-section-title">导出</div>
|
||||
{renderItems([
|
||||
{ action: 'export-csv', icon: <ExportOutlined />, title: 'CSV · .csv' },
|
||||
{ action: 'export-xlsx', icon: <ExportOutlined />, title: 'Excel · .xlsx' },
|
||||
{ action: 'export-json', icon: <ExportOutlined />, title: 'JSON · .json' },
|
||||
{ action: 'export-html', icon: <ExportOutlined />, title: 'HTML · .html' },
|
||||
])}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { HistoryOutlined, RobotOutlined, ClearOutlined, SettingOutlined, CloseOutlined, ExportOutlined } from '@ant-design/icons';
|
||||
import { HistoryOutlined, RobotOutlined, ClearOutlined, SettingOutlined, CloseOutlined, ExportOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
import type { AIChatMessage } from '../../types';
|
||||
|
||||
@@ -9,12 +9,15 @@ interface AIChatHeaderProps {
|
||||
mutedColor: string;
|
||||
textColor: string;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
isV2Ui?: boolean;
|
||||
onHistoryClick: () => void;
|
||||
onClear: () => void;
|
||||
onSettingsClick: () => void;
|
||||
onClose: () => void;
|
||||
messages?: AIChatMessage[];
|
||||
sessionTitle?: string;
|
||||
activeMode?: 'chat' | 'insights' | 'history';
|
||||
onModeChange?: (mode: 'chat' | 'insights' | 'history') => void;
|
||||
}
|
||||
|
||||
const exportToMarkdown = (messages: AIChatMessage[], title: string) => {
|
||||
@@ -41,36 +44,108 @@ const exportToMarkdown = (messages: AIChatMessage[], title: string) => {
|
||||
|
||||
export const AIChatHeader: React.FC<AIChatHeaderProps> = ({
|
||||
darkMode, mutedColor, textColor, overlayTheme,
|
||||
isV2Ui = false,
|
||||
onHistoryClick, onClear, onSettingsClick, onClose,
|
||||
messages = [], sessionTitle = '新对话'
|
||||
messages = [], sessionTitle = '新对话',
|
||||
activeMode = 'chat',
|
||||
onModeChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="ai-chat-header" style={{ borderBottom: 'none', padding: '10px 16px', background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)' }}>
|
||||
<div className="ai-chat-header-left" style={{ gap: 8 }}>
|
||||
<Tooltip title="历史会话">
|
||||
<Button type="text" size="small" icon={<HistoryOutlined />} onClick={onHistoryClick} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
<div className="ai-logo" style={{ background: overlayTheme.iconBg, color: overlayTheme.iconColor, display: 'flex', alignItems: 'center', justifyContent: 'center', width: 20, height: 20, borderRadius: 6, fontSize: 12 }}>
|
||||
<RobotOutlined />
|
||||
</div>
|
||||
<span className="ai-title" style={{ color: textColor, fontSize: 13, fontWeight: 600 }}>GoNavi AI</span>
|
||||
</div>
|
||||
<div className="ai-chat-header-right">
|
||||
{messages.length > 0 && (
|
||||
<Tooltip title="导出为 Markdown">
|
||||
<Button type="text" size="small" icon={<ExportOutlined />} onClick={() => exportToMarkdown(messages, sessionTitle)} style={{ color: mutedColor }} />
|
||||
if (!isV2Ui) {
|
||||
return (
|
||||
<div className="ai-chat-header" style={{ borderBottom: 'none', padding: '10px 16px', background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)' }}>
|
||||
<div className="ai-chat-header-left" style={{ gap: 8 }}>
|
||||
<Tooltip title="历史会话">
|
||||
<Button type="text" size="small" icon={<HistoryOutlined />} onClick={onHistoryClick} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="新对话 (清空当前)">
|
||||
<Button type="text" size="small" icon={<ClearOutlined />} onClick={onClear} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="AI 设置">
|
||||
<Button type="text" size="small" icon={<SettingOutlined />} onClick={onSettingsClick} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="关闭面板">
|
||||
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
<div className="ai-logo" style={{ background: overlayTheme.iconBg, color: overlayTheme.iconColor, display: 'flex', alignItems: 'center', justifyContent: 'center', width: 20, height: 20, borderRadius: 6, fontSize: 12 }}>
|
||||
<RobotOutlined />
|
||||
</div>
|
||||
<span className="ai-title" style={{ color: textColor, fontSize: 13, fontWeight: 600 }}>GoNavi AI</span>
|
||||
</div>
|
||||
<div className="ai-chat-header-right">
|
||||
{messages.length > 0 && (
|
||||
<Tooltip title="导出为 Markdown">
|
||||
<Button type="text" size="small" icon={<ExportOutlined />} onClick={() => exportToMarkdown(messages, sessionTitle)} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="新对话 (清空当前)">
|
||||
<Button type="text" size="small" icon={<ClearOutlined />} onClick={onClear} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="AI 设置">
|
||||
<Button type="text" size="small" icon={<SettingOutlined />} onClick={onSettingsClick} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="关闭面板">
|
||||
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-chat-header gn-v2-ai-header" style={{ borderBottom: 'none', padding: '10px 16px', background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)' }}>
|
||||
<div className="gn-v2-ai-header-top">
|
||||
<div className="ai-chat-header-left gn-v2-ai-brand" style={{ gap: 8 }}>
|
||||
<div className="ai-logo" style={{ background: overlayTheme.iconBg, color: overlayTheme.iconColor, display: 'flex', alignItems: 'center', justifyContent: 'center', width: 20, height: 20, borderRadius: 6, fontSize: 12 }}>
|
||||
<RobotOutlined />
|
||||
</div>
|
||||
<div className="ai-title-stack">
|
||||
<span className="ai-title" style={{ color: textColor, fontSize: 13, fontWeight: 600 }}>GoNavi AI</span>
|
||||
<small>{sessionTitle} · 已连接</small>
|
||||
</div>
|
||||
<span className="gn-v2-ai-provider-badge">BETA</span>
|
||||
</div>
|
||||
<div className="ai-chat-header-right gn-v2-ai-header-actions">
|
||||
<Tooltip title="新对话">
|
||||
<Button type="text" size="small" icon={<PlusOutlined />} onClick={onClear} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="历史会话">
|
||||
<Button type="text" size="small" icon={<HistoryOutlined />} onClick={onHistoryClick} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="AI 设置">
|
||||
<Button type="text" size="small" icon={<SettingOutlined />} onClick={onSettingsClick} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="关闭面板">
|
||||
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gn-v2-ai-mode-tabs" aria-label="AI 工作模式">
|
||||
<button
|
||||
type="button"
|
||||
className={activeMode === 'chat' ? 'is-active' : undefined}
|
||||
onClick={() => onModeChange?.('chat')}
|
||||
>
|
||||
<RobotOutlined />
|
||||
<span>对话</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={activeMode === 'insights' ? 'is-active' : undefined}
|
||||
onClick={() => onModeChange?.('insights')}
|
||||
>
|
||||
<ThunderboltOutlined />
|
||||
<span>自动洞察</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={activeMode === 'history' ? 'is-active' : undefined}
|
||||
onClick={() => onModeChange?.('history')}
|
||||
>
|
||||
<HistoryOutlined />
|
||||
<span>历史</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{messages.length > 0 && (
|
||||
<div className="gn-v2-ai-session-row">
|
||||
<button type="button" className="gn-v2-ai-export-button" onClick={() => exportToMarkdown(messages, sessionTitle)}>
|
||||
<ExportOutlined />
|
||||
<span>导出</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,8 +17,38 @@ vi.mock('../../../wailsjs/go/app/App', () => ({
|
||||
DBGetTables: vi.fn(),
|
||||
DBShowCreateTable: vi.fn(),
|
||||
DBGetDatabases: vi.fn(),
|
||||
DBGetColumns: vi.fn(),
|
||||
}));
|
||||
|
||||
const renderAIChatInput = (overrides: Partial<React.ComponentProps<typeof AIChatInput>> = {}) => renderToStaticMarkup(
|
||||
<AIChatInput
|
||||
input=""
|
||||
setInput={() => {}}
|
||||
draftImages={[]}
|
||||
setDraftImages={() => {}}
|
||||
sending={false}
|
||||
onSend={() => {}}
|
||||
onStop={() => {}}
|
||||
handleKeyDown={() => {}}
|
||||
activeConnName=""
|
||||
activeContext={null}
|
||||
activeProvider={{ model: '', models: [] }}
|
||||
dynamicModels={[]}
|
||||
loadingModels={false}
|
||||
sendShortcutBinding={{ combo: 'Enter', enabled: true }}
|
||||
composerNotice={null}
|
||||
onModelChange={() => {}}
|
||||
onFetchModels={() => {}}
|
||||
textareaRef={React.createRef<HTMLTextAreaElement>()}
|
||||
darkMode={false}
|
||||
textColor="#162033"
|
||||
mutedColor="rgba(16,24,40,0.55)"
|
||||
overlayTheme={buildOverlayWorkbenchTheme(false)}
|
||||
isV2Ui
|
||||
{...overrides}
|
||||
/>
|
||||
);
|
||||
|
||||
describe('AIChatInput notice layout', () => {
|
||||
it('renders the composer notice above the input editor', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
@@ -49,6 +79,7 @@ describe('AIChatInput notice layout', () => {
|
||||
textColor="#162033"
|
||||
mutedColor="rgba(16,24,40,0.55)"
|
||||
overlayTheme={buildOverlayWorkbenchTheme(false)}
|
||||
isV2Ui
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -77,6 +108,7 @@ describe('AIChatInput notice layout', () => {
|
||||
dynamicModels={[]}
|
||||
loadingModels={false}
|
||||
sendShortcutBinding={{ combo: 'Meta+Enter', enabled: true }}
|
||||
shortcutPlatform="mac"
|
||||
composerNotice={null}
|
||||
onModelChange={() => {}}
|
||||
onFetchModels={() => {}}
|
||||
@@ -85,9 +117,63 @@ describe('AIChatInput notice layout', () => {
|
||||
textColor="#162033"
|
||||
mutedColor="rgba(16,24,40,0.55)"
|
||||
overlayTheme={buildOverlayWorkbenchTheme(false)}
|
||||
isV2Ui
|
||||
/>
|
||||
);
|
||||
|
||||
expect(markup).toContain('Meta+Enter 发送');
|
||||
expect(markup).toContain('⌘↵ 发送');
|
||||
});
|
||||
|
||||
it('renders the model selector without the filled select variant', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AIChatInput
|
||||
input=""
|
||||
setInput={() => {}}
|
||||
draftImages={[]}
|
||||
setDraftImages={() => {}}
|
||||
sending={false}
|
||||
onSend={() => {}}
|
||||
onStop={() => {}}
|
||||
handleKeyDown={() => {}}
|
||||
activeConnName=""
|
||||
activeContext={null}
|
||||
activeProvider={{ model: 'gpt-5.5', models: ['gpt-5.5'] }}
|
||||
dynamicModels={[]}
|
||||
loadingModels={false}
|
||||
sendShortcutBinding={{ combo: 'Enter', enabled: true }}
|
||||
composerNotice={null}
|
||||
onModelChange={() => {}}
|
||||
onFetchModels={() => {}}
|
||||
textareaRef={React.createRef<HTMLTextAreaElement>()}
|
||||
darkMode={false}
|
||||
textColor="#162033"
|
||||
mutedColor="rgba(16,24,40,0.55)"
|
||||
overlayTheme={buildOverlayWorkbenchTheme(false)}
|
||||
isV2Ui
|
||||
/>
|
||||
);
|
||||
|
||||
expect(markup).toContain('gn-v2-ai-model-select');
|
||||
expect(markup).not.toContain('ant-select-filled');
|
||||
expect(markup).not.toContain('ant-select-show-search');
|
||||
});
|
||||
|
||||
it('renders an enabled type-safe send button when text is present', () => {
|
||||
const markup = renderAIChatInput({ input: 'select 1' });
|
||||
const sendButton = markup.match(/<button[^>]*class="ai-chat-send-btn gn-v2-ai-send"[^>]*>/)?.[0] || '';
|
||||
|
||||
expect(sendButton).toContain('type="button"');
|
||||
expect(sendButton).toContain('title="发送"');
|
||||
expect(sendButton).not.toContain('disabled');
|
||||
});
|
||||
|
||||
it('keeps the legacy composer free of v2-only layout classes by default', () => {
|
||||
const markup = renderAIChatInput({ isV2Ui: false, input: 'select 1' });
|
||||
|
||||
expect(markup).toContain('class="ai-chat-input-area"');
|
||||
expect(markup).toContain('class="ai-chat-send-btn"');
|
||||
expect(markup).not.toContain('gn-v2-ai-composer');
|
||||
expect(markup).not.toContain('gn-v2-ai-model-select');
|
||||
expect(markup).not.toContain('gn-v2-ai-send');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Input, Select, AutoComplete, Tooltip, Modal, Checkbox, Spin, message, Button, Tag } from 'antd';
|
||||
import { DatabaseOutlined, SendOutlined, TableOutlined, SearchOutlined, PictureOutlined, ExclamationCircleFilled } from '@ant-design/icons';
|
||||
import { Input, Select, Tooltip, Modal, Checkbox, Spin, message, Button, Tag } from 'antd';
|
||||
import { CodeOutlined, DatabaseOutlined, DownOutlined, PlusOutlined, SendOutlined, StopOutlined, TableOutlined, SearchOutlined, PictureOutlined, ExclamationCircleFilled } from '@ant-design/icons';
|
||||
import { useStore } from '../../store';
|
||||
import { DBGetTables, DBShowCreateTable, DBGetDatabases, DBGetColumns } from '../../../wailsjs/go/app/App';
|
||||
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
@@ -8,7 +8,7 @@ import type { AIComposerNotice } from '../../utils/aiComposerNotice';
|
||||
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
|
||||
import { resolveAITableSchemaToolResult } from '../../utils/aiTableSchemaTool';
|
||||
import { getAIChatSendShortcutLabel } from '../../utils/aiChatSendShortcut';
|
||||
import type { ShortcutBinding } from '../../utils/shortcuts';
|
||||
import type { ShortcutPlatform, ShortcutPlatformBinding } from '../../utils/shortcuts';
|
||||
|
||||
interface AIChatInputProps {
|
||||
input: string;
|
||||
@@ -24,7 +24,8 @@ interface AIChatInputProps {
|
||||
activeProvider: any;
|
||||
dynamicModels: string[];
|
||||
loadingModels: boolean;
|
||||
sendShortcutBinding: ShortcutBinding;
|
||||
sendShortcutBinding: ShortcutPlatformBinding;
|
||||
shortcutPlatform?: ShortcutPlatform;
|
||||
composerNotice?: AIComposerNotice | null;
|
||||
onModelChange: (val: string) => void;
|
||||
onFetchModels: () => void;
|
||||
@@ -35,14 +36,15 @@ interface AIChatInputProps {
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
contextUsageChars?: number;
|
||||
maxContextChars?: number;
|
||||
isV2Ui?: boolean;
|
||||
}
|
||||
|
||||
export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
input, setInput, draftImages, setDraftImages, sending, onSend, onStop, handleKeyDown,
|
||||
activeConnName, activeContext, activeProvider, dynamicModels, loadingModels,
|
||||
sendShortcutBinding, composerNotice,
|
||||
sendShortcutBinding, shortcutPlatform = 'windows', composerNotice,
|
||||
onModelChange, onFetchModels, textareaRef, darkMode, textColor, mutedColor, overlayTheme,
|
||||
contextUsageChars, maxContextChars
|
||||
contextUsageChars, maxContextChars, isV2Ui = false
|
||||
}) => {
|
||||
const [contextOpen, setContextOpen] = React.useState(false);
|
||||
const [contextLoading, setContextLoading] = React.useState(false);
|
||||
@@ -245,10 +247,397 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
if (!isV2Ui) {
|
||||
return (
|
||||
<div className="ai-chat-input-area" style={{ borderTop: 'none', padding: '12px 16px 20px' }}>
|
||||
<div className="ai-chat-input-wrapper" style={{
|
||||
borderColor: 'transparent',
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
gap: 8,
|
||||
padding: '8px 4px 8px'
|
||||
}}>
|
||||
<div className="ai-chat-input-preview-area" style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{activeContextItems.length > 0 && (
|
||||
<Tag
|
||||
onClick={() => setContextExpanded(!contextExpanded)}
|
||||
style={{ background: darkMode ? 'rgba(24, 144, 255, 0.15)' : 'rgba(24, 144, 255, 0.08)', border: 'none', color: '#1890ff', borderRadius: 12, padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 4, margin: 0, cursor: 'pointer', transition: 'all 0.3s' }}
|
||||
>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<DatabaseOutlined /> 关联上下文 ({activeContextItems.length}) {contextExpanded ? '▴' : '▾'}
|
||||
</span>
|
||||
</Tag>
|
||||
)}
|
||||
|
||||
{contextExpanded && activeContextItems.map((ctx, idx) => (
|
||||
<Tag
|
||||
key={`ctx-${idx}`}
|
||||
closable
|
||||
onClose={(e) => { e.preventDefault(); removeAIContext(connectionKey, ctx.dbName, ctx.tableName); }}
|
||||
style={{ background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)', border: 'none', color: textColor, borderRadius: 12, padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 4, margin: 0 }}
|
||||
>
|
||||
<span style={{ fontSize: 13 }}>🗄️ {ctx.tableName}</span>
|
||||
</Tag>
|
||||
))}
|
||||
{draftImages.map((b64, i) => (
|
||||
<div key={i} style={{ position: 'relative', width: 60, height: 60, borderRadius: 6, overflow: 'hidden', border: overlayTheme.shellBorder }}>
|
||||
<img src={b64} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt={`Draft ${i}`} />
|
||||
<div
|
||||
onClick={() => setDraftImages(prev => prev.filter((_, idx) => idx !== i))}
|
||||
style={{ position: 'absolute', top: 2, right: 2, background: 'rgba(0,0,0,0.5)', color: '#fff', borderRadius: '50%', width: 16, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: 10 }}
|
||||
>
|
||||
✕
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{composerNotice && (
|
||||
<div
|
||||
data-ai-chat-composer-notice="true"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 8,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 12,
|
||||
background: composerNoticePalette.background,
|
||||
border: `1px solid ${composerNoticePalette.borderColor}`,
|
||||
}}
|
||||
>
|
||||
<ExclamationCircleFilled style={{ color: composerNoticePalette.iconColor, fontSize: 14, marginTop: 1, flexShrink: 0 }} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: textColor, lineHeight: 1.4 }}>
|
||||
{composerNotice.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: mutedColor, lineHeight: 1.5, marginTop: 2, wordBreak: 'break-word' }}>
|
||||
{composerNotice.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div data-ai-chat-composer-input="true" style={{ position: 'relative' }}>
|
||||
{showSlashMenu && filteredSlashCmds.length > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: '100%', left: 0, right: 0, marginBottom: 4,
|
||||
background: darkMode ? '#2a2a2a' : '#fff',
|
||||
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.1)'}`,
|
||||
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)', zIndex: 100,
|
||||
maxHeight: 220, overflowY: 'auto', padding: 4
|
||||
}}>
|
||||
{filteredSlashCmds.map(cmd => (
|
||||
<div
|
||||
key={cmd.cmd}
|
||||
style={{
|
||||
padding: '8px 12px', borderRadius: 6, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
transition: 'background 0.15s'
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
onClick={() => {
|
||||
setInput(cmd.prompt);
|
||||
setShowSlashMenu(false);
|
||||
setSlashFilter('');
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: textColor, minWidth: 80 }}>{cmd.cmd}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: textColor }}>{cmd.label}</span>
|
||||
<span style={{ fontSize: 11, color: mutedColor, marginLeft: 'auto' }}>{cmd.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Input.TextArea
|
||||
onPaste={(e) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.indexOf('image') !== -1) {
|
||||
e.preventDefault();
|
||||
const blob = items[i].getAsFile();
|
||||
if (blob) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
if (event.target?.result) {
|
||||
setDraftImages(prev => [...prev, event.target!.result as string]);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
ref={textareaRef as any}
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setInput(val);
|
||||
if (val.startsWith('/')) {
|
||||
setSlashFilter(val.split(/\s/)[0]);
|
||||
setShowSlashMenu(true);
|
||||
} else {
|
||||
setShowSlashMenu(false);
|
||||
setSlashFilter('');
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown as any}
|
||||
placeholder={`输入消息... (${getAIChatSendShortcutLabel(sendShortcutBinding, shortcutPlatform)},Shift+Enter 换行,/ 快捷命令)`}
|
||||
variant="borderless"
|
||||
autoSize={{ minRows: 1, maxRows: 8 }}
|
||||
style={{ color: textColor, width: '100%', padding: 0, resize: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{activeConnName && (
|
||||
<Tooltip title="当前数据查询上下文">
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
fontSize: 11, padding: '2px 8px', borderRadius: 12,
|
||||
background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
|
||||
color: overlayTheme.mutedText, cursor: 'default'
|
||||
}}>
|
||||
<DatabaseOutlined style={{ fontSize: 10 }} />
|
||||
<span style={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{activeConnName}{activeContext?.dbName ? ` / ${activeContext.dbName}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{activeProvider && (
|
||||
<Select
|
||||
size="small"
|
||||
variant="filled"
|
||||
value={activeProvider.model || undefined}
|
||||
onChange={onModelChange}
|
||||
onOpenChange={(open) => {
|
||||
if (open && dynamicModels.length === 0 && (activeProvider.models || []).length === 0) {
|
||||
onFetchModels();
|
||||
}
|
||||
}}
|
||||
loading={loadingModels}
|
||||
options={(dynamicModels.length > 0 ? dynamicModels : (activeProvider.models || [])).map((m: string) => ({ label: m, value: m }))}
|
||||
style={{ width: 130, fontSize: 11, background: 'transparent' }}
|
||||
styles={{ popup: { root: { minWidth: 200 } } }}
|
||||
showSearch
|
||||
placeholder="选择模型"
|
||||
/>
|
||||
)}
|
||||
|
||||
{contextUsageChars !== undefined && maxContextChars !== undefined && (
|
||||
<Tooltip title={`当前会话记忆已用字符。达到限制(${(maxContextChars/1000).toFixed(0)}k)时将触发自动压缩。`}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
fontSize: 10, padding: '2px 6px', borderRadius: 12, border: '1px solid transparent',
|
||||
background: contextUsageChars > maxContextChars * 0.8 ? (darkMode ? 'rgba(250, 173, 20, 0.1)' : 'rgba(250, 173, 20, 0.08)') : (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'),
|
||||
borderColor: contextUsageChars > maxContextChars * 0.8 ? 'rgba(250, 173, 20, 0.3)' : 'transparent',
|
||||
color: contextUsageChars > maxContextChars * 0.8 ? '#faad14' : overlayTheme.mutedText, cursor: 'default',
|
||||
transition: 'all 0.3s'
|
||||
}}>
|
||||
<span>🧠 {(contextUsageChars / 1000).toFixed(1)}k / {(maxContextChars / 1000).toFixed(0)}k</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexShrink: 0 }}>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleImageUpload}
|
||||
/>
|
||||
<Tooltip title="上传图片/截图">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PictureOutlined style={{ fontSize: 16 }} />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{ color: overlayTheme.mutedText, border: 'none', background: 'transparent', padding: '0 4px', height: 26 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = textColor}
|
||||
onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="关联附带数据库表上下文">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<TableOutlined style={{ fontSize: 16 }} />}
|
||||
onClick={handleOpenContext}
|
||||
style={{ color: overlayTheme.mutedText, border: 'none', background: 'transparent', padding: '0 4px', height: 26 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = textColor}
|
||||
onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText}
|
||||
/>
|
||||
</Tooltip>
|
||||
{sending ? (
|
||||
<button
|
||||
className="ai-chat-send-btn ai-chat-stop-btn"
|
||||
onClick={onStop}
|
||||
title="停止生成"
|
||||
style={{
|
||||
background: 'rgba(255,77,79,0.1)',
|
||||
color: '#ff4d4f', border: '1px solid rgba(255,77,79,0.2)',
|
||||
width: 26, height: 26, borderRadius: 6, padding: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 10, height: 10, background: 'currentColor', borderRadius: 2 }} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="ai-chat-send-btn"
|
||||
onClick={() => onSend()}
|
||||
disabled={!input.trim() && draftImages.length === 0}
|
||||
title="发送"
|
||||
style={{
|
||||
background: (input.trim() || draftImages.length > 0) ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)'),
|
||||
color: (input.trim() || draftImages.length > 0) ? overlayTheme.iconColor : mutedColor,
|
||||
width: 26, height: 26, borderRadius: 6, border: 'none', padding: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: (input.trim() || draftImages.length > 0) ? 'pointer' : 'not-allowed', flexShrink: 0
|
||||
}}
|
||||
>
|
||||
<SendOutlined />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={<span style={{ color: textColor }}>关联数据库表结构上下文</span>}
|
||||
open={contextOpen}
|
||||
onCancel={() => setContextOpen(false)}
|
||||
onOk={handleAppendContext}
|
||||
confirmLoading={appendingContext}
|
||||
okText="同步所选表至上下文"
|
||||
cancelText="取消"
|
||||
centered
|
||||
styles={{
|
||||
content: { background: darkMode ? '#1e1e1e' : '#ffffff', border: overlayTheme.shellBorder },
|
||||
header: { background: darkMode ? '#1e1e1e' : '#ffffff', borderBottom: overlayTheme.shellBorder },
|
||||
body: { padding: '20px 24px' }
|
||||
}}
|
||||
>
|
||||
<Spin spinning={contextLoading}>
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 12 }}>
|
||||
{dbList.length > 0 && (
|
||||
<Select
|
||||
value={selectedDbName}
|
||||
onChange={val => {
|
||||
const c = useStore.getState().connections.find(conn => conn.id === activeContext?.connectionId);
|
||||
if (c) fetchTablesForDb(val, c.config);
|
||||
}}
|
||||
options={dbList.map(d => ({ label: d, value: d }))}
|
||||
style={{ width: 160, flexShrink: 0 }}
|
||||
placeholder="切换数据库"
|
||||
showSearch
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
placeholder="在当前库搜索表名..."
|
||||
prefix={<SearchOutlined style={{ color: overlayTheme.mutedText }} />}
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
style={{ background: darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)', border: 'none', flexGrow: 1 }}
|
||||
/>
|
||||
</div>
|
||||
{filteredTables.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}`, paddingBottom: 12, marginBottom: 8 }}>
|
||||
<Checkbox
|
||||
indeterminate={
|
||||
filteredTables.length > 0 &&
|
||||
filteredTables.some(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`)) &&
|
||||
!filteredTables.every(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`))
|
||||
}
|
||||
checked={filteredTables.length > 0 && filteredTables.every(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`))}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
const newSelected = new Set([...selectedTableKeys, ...filteredTables.map(t => `${selectedDbName}::${t.name}`)]);
|
||||
setSelectedTableKeys(Array.from(newSelected));
|
||||
} else {
|
||||
const filteredKeys = filteredTables.map(t => `${selectedDbName}::${t.name}`);
|
||||
setSelectedTableKeys(selectedTableKeys.filter(key => !filteredKeys.includes(key)));
|
||||
}
|
||||
}}
|
||||
style={{ color: textColor, fontWeight: 'bold' }}
|
||||
>
|
||||
全选匹配的表 ({filteredTables.length})
|
||||
</Checkbox>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
style={{ padding: 0, height: 'auto', fontSize: 13 }}
|
||||
onClick={() => {
|
||||
const filteredKeys = filteredTables.map(t => `${selectedDbName}::${t.name}`);
|
||||
const remainingSelected = selectedTableKeys.filter(key => !filteredKeys.includes(key));
|
||||
const toAdd = filteredKeys.filter(key => !selectedTableKeys.includes(key));
|
||||
setSelectedTableKeys([...remainingSelected, ...toAdd]);
|
||||
}}
|
||||
>
|
||||
反选匹配结果
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ maxHeight: 300, overflowY: 'auto', margin: '0 -24px', padding: '0 24px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{filteredTables.map(t => {
|
||||
const key = `${selectedDbName}::${t.name}`;
|
||||
const isSelected = selectedTableKeys.includes(key);
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
borderRadius: 6,
|
||||
transition: 'background 0.2s',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.03)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).tagName.toLowerCase() === 'input') return;
|
||||
if (isSelected) {
|
||||
setSelectedTableKeys(selectedTableKeys.filter(k => k !== key));
|
||||
} else {
|
||||
setSelectedTableKeys([...selectedTableKeys, key]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) setSelectedTableKeys([...selectedTableKeys, key]);
|
||||
else setSelectedTableKeys(selectedTableKeys.filter(k => k !== key));
|
||||
}}
|
||||
style={{ color: textColor, width: '100%' }}
|
||||
>
|
||||
<span style={{ fontSize: 13, userSelect: 'none' }}>{t.name}</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '40px 0', textAlign: 'center', color: overlayTheme.mutedText }}>
|
||||
没有找到匹配 '{searchText}' 的表
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-chat-input-area" style={{ borderTop: 'none', padding: '12px 16px 20px' }}>
|
||||
<div className="ai-chat-input-wrapper" style={{
|
||||
borderColor: 'transparent',
|
||||
<div className="ai-chat-input-area gn-v2-ai-composer" style={{ borderTop: 'none', padding: '12px 16px 20px' }}>
|
||||
<div className="ai-chat-input-wrapper" style={{
|
||||
borderColor: 'transparent',
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -256,40 +645,62 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
gap: 8,
|
||||
padding: '8px 4px 8px'
|
||||
}}>
|
||||
<div className="ai-chat-input-preview-area" style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{activeContextItems.length > 0 && (
|
||||
<Tag
|
||||
onClick={() => setContextExpanded(!contextExpanded)}
|
||||
style={{ background: darkMode ? 'rgba(24, 144, 255, 0.15)' : 'rgba(24, 144, 255, 0.08)', border: 'none', color: '#1890ff', borderRadius: 12, padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 4, margin: 0, cursor: 'pointer', transition: 'all 0.3s' }}
|
||||
>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<DatabaseOutlined /> 关联上下文 ({activeContextItems.length}) {contextExpanded ? '▴' : '▾'}
|
||||
</span>
|
||||
</Tag>
|
||||
)}
|
||||
|
||||
{contextExpanded && activeContextItems.map((ctx, idx) => (
|
||||
<Tag
|
||||
key={`ctx-${idx}`}
|
||||
closable
|
||||
onClose={(e) => { e.preventDefault(); removeAIContext(connectionKey, ctx.dbName, ctx.tableName); }}
|
||||
style={{ background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)', border: 'none', color: textColor, borderRadius: 12, padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 4, margin: 0 }}
|
||||
>
|
||||
<span style={{ fontSize: 13 }}>🗄️ {ctx.tableName}</span>
|
||||
</Tag>
|
||||
))}
|
||||
{draftImages.map((b64, i) => (
|
||||
<div key={i} style={{ position: 'relative', width: 60, height: 60, borderRadius: 6, overflow: 'hidden', border: overlayTheme.shellBorder }}>
|
||||
<img src={b64} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt={`Draft ${i}`} />
|
||||
<div
|
||||
onClick={() => setDraftImages(prev => prev.filter((_, idx) => idx !== i))}
|
||||
style={{ position: 'absolute', top: 2, right: 2, background: 'rgba(0,0,0,0.5)', color: '#fff', borderRadius: '50%', width: 16, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: 10 }}
|
||||
>
|
||||
✕
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="ai-chat-input-preview-area gn-v2-ai-context-row">
|
||||
<button
|
||||
type="button"
|
||||
className={`gn-v2-ai-context-toggle${contextExpanded ? ' is-expanded' : ''}`}
|
||||
onClick={() => setContextExpanded(!contextExpanded)}
|
||||
aria-expanded={contextExpanded}
|
||||
>
|
||||
<TableOutlined />
|
||||
<span>关联上下文</span>
|
||||
<strong>{activeContextItems.length}</strong>
|
||||
<DownOutlined />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="gn-v2-ai-context-add"
|
||||
onClick={handleOpenContext}
|
||||
>
|
||||
<PlusOutlined />
|
||||
<span>添加</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{contextExpanded && activeContextItems.length > 0 && (
|
||||
<div className="gn-v2-ai-context-detail" data-ai-context-detail="true">
|
||||
<div className="gn-v2-ai-context-detail-title">当前上下文 · {activeContextItems.length}</div>
|
||||
{activeContextItems.map((ctx, idx) => (
|
||||
<Tag
|
||||
key={`ctx-${idx}`}
|
||||
closable
|
||||
onClose={(e) => { e.preventDefault(); removeAIContext(connectionKey, ctx.dbName, ctx.tableName); }}
|
||||
className="gn-v2-ai-context-table-chip"
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<TableOutlined />
|
||||
<span>{ctx.tableName}</span>
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draftImages.length > 0 && (
|
||||
<div className="gn-v2-ai-attachment-row">
|
||||
{draftImages.map((b64, i) => (
|
||||
<div key={i} className="gn-v2-ai-attachment-thumb">
|
||||
<img src={b64} alt={`Draft ${i}`} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraftImages(prev => prev.filter((_, idx) => idx !== i))}
|
||||
aria-label="移除图片"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{composerNotice && (
|
||||
<div
|
||||
data-ai-chat-composer-notice="true"
|
||||
@@ -314,14 +725,11 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div data-ai-chat-composer-input="true" style={{ position: 'relative' }}>
|
||||
<div className="gn-v2-ai-input-box" data-ai-chat-composer-input="true" style={{ position: 'relative' }}>
|
||||
{showSlashMenu && filteredSlashCmds.length > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: '100%', left: 0, right: 0, marginBottom: 4,
|
||||
<div className="gn-v2-ai-slash-menu" style={{
|
||||
background: darkMode ? '#2a2a2a' : '#fff',
|
||||
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.1)'}`,
|
||||
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)', zIndex: 100,
|
||||
maxHeight: 220, overflowY: 'auto', padding: 4
|
||||
}}>
|
||||
{filteredSlashCmds.map(cmd => (
|
||||
<div
|
||||
@@ -347,162 +755,151 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Input.TextArea
|
||||
onPaste={(e) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.indexOf('image') !== -1) {
|
||||
e.preventDefault();
|
||||
const blob = items[i].getAsFile();
|
||||
if (blob) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
if (event.target?.result) {
|
||||
setDraftImages(prev => [...prev, event.target!.result as string]);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
<div className="gn-v2-ai-input-surface">
|
||||
<Input.TextArea
|
||||
onPaste={(e) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.indexOf('image') !== -1) {
|
||||
e.preventDefault();
|
||||
const blob = items[i].getAsFile();
|
||||
if (blob) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
if (event.target?.result) {
|
||||
setDraftImages(prev => [...prev, event.target!.result as string]);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
ref={textareaRef as any}
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setInput(val);
|
||||
// Slash command detection
|
||||
if (val.startsWith('/')) {
|
||||
setSlashFilter(val.split(/\s/)[0]);
|
||||
setShowSlashMenu(true);
|
||||
} else {
|
||||
setShowSlashMenu(false);
|
||||
setSlashFilter('');
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown as any}
|
||||
placeholder={`输入消息... (${getAIChatSendShortcutLabel(sendShortcutBinding)},Shift+Enter 换行,/ 快捷命令)`}
|
||||
variant="borderless"
|
||||
autoSize={{ minRows: 1, maxRows: 8 }}
|
||||
style={{ color: textColor, width: '100%', padding: 0, resize: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{activeConnName && (
|
||||
<Tooltip title="当前数据查询上下文">
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
fontSize: 11, padding: '2px 8px', borderRadius: 12,
|
||||
background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
|
||||
color: overlayTheme.mutedText, cursor: 'default'
|
||||
}}>
|
||||
<DatabaseOutlined style={{ fontSize: 10 }} />
|
||||
<span style={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{activeConnName}{activeContext?.dbName ? ` / ${activeContext.dbName}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{activeProvider && (
|
||||
<Select
|
||||
size="small"
|
||||
variant="filled"
|
||||
value={activeProvider.model || undefined}
|
||||
onChange={onModelChange}
|
||||
onDropdownVisibleChange={(open) => {
|
||||
if (open && dynamicModels.length === 0 && (activeProvider.models || []).length === 0) {
|
||||
onFetchModels();
|
||||
}
|
||||
}}
|
||||
loading={loadingModels}
|
||||
options={(dynamicModels.length > 0 ? dynamicModels : (activeProvider.models || [])).map((m: string) => ({ label: m, value: m }))}
|
||||
style={{ width: 130, fontSize: 11, background: 'transparent' }}
|
||||
dropdownStyle={{ minWidth: 200 }}
|
||||
showSearch
|
||||
placeholder="选择模型"
|
||||
/>
|
||||
)}
|
||||
|
||||
{contextUsageChars !== undefined && maxContextChars !== undefined && (
|
||||
<Tooltip title={`当前会话记忆已用字符。达到限制(${(maxContextChars/1000).toFixed(0)}k)时将触发自动压缩。`}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
fontSize: 10, padding: '2px 6px', borderRadius: 12, border: '1px solid transparent',
|
||||
background: contextUsageChars > maxContextChars * 0.8 ? (darkMode ? 'rgba(250, 173, 20, 0.1)' : 'rgba(250, 173, 20, 0.08)') : (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'),
|
||||
borderColor: contextUsageChars > maxContextChars * 0.8 ? 'rgba(250, 173, 20, 0.3)' : 'transparent',
|
||||
color: contextUsageChars > maxContextChars * 0.8 ? '#faad14' : overlayTheme.mutedText, cursor: 'default',
|
||||
transition: 'all 0.3s'
|
||||
}}>
|
||||
<span>🧠 {(contextUsageChars / 1000).toFixed(1)}k / {(maxContextChars / 1000).toFixed(0)}k</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexShrink: 0 }}>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleImageUpload}
|
||||
}}
|
||||
ref={textareaRef as any}
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setInput(val);
|
||||
// Slash command detection
|
||||
if (val.startsWith('/')) {
|
||||
setSlashFilter(val.split(/\s/)[0]);
|
||||
setShowSlashMenu(true);
|
||||
} else {
|
||||
setShowSlashMenu(false);
|
||||
setSlashFilter('');
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown as any}
|
||||
placeholder={`输入消息... ${getAIChatSendShortcutLabel(sendShortcutBinding, shortcutPlatform)} · / 命令`}
|
||||
variant="borderless"
|
||||
autoSize={{ minRows: 1, maxRows: 8 }}
|
||||
style={{ color: textColor, width: '100%', padding: 0, resize: 'none' }}
|
||||
/>
|
||||
<Tooltip title="上传图片/截图">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PictureOutlined style={{ fontSize: 16 }} />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{ color: overlayTheme.mutedText, border: 'none', background: 'transparent', padding: '0 4px', height: 26 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = textColor}
|
||||
onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText}
|
||||
<div className="gn-v2-ai-input-actions">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleImageUpload}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="关联附带数据库表上下文">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<TableOutlined style={{ fontSize: 16 }} />}
|
||||
onClick={handleOpenContext}
|
||||
style={{ color: overlayTheme.mutedText, border: 'none', background: 'transparent', padding: '0 4px', height: 26 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = textColor}
|
||||
onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText}
|
||||
/>
|
||||
</Tooltip>
|
||||
{sending ? (
|
||||
<button
|
||||
className="ai-chat-send-btn ai-chat-stop-btn"
|
||||
onClick={onStop}
|
||||
title="停止生成"
|
||||
style={{
|
||||
background: 'rgba(255,77,79,0.1)',
|
||||
color: '#ff4d4f', border: '1px solid rgba(255,77,79,0.2)',
|
||||
width: 26, height: 26, borderRadius: 6, padding: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 10, height: 10, background: 'currentColor', borderRadius: 2 }} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="ai-chat-send-btn"
|
||||
onClick={() => onSend()}
|
||||
disabled={!input.trim() && draftImages.length === 0}
|
||||
title="发送"
|
||||
style={{
|
||||
background: (input.trim() || draftImages.length > 0) ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)'),
|
||||
color: (input.trim() || draftImages.length > 0) ? overlayTheme.iconColor : mutedColor,
|
||||
width: 26, height: 26, borderRadius: 6, border: 'none', padding: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: (input.trim() || draftImages.length > 0) ? 'pointer' : 'not-allowed', flexShrink: 0
|
||||
}}
|
||||
>
|
||||
<SendOutlined />
|
||||
</button>
|
||||
)}
|
||||
<Tooltip title="上传图片/截图">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PictureOutlined />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{ color: overlayTheme.mutedText, border: 'none', background: 'transparent' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="关联附带数据库表上下文">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<TableOutlined />}
|
||||
onClick={handleOpenContext}
|
||||
style={{ color: overlayTheme.mutedText, border: 'none', background: 'transparent' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="快捷命令">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CodeOutlined />}
|
||||
onClick={() => {
|
||||
setInput('/');
|
||||
setSlashFilter('/');
|
||||
setShowSlashMenu(true);
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
style={{ color: overlayTheme.mutedText, border: 'none', background: 'transparent' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
{sending ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ai-chat-send-btn ai-chat-stop-btn gn-v2-ai-send"
|
||||
onClick={onStop}
|
||||
title="停止生成"
|
||||
>
|
||||
<StopOutlined />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="ai-chat-send-btn gn-v2-ai-send"
|
||||
onClick={() => onSend()}
|
||||
disabled={!input.trim() && draftImages.length === 0}
|
||||
title="发送"
|
||||
>
|
||||
<SendOutlined />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="gn-v2-ai-model-bar">
|
||||
{activeConnName && (
|
||||
<Tooltip title="当前数据查询上下文">
|
||||
<div className="gn-v2-ai-context-chip">
|
||||
<span className="gn-v2-ai-context-live-dot" />
|
||||
<DatabaseOutlined />
|
||||
<span>{activeConnName}{activeContext?.dbName ? ` / ${activeContext.dbName}` : ''}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{activeProvider && (
|
||||
<Select
|
||||
size="small"
|
||||
value={activeProvider.model || undefined}
|
||||
onChange={onModelChange}
|
||||
onOpenChange={(open) => {
|
||||
if (open && dynamicModels.length === 0 && (activeProvider.models || []).length === 0) {
|
||||
onFetchModels();
|
||||
}
|
||||
}}
|
||||
loading={loadingModels}
|
||||
options={(dynamicModels.length > 0 ? dynamicModels : (activeProvider.models || [])).map((m: string) => ({ label: m, value: m }))}
|
||||
styles={{ popup: { root: { minWidth: 200 } } }}
|
||||
placeholder="选择模型"
|
||||
className="gn-v2-ai-model-select"
|
||||
suffixIcon={<DownOutlined />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="gn-v2-ai-model-spacer" />
|
||||
|
||||
{contextUsageChars !== undefined && maxContextChars !== undefined && (
|
||||
<Tooltip title={`当前会话记忆已用字符。达到限制(${(maxContextChars/1000).toFixed(0)}k)时将触发自动压缩。`}>
|
||||
<div className={`gn-v2-ai-token-meter${contextUsageChars > maxContextChars * 0.8 ? ' is-warn' : ''}`}>
|
||||
<span className="gn-v2-ai-token-bar" aria-hidden="true">
|
||||
<span style={{ width: `${Math.min(100, (contextUsageChars / Math.max(1, maxContextChars)) * 100)}%` }} />
|
||||
</span>
|
||||
<span>{(contextUsageChars / 1000).toFixed(1)}k/{(maxContextChars / 1000).toFixed(0)}k</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { RobotOutlined } from '@ant-design/icons';
|
||||
import { ApiOutlined, DatabaseOutlined, FileTextOutlined, RobotOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
|
||||
interface AIChatWelcomeProps {
|
||||
@@ -10,15 +10,15 @@ interface AIChatWelcomeProps {
|
||||
mutedColor: string;
|
||||
onQuickAction: (prompt: string, autoSend?: boolean) => void;
|
||||
contextTableNames?: string[];
|
||||
isV2Ui?: boolean;
|
||||
}
|
||||
|
||||
export const AIChatWelcome: React.FC<AIChatWelcomeProps> = ({
|
||||
overlayTheme, quickActionBg, quickActionBorder, textColor, mutedColor, onQuickAction, contextTableNames = []
|
||||
overlayTheme, quickActionBg, quickActionBorder, textColor, mutedColor, onQuickAction, contextTableNames = [], isV2Ui = false
|
||||
}) => {
|
||||
const hasContext = contextTableNames.length > 0;
|
||||
const tableList = contextTableNames.join('、');
|
||||
|
||||
const quickActions = hasContext
|
||||
const legacyQuickActions = hasContext
|
||||
? [
|
||||
{ label: '📝 生成 SQL', prompt: `请根据以下表结构生成一条常用查询语句:${tableList}` },
|
||||
{ label: '🔍 解释表结构', prompt: `请详细解释以下表的设计意图和字段含义:${tableList}` },
|
||||
@@ -32,22 +32,82 @@ export const AIChatWelcome: React.FC<AIChatWelcomeProps> = ({
|
||||
{ label: '🏗️ Schema 分析', prompt: '请分析当前数据库的表结构并给出优化建议。' },
|
||||
];
|
||||
|
||||
const quickActions = hasContext
|
||||
? [
|
||||
{ label: '生成 SQL', hint: '自然语言生成查询', icon: <FileTextOutlined />, tone: 'info', prompt: `请根据以下表结构生成一条常用查询语句:${tableList}` },
|
||||
{ label: '解释表结构', hint: '逐字段说明含义与约束', icon: <DatabaseOutlined />, tone: 'success', prompt: `请详细解释以下表的设计意图和字段含义:${tableList}` },
|
||||
{ label: '优化建议', hint: '索引、范式、潜在风险', icon: <ThunderboltOutlined />, tone: 'warn', prompt: `请分析以下表的结构设计,给出索引优化和查询性能优化建议:${tableList}` },
|
||||
{ label: 'Schema 分析', hint: '表关系与依赖图', icon: <ApiOutlined />, tone: 'purple', prompt: `请对以下表进行全面的 Schema 分析,包括数据类型选择、范式评估和改进建议:${tableList}` },
|
||||
]
|
||||
: [
|
||||
{ label: '生成 SQL', hint: '自然语言生成查询', icon: <FileTextOutlined />, tone: 'info', prompt: '请根据当前数据库表结构生成一条查询语句:' },
|
||||
{ label: '解释 SQL', hint: '说明执行逻辑', icon: <DatabaseOutlined />, tone: 'success', prompt: '请解释以下 SQL 语句的执行逻辑:\n```sql\n\n```' },
|
||||
{ label: '优化建议', hint: '性能和索引建议', icon: <ThunderboltOutlined />, tone: 'warn', prompt: '请分析以下 SQL 语句的性能并给出优化建议:\n```sql\n\n```' },
|
||||
{ label: 'Schema 分析', hint: '结构质量分析', icon: <ApiOutlined />, tone: 'purple', prompt: '请分析当前数据库的表结构并给出优化建议。' },
|
||||
];
|
||||
const promptSuggestions = hasContext
|
||||
? [
|
||||
`为什么 ${contextTableNames[0]?.split('.').pop() || '当前表'} 只有少量记录?`,
|
||||
'过去 7 天关键渠道的分布情况',
|
||||
'帮我写一条禁用异常渠道的 SQL',
|
||||
]
|
||||
: [
|
||||
'为什么当前结果只有少量记录?',
|
||||
'过去 7 天订单渠道分布',
|
||||
'帮我写一条清理异常数据的 SQL',
|
||||
];
|
||||
|
||||
if (!isV2Ui) {
|
||||
return (
|
||||
<div className="ai-chat-welcome" style={{ padding: '30px 20px', alignItems: 'flex-start', textAlign: 'left' }}>
|
||||
<div style={{ color: overlayTheme.titleText, fontSize: 16, fontWeight: 600, marginBottom: 8 }}>
|
||||
<RobotOutlined style={{ marginRight: 8, color: overlayTheme.iconColor }} />
|
||||
你好,我是 GoNavi AI
|
||||
</div>
|
||||
<div className="welcome-desc" style={{ color: mutedColor, fontSize: 13, lineHeight: 1.6, marginBottom: 20 }}>
|
||||
{hasContext
|
||||
? `已自动关联 ${contextTableNames.length} 张表结构,点击下方按钮快速开始分析。`
|
||||
: '我是你的智能数据库助手。我可以帮你生成 SQL 查询、分析表结构、解释执行逻辑以及优化数据库性能。'}
|
||||
</div>
|
||||
<div className="quick-actions">
|
||||
{legacyQuickActions.map(action => (
|
||||
<div
|
||||
key={action.label}
|
||||
className="quick-action-btn"
|
||||
style={{
|
||||
background: quickActionBg,
|
||||
borderColor: quickActionBorder,
|
||||
color: textColor,
|
||||
}}
|
||||
onClick={() => onQuickAction(action.prompt)}
|
||||
>
|
||||
{action.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-chat-welcome" style={{ padding: '30px 20px', alignItems: 'flex-start', textAlign: 'left' }}>
|
||||
<div style={{ color: overlayTheme.titleText, fontSize: 16, fontWeight: 600, marginBottom: 8 }}>
|
||||
<RobotOutlined style={{ marginRight: 8, color: overlayTheme.iconColor }} />
|
||||
你好,我是 GoNavi AI
|
||||
<div className="gn-v2-ai-welcome-title" style={{ color: overlayTheme.titleText, fontSize: 16, fontWeight: 600, marginBottom: 8 }}>
|
||||
<span className="gn-v2-ai-welcome-icon">
|
||||
<RobotOutlined style={{ color: overlayTheme.iconColor }} />
|
||||
</span>
|
||||
<strong>你好,我是 GoNavi AI</strong>
|
||||
</div>
|
||||
<div className="welcome-desc" style={{ color: mutedColor, fontSize: 13, lineHeight: 1.6, marginBottom: 20 }}>
|
||||
{hasContext
|
||||
? `已自动关联 ${contextTableNames.length} 张表结构,点击下方按钮快速开始分析。`
|
||||
? <>已自动关联 <span className="gn-v2-ai-context-inline"><DatabaseOutlined />{contextTableNames.length} 张表</span> 结构,点击下方按钮快速开始分析。</>
|
||||
: '我是你的智能数据库助手。我可以帮你生成 SQL 查询、分析表结构、解释执行逻辑以及优化数据库性能。'}
|
||||
</div>
|
||||
<div className="quick-actions">
|
||||
<div className="quick-actions gn-v2-ai-quick-grid">
|
||||
{quickActions.map(action => (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
key={action.label}
|
||||
className="quick-action-btn"
|
||||
className={`quick-action-btn gn-v2-ai-quick-card tone-${action.tone}`}
|
||||
style={{
|
||||
background: quickActionBg,
|
||||
borderColor: quickActionBorder,
|
||||
@@ -55,8 +115,23 @@ export const AIChatWelcome: React.FC<AIChatWelcomeProps> = ({
|
||||
}}
|
||||
onClick={() => onQuickAction(action.prompt)}
|
||||
>
|
||||
{action.label}
|
||||
</div>
|
||||
<span className="gn-v2-ai-quick-icon">{action.icon}</span>
|
||||
<strong>{action.label}</strong>
|
||||
<span>{action.hint}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="gn-v2-ai-suggestion-list">
|
||||
<div className="gn-v2-ai-suggestion-divider">
|
||||
<span />
|
||||
<small>或直接提问</small>
|
||||
<span />
|
||||
</div>
|
||||
{promptSuggestions.map((prompt) => (
|
||||
<button key={prompt} type="button" onClick={() => onQuickAction(prompt)}>
|
||||
<RobotOutlined />
|
||||
<span>{prompt}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -59,7 +59,7 @@ const JVMChangePreviewModal: React.FC<JVMChangePreviewModalProps> = ({
|
||||
cancelText="关闭"
|
||||
okButtonProps={{ disabled: !preview?.allowed, loading: applying }}
|
||||
width={880}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
>
|
||||
{!preview ? (
|
||||
<Alert type="info" showIcon message="暂无预览结果" />
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { resolveTextInputSafeBackdropFilter } from '../utils/appearance';
|
||||
|
||||
/** v2 = body[data-ui-version="v2"],由 App.tsx 切换。 */
|
||||
const isV2 = (): boolean => {
|
||||
if (typeof document === 'undefined' || !document.body) return false;
|
||||
return document.body.getAttribute('data-ui-version') === 'v2';
|
||||
};
|
||||
|
||||
type RedisWorkbenchThemeInput = {
|
||||
darkMode: boolean;
|
||||
opacity: number;
|
||||
@@ -56,6 +62,77 @@ export const buildRedisWorkbenchTheme = ({
|
||||
disableBackdropFilter ?? false,
|
||||
);
|
||||
|
||||
// ─── v2 palette: Redis-red accent on GoNavi neutral surfaces ──
|
||||
if (isV2()) {
|
||||
if (darkMode) {
|
||||
return {
|
||||
isDark: true,
|
||||
appBg: '#0c0e12',
|
||||
panelBg: '#161a21',
|
||||
panelBgStrong: '#1b1f27',
|
||||
panelBgSubtle: 'rgba(255,255,255,0.04)',
|
||||
panelBorder: '0.5px solid rgba(255,255,255,0.10)',
|
||||
panelInset: 'inset 0 0.5px 0 rgba(255,255,255,0.05)',
|
||||
toolbarPrimaryBg: 'linear-gradient(135deg, rgba(248,113,113,0.18) 0%, rgba(248,113,113,0.08) 100%)',
|
||||
contentEmptyBg: 'linear-gradient(180deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.01) 100%)',
|
||||
textPrimary: '#f1f3f5',
|
||||
textSecondary: '#d6dade',
|
||||
textMuted: '#80868f',
|
||||
accent: '#f87171',
|
||||
accentSoft: 'rgba(248, 113, 113, 0.16)',
|
||||
accentBorder: 'rgba(248, 113, 113, 0.32)',
|
||||
actionSecondaryBg: 'rgba(255,255,255,0.05)',
|
||||
actionSecondaryBorder: '0.5px solid rgba(255,255,255,0.10)',
|
||||
actionDangerBg: 'rgba(220, 38, 38, 0.16)',
|
||||
actionDangerBorder: '0.5px solid rgba(220, 38, 38, 0.32)',
|
||||
actionDangerText: '#f87171',
|
||||
statusTagBg: 'rgba(56, 189, 248, 0.16)',
|
||||
statusTagBorder: '0.5px solid rgba(56, 189, 248, 0.30)',
|
||||
statusTagMutedBg: 'rgba(255,255,255,0.06)',
|
||||
statusTagMutedBorder: '0.5px solid rgba(255,255,255,0.10)',
|
||||
treeHoverBg: 'rgba(255,255,255,0.05)',
|
||||
treeSelectedBg: 'linear-gradient(90deg, rgba(248,113,113,0.18) 0%, rgba(248,113,113,0.06) 100%)',
|
||||
treeSelectedBorder: 'rgba(248, 113, 113, 0.28)',
|
||||
divider: 'rgba(255,255,255,0.06)',
|
||||
shadow: '0 12px 40px rgba(0,0,0,0.55)',
|
||||
backdropFilter,
|
||||
};
|
||||
}
|
||||
return {
|
||||
isDark: false,
|
||||
appBg: '#f6f6f4',
|
||||
panelBg: '#ffffff',
|
||||
panelBgStrong: '#ffffff',
|
||||
panelBgSubtle: '#fafaf8',
|
||||
panelBorder: '0.5px solid rgba(15,23,42,0.12)',
|
||||
panelInset: 'inset 0 0.5px 0 rgba(255,255,255,0.6)',
|
||||
toolbarPrimaryBg: 'linear-gradient(135deg, rgba(220,38,38,0.10) 0%, rgba(220,38,38,0.04) 100%)',
|
||||
contentEmptyBg: 'linear-gradient(180deg, rgba(15,23,42,0.02) 0%, rgba(15,23,42,0.01) 100%)',
|
||||
textPrimary: '#0c1322',
|
||||
textSecondary: '#1f2937',
|
||||
textMuted: '#6b7280',
|
||||
accent: '#dc2626',
|
||||
accentSoft: 'rgba(220, 38, 38, 0.10)',
|
||||
accentBorder: 'rgba(220, 38, 38, 0.24)',
|
||||
actionSecondaryBg: '#ffffff',
|
||||
actionSecondaryBorder: '0.5px solid rgba(15,23,42,0.12)',
|
||||
actionDangerBg: 'rgba(220, 38, 38, 0.10)',
|
||||
actionDangerBorder: '0.5px solid rgba(220, 38, 38, 0.24)',
|
||||
actionDangerText: '#dc2626',
|
||||
statusTagBg: 'rgba(2, 132, 199, 0.10)',
|
||||
statusTagBorder: '0.5px solid rgba(2, 132, 199, 0.24)',
|
||||
statusTagMutedBg: 'rgba(15,23,42,0.04)',
|
||||
statusTagMutedBorder: '0.5px solid rgba(15,23,42,0.08)',
|
||||
treeHoverBg: 'rgba(15,23,42,0.045)',
|
||||
treeSelectedBg: 'linear-gradient(90deg, rgba(220,38,38,0.10) 0%, rgba(220,38,38,0.02) 100%)',
|
||||
treeSelectedBorder: 'rgba(220, 38, 38, 0.22)',
|
||||
divider: 'rgba(15,23,42,0.08)',
|
||||
shadow: '0 12px 40px rgba(15,23,42,0.14)',
|
||||
backdropFilter,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── legacy palette (existing behavior) ──────────────────────
|
||||
if (darkMode) {
|
||||
const appTopAlpha = isTranslucent ? Math.max(0.08, Math.min(0.22, normalizedOpacity * 0.16)) : 0.92;
|
||||
const appBottomAlpha = isTranslucent ? Math.max(0.12, Math.min(0.28, normalizedOpacity * 0.22)) : 0.96;
|
||||
|
||||
Reference in New Issue
Block a user