mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-11 17:59:43 +08:00
✨ feat(ui): 优化侧边栏设置中心与数据表交互
- 收敛左上角入口为工具和设置中心,并调整新建连接操作优先级 - 优化表设计器 SQL 预览高亮和刷新前未保存字段变更确认 - 下移数据页次级操作并将编辑行收口到单元格右键菜单 - 补充侧边栏布局、表设计器草稿检测和数据页布局回归测试 Refs #324
This commit is contained in:
@@ -869,8 +869,8 @@ function App() {
|
||||
...sidebarQuickActionBaseStyle,
|
||||
flex: '1 1 0',
|
||||
border: 'none',
|
||||
background: 'linear-gradient(135deg, rgba(255,214,102,0.96) 0%, rgba(240,183,39,0.92) 100%)',
|
||||
color: '#2a1f00',
|
||||
background: 'linear-gradient(135deg, rgba(34,197,94,0.96) 0%, rgba(22,163,74,0.92) 100%)',
|
||||
color: '#f3fff7',
|
||||
}), [sidebarQuickActionBaseStyle]);
|
||||
|
||||
const utilityModalShellStyle = useMemo(() => ({
|
||||
@@ -1855,6 +1855,7 @@ function App() {
|
||||
};
|
||||
|
||||
const [isToolsModalOpen, setIsToolsModalOpen] = useState(false);
|
||||
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
|
||||
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
|
||||
const [themeModalSection, setThemeModalSection] = useState<'theme' | 'appearance'>('theme');
|
||||
const [isAppearanceModalOpen, setIsAppearanceModalOpen] = useState(false);
|
||||
@@ -1888,26 +1889,11 @@ function App() {
|
||||
icon: <ToolOutlined />,
|
||||
onClick: () => setIsToolsModalOpen(true),
|
||||
},
|
||||
proxy: {
|
||||
key: 'proxy',
|
||||
title: '代理',
|
||||
icon: <GlobalOutlined />,
|
||||
onClick: () => {
|
||||
setSecurityUpdateRepairSource(null);
|
||||
setIsProxyModalOpen(true);
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
key: 'theme',
|
||||
title: '主题',
|
||||
icon: <SkinOutlined />,
|
||||
onClick: () => setIsThemeModalOpen(true),
|
||||
},
|
||||
about: {
|
||||
key: 'about',
|
||||
title: '关于',
|
||||
icon: <InfoCircleOutlined />,
|
||||
onClick: () => setIsAboutOpen(true),
|
||||
settings: {
|
||||
key: 'settings',
|
||||
title: '设置',
|
||||
icon: <SettingOutlined />,
|
||||
onClick: () => setIsSettingsModalOpen(true),
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -2648,7 +2634,7 @@ function App() {
|
||||
>
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ padding: `12px ${sidebarHorizontalPadding}px 8px`, borderBottom: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 8, width: '100%' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${sidebarUtilityItems.length}, minmax(0, 1fr))`, gap: 8, width: '100%' }}>
|
||||
{sidebarUtilityItems.map((item) => (
|
||||
<Tooltip key={item.key} title={item.title}>
|
||||
<Button type="text" icon={item.icon} style={utilityButtonStyle} onClick={item.onClick} />
|
||||
@@ -2658,12 +2644,12 @@ function App() {
|
||||
</div>
|
||||
<div style={{ padding: `0 ${sidebarHorizontalPadding}px 10px`, borderBottom: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isSidebarCompact ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) minmax(0, 1fr)', gap: 8, width: '100%' }}>
|
||||
<Button icon={<ConsoleSqlOutlined />} onClick={handleNewQuery} title="新建查询" style={sidebarQueryActionStyle}>
|
||||
新建查询
|
||||
</Button>
|
||||
<Button icon={<PlusOutlined />} onClick={handleCreateConnection} title="新建连接" style={sidebarCreateConnectionActionStyle}>
|
||||
新建连接
|
||||
</Button>
|
||||
<Button icon={<ConsoleSqlOutlined />} onClick={handleNewQuery} title="新建查询" style={sidebarQueryActionStyle}>
|
||||
新建查询
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2910,6 +2896,71 @@ function App() {
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={renderUtilityModalTitle(<SettingOutlined />, '设置中心', '集中处理代理、主题、AI 与关于等通用配置入口。')}
|
||||
open={isSettingsModalOpen}
|
||||
onCancel={() => setIsSettingsModalOpen(false)}
|
||||
footer={null}
|
||||
width={560}
|
||||
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 12, padding: '12px 0' }}>
|
||||
{[
|
||||
{
|
||||
key: 'theme',
|
||||
icon: <SkinOutlined />,
|
||||
title: '主题与外观',
|
||||
description: '切换亮暗主题并调整界面观感。',
|
||||
onClick: () => {
|
||||
setIsSettingsModalOpen(false);
|
||||
setThemeModalSection('theme');
|
||||
setIsThemeModalOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'proxy',
|
||||
icon: <GlobalOutlined />,
|
||||
title: '全局代理',
|
||||
description: '统一配置更新检查、驱动管理和公共网络出口。',
|
||||
onClick: () => {
|
||||
setIsSettingsModalOpen(false);
|
||||
setSecurityUpdateRepairSource(null);
|
||||
setIsProxyModalOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ai',
|
||||
icon: <RobotOutlined />,
|
||||
title: 'AI 设置',
|
||||
description: '管理模型供应商、密钥和默认行为。',
|
||||
onClick: () => {
|
||||
setIsSettingsModalOpen(false);
|
||||
handleOpenAISettings();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'about',
|
||||
icon: <InfoCircleOutlined />,
|
||||
title: '关于 GoNavi',
|
||||
description: '查看版本信息、仓库地址和更新状态。',
|
||||
onClick: () => {
|
||||
setIsSettingsModalOpen(false);
|
||||
setIsAboutOpen(true);
|
||||
},
|
||||
},
|
||||
].map((item) => (
|
||||
<Button key={item.key} type="text" style={utilityActionCardStyle} onClick={item.onClick}>
|
||||
<span style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span style={{ display: 'grid', gap: 4, textAlign: 'left', minWidth: 0 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: overlayTheme.titleText }}>{item.title}</span>
|
||||
<span style={{ fontSize: 12, color: overlayTheme.mutedText, whiteSpace: 'normal' }}>{item.description}</span>
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={renderUtilityModalTitle(<HddOutlined />, '数据存储位置', '统一管理连接、代理、AI 配置与驱动等文件型数据的根目录。')}
|
||||
open={isDataRootModalOpen}
|
||||
|
||||
80
frontend/src/components/DataGrid.layout.test.tsx
Normal file
80
frontend/src/components/DataGrid.layout.test.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DataGrid from './DataGrid';
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useStore: (selector: (state: any) => any) => selector({
|
||||
connections: [],
|
||||
addSqlLog: vi.fn(),
|
||||
theme: 'light',
|
||||
appearance: {
|
||||
enabled: true,
|
||||
opacity: 1,
|
||||
blur: 0,
|
||||
showDataTableVerticalBorders: false,
|
||||
dataTableColumnWidthMode: 'standard',
|
||||
},
|
||||
queryOptions: {
|
||||
showColumnComment: false,
|
||||
showColumnType: false,
|
||||
},
|
||||
setQueryOptions: vi.fn(),
|
||||
tableColumnOrders: {},
|
||||
enableColumnOrderMemory: false,
|
||||
setTableColumnOrder: vi.fn(),
|
||||
setEnableColumnOrderMemory: vi.fn(),
|
||||
clearTableColumnOrder: vi.fn(),
|
||||
tableHiddenColumns: {},
|
||||
enableHiddenColumnMemory: false,
|
||||
setTableHiddenColumns: vi.fn(),
|
||||
setEnableHiddenColumnMemory: vi.fn(),
|
||||
clearTableHiddenColumns: vi.fn(),
|
||||
aiPanelVisible: false,
|
||||
setAIPanelVisible: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../wailsjs/go/app/App', () => ({
|
||||
ImportData: vi.fn(),
|
||||
ExportTable: vi.fn(),
|
||||
ExportData: vi.fn(),
|
||||
ExportQuery: vi.fn(),
|
||||
ApplyChanges: vi.fn(),
|
||||
DBGetColumns: vi.fn(),
|
||||
DBGetIndexes: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@monaco-editor/react', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
describe('DataGrid layout', () => {
|
||||
it('renders a secondary action strip for view switching and auxiliary actions', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<DataGrid
|
||||
data={[
|
||||
{
|
||||
__gonavi_row_key__: 'row-1',
|
||||
id: 1,
|
||||
name: 'alpha',
|
||||
},
|
||||
]}
|
||||
columnNames={['id', 'name']}
|
||||
loading={false}
|
||||
tableName="users"
|
||||
readOnly
|
||||
pagination={{
|
||||
current: 1,
|
||||
pageSize: 100,
|
||||
total: 1,
|
||||
}}
|
||||
onPageChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-grid-secondary-actions="true"');
|
||||
expect(markup).toContain('data-grid-view-switcher="true"');
|
||||
});
|
||||
});
|
||||
@@ -3247,20 +3247,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
setRowEditorOpen(true);
|
||||
}, [canModifyData, mergedDisplayData, data, addedRows, displayColumnNames, rowEditorForm, rowKeyStr, columnMetaMap, columnMetaMapByLowerName]);
|
||||
|
||||
const openRowEditor = useCallback(() => {
|
||||
if (!canModifyData) return;
|
||||
if (selectedRowKeys.length > 1) {
|
||||
void message.info('一次只能编辑一行,请仅选择一行');
|
||||
return;
|
||||
}
|
||||
const keyStr = selectedRowKeys.length === 1 ? rowKeyStr(selectedRowKeys[0]) : undefined;
|
||||
if (!keyStr) {
|
||||
void message.info('请先选择一行(勾选复选框)');
|
||||
return;
|
||||
}
|
||||
openRowEditorByKey(keyStr);
|
||||
}, [canModifyData, selectedRowKeys, rowKeyStr, openRowEditorByKey]);
|
||||
|
||||
const openCurrentViewRowEditor = useCallback(() => {
|
||||
if (!canModifyData) return;
|
||||
const currentRow = mergedDisplayData[textRecordIndex];
|
||||
@@ -3278,6 +3264,50 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
setJsonEditorOpen(true);
|
||||
}, [canModifyData, jsonViewText]);
|
||||
|
||||
const handleViewModeChange = useCallback((nextMode: GridViewMode) => {
|
||||
if (nextMode === 'json' && cellEditMode) {
|
||||
setCellEditMode(false);
|
||||
setSelectedCells(new Set());
|
||||
currentSelectionRef.current = new Set();
|
||||
selectionStartRef.current = null;
|
||||
isDraggingRef.current = false;
|
||||
cellSelectionPointerRef.current = null;
|
||||
if (cellSelectionRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionRafRef.current);
|
||||
cellSelectionRafRef.current = null;
|
||||
}
|
||||
if (cellSelectionScrollRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionScrollRafRef.current);
|
||||
cellSelectionScrollRafRef.current = null;
|
||||
}
|
||||
if (cellSelectionAutoScrollRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
|
||||
cellSelectionAutoScrollRafRef.current = null;
|
||||
}
|
||||
updateCellSelection(new Set());
|
||||
}
|
||||
|
||||
if (nextMode === 'text') {
|
||||
const selectedKey = selectedRowKeys[0];
|
||||
if (selectedKey !== undefined) {
|
||||
const idx = mergedDisplayData.findIndex((row) => rowKeyStr(row?.[GONAVI_ROW_KEY]) === rowKeyStr(selectedKey));
|
||||
if (idx >= 0) {
|
||||
setTextRecordIndex(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setViewMode(nextMode);
|
||||
}, [cellEditMode, mergedDisplayData, selectedRowKeys, rowKeyStr, updateCellSelection]);
|
||||
|
||||
const handleOpenContextMenuRowEditor = useCallback(() => {
|
||||
if (!canModifyData) return;
|
||||
const rowKey = cellContextMenu.record?.[GONAVI_ROW_KEY];
|
||||
if (rowKey === undefined || rowKey === null) return;
|
||||
openRowEditorByKey(rowKeyStr(rowKey));
|
||||
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}, [canModifyData, cellContextMenu.record, openRowEditorByKey, rowKeyStr]);
|
||||
|
||||
const handleFormatJsonEditor = useCallback(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonEditorValue);
|
||||
@@ -4868,7 +4898,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
<div className={`${gridId}${cellEditMode ? ' cell-edit-mode' : ''} data-grid-root`} style={{ flex: '1 1 auto', height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0, minWidth: 0, background: 'transparent' }}>
|
||||
{/* Toolbar + Filter Panel */}
|
||||
<div style={{ margin: `${panelOuterGap}px 0 ${panelOuterGap}px 0`, border: `1px solid ${panelFrameColor}`, borderRadius: `${panelRadius}px`, background: bgFilter, overflow: 'hidden', boxSizing: 'border-box' }}>
|
||||
<div className="data-grid-toolbar-scroll" style={{ padding: showFilter ? `${panelPaddingY}px ${panelPaddingX}px ${toolbarBottomPadding}px ${panelPaddingX}px` : `${panelPaddingY}px ${panelPaddingX}px`, border: 'none', borderRadius: 0, background: 'transparent', display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'nowrap', minWidth: 0, overflowX: 'auto', overflowY: 'hidden', scrollbarGutter: 'stable', WebkitOverflowScrolling: 'touch', boxSizing: 'border-box' }}>
|
||||
<div className="data-grid-toolbar-scroll" data-grid-primary-actions="true" style={{ padding: showFilter ? `${panelPaddingY}px ${panelPaddingX}px ${toolbarBottomPadding}px ${panelPaddingX}px` : `${panelPaddingY}px ${panelPaddingX}px`, border: 'none', borderRadius: 0, background: 'transparent', display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'nowrap', minWidth: 0, overflowX: 'auto', overflowY: 'hidden', scrollbarGutter: 'stable', WebkitOverflowScrolling: 'touch', boxSizing: 'border-box' }}>
|
||||
{onReload && <Button icon={<ReloadOutlined />} disabled={loading} onClick={() => {
|
||||
setAddedRows([]);
|
||||
setModifiedRows({});
|
||||
@@ -4891,13 +4921,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
<>
|
||||
<div style={{ width: 1, background: toolbarDividerColor, height: 20, margin: '0 8px' }} />
|
||||
<Button icon={<PlusOutlined />} onClick={handleAddRow}>添加行</Button>
|
||||
<Button
|
||||
icon={<EditOutlined />}
|
||||
disabled={selectedRowKeys.length !== 1}
|
||||
onClick={openRowEditor}
|
||||
>
|
||||
编辑行
|
||||
</Button>
|
||||
<Button icon={<DeleteOutlined />} danger disabled={selectedRowKeys.length === 0} onClick={handleDeleteSelected}>删除选中</Button>
|
||||
{selectedRowKeys.length > 0 && <span style={{ fontSize: '12px', color: '#888' }}>已选 {selectedRowKeys.length}</span>}
|
||||
<div style={{ width: 1, background: toolbarDividerColor, height: 20, margin: '0 8px' }} />
|
||||
@@ -5047,78 +5070,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
)}
|
||||
|
||||
<div style={{ marginLeft: 'auto' }} />
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<Button
|
||||
icon={<EditOutlined />}
|
||||
type={dataPanelOpen ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
const next = !dataPanelOpen;
|
||||
setDataPanelOpen(next);
|
||||
if (!next) {
|
||||
setFocusedCellInfo(null);
|
||||
setDataPanelValue('');
|
||||
setDataPanelIsJson(false);
|
||||
dataPanelDirtyRef.current = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
数据预览
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
content={columnInfoSettingContent}
|
||||
>
|
||||
<Button icon={<FileTextOutlined />}>字段信息</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<Segmented
|
||||
size="small"
|
||||
value={viewMode}
|
||||
options={[
|
||||
{ label: '表格', value: 'table' },
|
||||
{ label: 'JSON', value: 'json' },
|
||||
{ label: '文本', value: 'text' }
|
||||
]}
|
||||
onChange={(val) => {
|
||||
const nextMode = String(val) as GridViewMode;
|
||||
if (nextMode === 'json' && cellEditMode) {
|
||||
setCellEditMode(false);
|
||||
setSelectedCells(new Set());
|
||||
currentSelectionRef.current = new Set();
|
||||
selectionStartRef.current = null;
|
||||
isDraggingRef.current = false;
|
||||
cellSelectionPointerRef.current = null;
|
||||
if (cellSelectionRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionRafRef.current);
|
||||
cellSelectionRafRef.current = null;
|
||||
}
|
||||
if (cellSelectionScrollRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionScrollRafRef.current);
|
||||
cellSelectionScrollRafRef.current = null;
|
||||
}
|
||||
if (cellSelectionAutoScrollRafRef.current !== null) {
|
||||
cancelAnimationFrame(cellSelectionAutoScrollRafRef.current);
|
||||
cellSelectionAutoScrollRafRef.current = null;
|
||||
}
|
||||
updateCellSelection(new Set());
|
||||
}
|
||||
if (nextMode === 'text') {
|
||||
const selectedKey = selectedRowKeys[0];
|
||||
if (selectedKey !== undefined) {
|
||||
const idx = mergedDisplayData.findIndex((row) => rowKeyStr(row?.[GONAVI_ROW_KEY]) === rowKeyStr(selectedKey));
|
||||
if (idx >= 0) {
|
||||
setTextRecordIndex(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
setViewMode(nextMode);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFilter && (
|
||||
@@ -5710,6 +5661,19 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
>
|
||||
设置为 NULL
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||
onClick={handleOpenContextMenuRowEditor}
|
||||
>
|
||||
<EditOutlined style={{ marginRight: 8 }} />
|
||||
编辑本行
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
@@ -5919,6 +5883,58 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-grid-secondary-actions="true"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 10,
|
||||
flexWrap: 'wrap',
|
||||
padding: '4px 0 0',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
icon={<EditOutlined />}
|
||||
type={dataPanelOpen ? 'primary' : 'default'}
|
||||
disabled={viewMode !== 'table'}
|
||||
onClick={() => {
|
||||
const next = !dataPanelOpen;
|
||||
setDataPanelOpen(next);
|
||||
if (!next) {
|
||||
setFocusedCellInfo(null);
|
||||
setDataPanelValue('');
|
||||
setDataPanelIsJson(false);
|
||||
dataPanelDirtyRef.current = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
数据预览
|
||||
</Button>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
content={columnInfoSettingContent}
|
||||
>
|
||||
<Button icon={<FileTextOutlined />}>字段信息</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
<div data-grid-view-switcher="true" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 12, color: darkMode ? '#999' : '#666' }}>结果视图</span>
|
||||
<Segmented
|
||||
size="small"
|
||||
value={viewMode}
|
||||
options={[
|
||||
{ label: '表格', value: 'table' },
|
||||
{ label: 'JSON', value: 'json' },
|
||||
{ label: '文本', value: 'text' }
|
||||
]}
|
||||
onChange={(val) => handleViewModeChange(String(val) as GridViewMode)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<div className="data-grid-pagination-wrap" style={{ padding: '12px 0 0', borderTop: 'none', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
|
||||
@@ -320,6 +320,10 @@ const TabManager: React.FC = () => {
|
||||
box-shadow: 0 0 0 2px rgba(9, 109, 217, 0.32);
|
||||
background: rgba(9, 109, 217, 0.08);
|
||||
}
|
||||
body[data-theme='light'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
|
||||
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 {
|
||||
background: rgba(255, 214, 102, 0.12) !important;
|
||||
border-color: rgba(255, 214, 102, 0.4) !important;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, Trigg
|
||||
import { useStore } from '../store';
|
||||
import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App';
|
||||
import { hasIndexFormChanged, normalizeIndexFormFromRow, shouldRestoreOriginalIndex, toggleIndexSelection as getNextIndexSelection, type IndexDisplaySnapshot } from './tableDesignerIndexUtils';
|
||||
import { buildAlterTablePreviewSql } from './tableDesignerSchemaSql';
|
||||
import { buildAlterTablePreviewSql, hasAlterTableDraftChanges } from './tableDesignerSchemaSql';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { noAutoCapInputProps } from '../utils/inputAutoCap';
|
||||
|
||||
@@ -1396,6 +1396,19 @@ ${selectedTrigger.statement}`;
|
||||
};
|
||||
};
|
||||
|
||||
const hasUnsavedDraftChanges = useMemo(() => {
|
||||
if (isNewTable || readOnly) {
|
||||
return false;
|
||||
}
|
||||
const tableInfo = resolveTableInfo();
|
||||
return hasAlterTableDraftChanges({
|
||||
dbType: tableInfo.dbType,
|
||||
tableName: tableInfo.qualifiedName,
|
||||
originalColumns,
|
||||
columns,
|
||||
});
|
||||
}, [columns, connections, isNewTable, originalColumns, readOnly, tab.connectionId, tab.dbName, tab.tableName]);
|
||||
|
||||
const supportsIndexSchemaOps = (): boolean => {
|
||||
const dbType = getDbType();
|
||||
if (!dbType) return false;
|
||||
@@ -2143,6 +2156,24 @@ END;`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshDesigner = () => {
|
||||
if (!hasUnsavedDraftChanges) {
|
||||
void fetchData();
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '存在未保存的字段变更',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: '刷新后会丢失当前尚未保存的字段调整,是否仍要刷新并覆盖当前草稿?',
|
||||
okText: '仍然刷新',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
await fetchData();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleExecuteSave = async () => {
|
||||
const result = await executeSchemaStatements(previewSql);
|
||||
if (!result.ok) {
|
||||
@@ -2519,7 +2550,7 @@ END;`;
|
||||
</>
|
||||
)}
|
||||
{!readOnly && <Button size="small" icon={<SaveOutlined />} type="primary" onClick={generateDDL}>保存</Button>}
|
||||
{!isNewTable && <Button size="small" icon={<ReloadOutlined />} onClick={fetchData}>刷新</Button>}
|
||||
{!isNewTable && <Button size="small" icon={<ReloadOutlined />} onClick={handleRefreshDesigner}>刷新</Button>}
|
||||
{!isNewTable && !readOnly && supportsTableCommentOps() && (
|
||||
<Button size="small" icon={<EditOutlined />} onClick={openTableCommentModal}>表备注</Button>
|
||||
)}
|
||||
@@ -2983,10 +3014,24 @@ END;`;
|
||||
okText="执行"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div style={{ maxHeight: '400px', overflow: 'auto' }}>
|
||||
<pre style={{ background: darkMode ? '#1e1e1e' : '#f5f5f5', color: darkMode ? '#d4d4d4' : 'inherit', padding: '10px', borderRadius: '4px', border: darkMode ? '1px solid #333' : '1px solid #eee', whiteSpace: 'pre-wrap' }}>
|
||||
{previewSql}
|
||||
</pre>
|
||||
<div style={{ maxHeight: '400px', overflow: 'hidden', borderRadius: 8, border: darkMode ? '1px solid #333' : '1px solid #eee' }}>
|
||||
<Editor
|
||||
height="360px"
|
||||
defaultLanguage="sql"
|
||||
language="sql"
|
||||
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
|
||||
value={previewSql}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
automaticLayout: true,
|
||||
padding: { top: 8, bottom: 8 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ marginTop: 10, color: '#faad14' }}>请仔细检查 SQL,执行后不可撤销。</p>
|
||||
</Modal>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildAlterTablePreviewSql,
|
||||
hasAlterTableDraftChanges,
|
||||
type BuildAlterTablePreviewInput,
|
||||
type EditableColumnSnapshot,
|
||||
} from './tableDesignerSchemaSql';
|
||||
@@ -29,6 +30,18 @@ const buildInput = (overrides: Partial<BuildAlterTablePreviewInput>): BuildAlter
|
||||
});
|
||||
|
||||
describe('tableDesignerSchemaSql', () => {
|
||||
it('detects when alter table drafts contain unsaved column changes', () => {
|
||||
expect(hasAlterTableDraftChanges(buildInput({ dbType: 'mysql' }))).toBe(true);
|
||||
expect(
|
||||
hasAlterTableDraftChanges(
|
||||
buildInput({
|
||||
dbType: 'mysql',
|
||||
columns: [baseColumn({ _key: 'id', name: 'id', key: 'PRI', nullable: 'NO' })],
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps mysql alter preview syntax with column position clauses', () => {
|
||||
const sql = buildAlterTablePreviewSql(buildInput({ dbType: 'mysql' }));
|
||||
|
||||
|
||||
@@ -260,3 +260,6 @@ export const buildAlterTablePreviewSql = (input: BuildAlterTablePreviewInput): s
|
||||
}
|
||||
return buildMySqlAlterPreviewSql({ ...input, dbType });
|
||||
};
|
||||
|
||||
export const hasAlterTableDraftChanges = (input: BuildAlterTablePreviewInput): boolean =>
|
||||
buildAlterTablePreviewSql(input).trim().length > 0;
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
} from './aiEntryLayout';
|
||||
|
||||
describe('ai entry layout', () => {
|
||||
it('keeps the sidebar utility group free of the AI entry', () => {
|
||||
expect(SIDEBAR_UTILITY_ITEM_KEYS).toEqual(['tools', 'proxy', 'theme', 'about']);
|
||||
it('keeps the sidebar utility group compact and free of the AI entry', () => {
|
||||
expect(SIDEBAR_UTILITY_ITEM_KEYS).toEqual(['tools', 'settings']);
|
||||
});
|
||||
|
||||
it('anchors the AI entry to the content edge', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export const SIDEBAR_UTILITY_ITEM_KEYS = ['tools', 'proxy', 'theme', 'about'] as const;
|
||||
export const SIDEBAR_UTILITY_ITEM_KEYS = ['tools', 'settings'] as const;
|
||||
|
||||
export type AIEntryPlacement = 'content-edge';
|
||||
export type AIEdgeHandleAttachment = 'content-shell' | 'panel-shell';
|
||||
|
||||
Reference in New Issue
Block a user