release/0.6.1

This commit is contained in:
Syngnat
2026-03-19 11:59:25 +08:00
committed by GitHub
8 changed files with 768 additions and 151 deletions

View File

@@ -1 +1 @@
d0f9366af59a6367ad3c7e2d4185ead4
5b8157374dae5f9340e31b2d0bd2c00e

View File

@@ -1641,7 +1641,7 @@ function App() {
title={renderUtilityModalTitle(<InfoCircleOutlined />, '关于 GoNavi', '查看版本信息、仓库地址、更新状态与下载入口。')}
open={isAboutOpen}
onCancel={() => setIsAboutOpen(false)}
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }}
styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10, display: 'flex', flexWrap: 'wrap', gap: 10, justifyContent: 'flex-end' } }}
footer={[
canShowProgressEntry ? (
<Button key="progress" icon={<DownloadOutlined />} onClick={showUpdateDownloadProgress}></Button>

View File

@@ -3900,12 +3900,28 @@ const DataGrid: React.FC<DataGridProps> = ({
// 虚拟表格路径:通过合成 WheelEvent 驱动 rc-virtual-list 内部状态,
// rc-table 自动同步 header scrollLeft。
if (enableVirtual && tableContainer instanceof HTMLElement) {
applyVirtualHorizontalOffset(tableContainer, externalScroll.scrollLeft);
// WheelEvent 经 rc-virtual-list 处理后状态异步更新,延迟同步 ref
requestAnimationFrame(() => {
lastTableScrollLeftRef.current = readVirtualHorizontalOffset(tableContainer);
const applied = applyVirtualHorizontalOffset(tableContainer, externalScroll.scrollLeft);
if (applied) {
// WheelEvent 经 rc-virtual-list 处理后状态异步更新,延迟同步 ref
requestAnimationFrame(() => {
lastTableScrollLeftRef.current = readVirtualHorizontalOffset(tableContainer);
horizontalSyncSourceRef.current = '';
});
return;
}
// 空数据回退virtual-holder 不存在时,直接滚动表头
const headerEl = tableContainer.querySelector('.ant-table-header') as HTMLElement | null;
const contentEl = tableContainer.querySelector('.ant-table-content') as HTMLElement | null;
const fallbackTargets = [headerEl, contentEl].filter((el): el is HTMLElement => el instanceof HTMLElement && el.scrollWidth > el.clientWidth + 1);
if (fallbackTargets.length > 0) {
fallbackTargets.forEach((target) => {
target.scrollLeft = externalScroll.scrollLeft;
});
lastTableScrollLeftRef.current = externalScroll.scrollLeft;
horizontalSyncSourceRef.current = '';
});
return;
}
horizontalSyncSourceRef.current = '';
return;
}
// 非虚拟表格路径:依赖 liveTargets 进行 scrollLeft 同步

View File

@@ -1454,6 +1454,17 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
};
const onDoubleClick = (e: any, node: any) => {
if (node.type === 'object-group' && node.dataRef?.groupKey === 'tables') {
const { id, dbName, schemaName } = node.dataRef;
addTab({
id: `table-overview-${id}-${dbName}${schemaName ? `-${schemaName}` : ''}`,
title: `表概览 - ${dbName}${schemaName ? ` (${schemaName})` : ''}`,
type: 'table-overview' as any,
connectionId: id,
dbName,
});
return;
}
if (node.type === 'table') {
const { tableName, dbName, id } = node.dataRef;
// 记录表访问

View File

@@ -14,6 +14,7 @@ import RedisViewer from './RedisViewer';
import RedisCommandEditor from './RedisCommandEditor';
import TriggerViewer from './TriggerViewer';
import DefinitionViewer from './DefinitionViewer';
import TableOverview from './TableOverview';
import type { TabData } from '../types';
const detectConnectionEnvLabel = (connectionName: string): string | null => {
@@ -28,7 +29,7 @@ const detectConnectionEnvLabel = (connectionName: string): string | null => {
};
const buildTabDisplayTitle = (tab: TabData, connectionName: string | undefined): string => {
if (tab.type !== 'table' && tab.type !== 'design') return tab.title;
if (tab.type !== 'table' && tab.type !== 'design' && tab.type !== 'table-overview') return tab.title;
if (!connectionName) return tab.title;
const prefix = detectConnectionEnvLabel(connectionName) || connectionName;
return `[${prefix}] ${tab.title}`;
@@ -159,6 +160,8 @@ const TabManager: React.FC = () => {
content = <TriggerViewer tab={tab} />;
} else if (tab.type === 'view-def' || tab.type === 'routine-def') {
content = <DefinitionViewer tab={tab} />;
} else if (tab.type === 'table-overview') {
content = <TableOverview tab={tab} />;
}
const menuItems: MenuProps['items'] = [

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState, useContext, useMemo, useRef, useCallback } from 'react';
import { Table, Tabs, Button, message, Input, Checkbox, Modal, AutoComplete, Tooltip, Select, Empty, Space, Tag } from 'antd';
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 { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragOverlay } from '@dnd-kit/core';
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
@@ -121,7 +121,7 @@ const ResizableTitle = (props: any) => {
nextStyle.width = width;
}
if (!width) {
if (!onResizeStart) {
return <th {...restProps} style={nextStyle} />;
}
@@ -225,7 +225,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
const [tableCommentDraft, setTableCommentDraft] = useState('');
const [isTableCommentModalOpen, setIsTableCommentModalOpen] = useState(false);
const [tableCommentSaving, setTableCommentSaving] = useState(false);
const [selectedIndex, setSelectedIndex] = useState<IndexDisplayRow | null>(null);
const [selectedIndexKeys, setSelectedIndexKeys] = useState<string[]>([]);
const [isIndexModalOpen, setIsIndexModalOpen] = useState(false);
const [indexModalMode, setIndexModalMode] = useState<'create' | 'edit'>('create');
const [indexSaving, setIndexSaving] = useState(false);
@@ -270,6 +270,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
const [tableHeight, setTableHeight] = useState(500);
const containerRef = useRef<HTMLDivElement>(null);
const shellRef = useRef<HTMLDivElement>(null);
const pendingFocusColumnKeyRef = useRef<string | null>(null);
const focusHighlightTimerRef = useRef<number | null>(null);
const [focusColumnKey, setFocusColumnKey] = useState('');
@@ -329,7 +330,8 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
// --- Resizable Columns State ---
const [tableColumns, setTableColumns] = useState<any[]>([]);
const resizeDragRef = useRef<{ startX: number; startWidth: number; index: number; containerLeft: number } | null>(null);
const [indexColumns, setIndexColumns] = useState<any[]>([]);
const resizeDragRef = useRef<{ startX: number; startWidth: number; index: number; containerLeft: number; setter: React.Dispatch<React.SetStateAction<any[]>> } | null>(null);
const resizeRafRef = useRef<number | null>(null);
const latestResizeXRef = useRef<number | null>(null);
const ghostRef = useRef<HTMLDivElement>(null);
@@ -413,11 +415,6 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
// Initial Columns Definition
useEffect(() => {
const initialCols = [
...(readOnly ? [] : [{
key: 'sort',
width: 40,
render: () => <MenuOutlined style={{ cursor: 'grab', color: '#999' }} />,
}]),
{
title: '名',
dataIndex: 'name',
@@ -548,17 +545,17 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
document.body.style.userSelect = '';
}, []);
const handleResizeStart = useCallback((index: number) => (e: React.MouseEvent) => {
const createResizeStartHandler = useCallback((columns: any[], setter: React.Dispatch<React.SetStateAction<any[]>>) => (index: number) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const currentWidth = Number(tableColumns[index]?.width || 200);
const containerLeft = containerRef.current?.getBoundingClientRect().left ?? 0;
resizeDragRef.current = { startX, startWidth: currentWidth, index, containerLeft };
const currentWidth = Number(columns[index]?.width || 200);
const containerLeft = shellRef.current?.getBoundingClientRect().left ?? 0;
resizeDragRef.current = { startX, startWidth: currentWidth, index, containerLeft, setter };
latestResizeXRef.current = startX;
if (ghostRef.current && containerRef.current) {
if (ghostRef.current && shellRef.current) {
const relativeLeft = startX - containerLeft;
ghostRef.current.style.transform = `translateX(${relativeLeft}px)`;
ghostRef.current.style.display = 'block';
@@ -575,10 +572,10 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
const onUp = (event: MouseEvent) => {
if (resizeDragRef.current) {
const { startX: dragStartX, startWidth, index: dragIndex } = resizeDragRef.current;
const { startX: dragStartX, startWidth, index: dragIndex, setter: dragSetter } = resizeDragRef.current;
const deltaX = event.clientX - dragStartX;
const newWidth = Math.max(50, startWidth + deltaX);
setTableColumns((prevColumns) => {
dragSetter((prevColumns) => {
if (!prevColumns[dragIndex]) return prevColumns;
const nextColumns = [...prevColumns];
nextColumns[dragIndex] = {
@@ -598,7 +595,10 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
document.addEventListener('mouseup', onUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}, [cleanupResizeState, detachResizeListeners, flushResizeGhost, tableColumns]);
}, [cleanupResizeState, detachResizeListeners, flushResizeGhost]);
const handleResizeStart = useMemo(() => createResizeStartHandler(tableColumns, setTableColumns), [createResizeStartHandler, tableColumns]);
const handleIndexResizeStart = useMemo(() => createResizeStartHandler(indexColumns, setIndexColumns), [createResizeStartHandler, indexColumns]);
useEffect(() => {
return () => {
@@ -1083,6 +1083,11 @@ ${selectedTrigger.statement}`;
});
}, [indexes]);
const selectedIndex = useMemo(() => {
if (selectedIndexKeys.length === 0) return null;
return groupedIndexes.find(idx => selectedIndexKeys.includes(idx.key)) || null;
}, [selectedIndexKeys, groupedIndexes]);
const groupedIndexFieldCount = useMemo(
() => groupedIndexes.reduce((total, row) => total + row.columnNames.length, 0),
[groupedIndexes]
@@ -1161,11 +1166,12 @@ ${selectedTrigger.statement}`;
);
useEffect(() => {
if (!selectedIndex) return;
if (!groupedIndexes.some(idx => idx.key === selectedIndex.key)) {
setSelectedIndex(null);
if (selectedIndexKeys.length === 0) return;
const validKeys = selectedIndexKeys.filter(key => groupedIndexes.some(idx => idx.key === key));
if (validKeys.length !== selectedIndexKeys.length) {
setSelectedIndexKeys(validKeys);
}
}, [groupedIndexes, selectedIndex]);
}, [groupedIndexes, selectedIndexKeys]);
useEffect(() => {
if (!selectedForeignKey) return;
@@ -2009,13 +2015,163 @@ END;`;
};
// Merge columns with resize handler
const resizableColumns = tableColumns.map((col, index) => ({
const resizableColumns = useMemo(() => tableColumns.map((col, index) => ({
...col,
onHeaderCell: (column: any) => ({
width: column.width,
onResizeStart: handleResizeStart(index),
}),
}));
})), [tableColumns]);
// 字段表 Checkbox 选择列(不参与 resize支持全选
const allColumnKeys = useMemo(() => columns.map(c => c._key), [columns]);
const isAllColumnsSelected = allColumnKeys.length > 0 && selectedColumnRowKeys.length === allColumnKeys.length;
const isColumnsIndeterminate = selectedColumnRowKeys.length > 0 && selectedColumnRowKeys.length < allColumnKeys.length;
const columnSelectCol = useMemo(() => ({
title: () => (
<Checkbox
checked={isAllColumnsSelected}
indeterminate={isColumnsIndeterminate}
onChange={(e: any) => setSelectedColumnRowKeys(e.target.checked ? allColumnKeys : [])}
style={{ margin: 0 }}
/>
),
dataIndex: '_select',
key: '_select',
width: 48,
render: (_: any, record: any) => (
<Checkbox
checked={selectedColumnRowKeys.includes(record._key)}
onChange={(e: any) => {
e.stopPropagation();
setSelectedColumnRowKeys((prev: string[]) =>
e.target.checked
? [...prev, record._key]
: prev.filter((k: string) => k !== record._key)
);
}}
style={{ margin: 0 }}
/>
),
}), [selectedColumnRowKeys, allColumnKeys, isAllColumnsSelected, isColumnsIndeterminate]);
// sort 拖拽列(不参与 resize
const sortColumn = useMemo(() => ({
key: 'sort',
width: 40,
render: () => <MenuOutlined style={{ cursor: 'grab', color: '#999' }} />,
}), []);
const columnsWithSelect = useMemo(() =>
readOnly
? resizableColumns
: [columnSelectCol, sortColumn, ...resizableColumns],
[readOnly, columnSelectCol, sortColumn, resizableColumns]
);
// --- Index Columns Init ---
useEffect(() => {
setIndexColumns([
{
title: '索引名',
dataIndex: 'name',
key: 'name',
width: 240,
render: (text: string) => (
<Tooltip title={text}>
<span style={{ display: 'inline-block', maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{text}
</span>
</Tooltip>
),
},
{
title: '字段',
dataIndex: 'columnNames',
key: 'columnNames',
width: 320,
render: (columnNames: string[]) => {
if (!columnNames || columnNames.length === 0) {
return '-';
}
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{columnNames.map((columnName: string, idx: number) => (
<Tag key={`${columnName}-${idx}`}>
{columnName}
</Tag>
))}
</div>
);
}
},
{
title: '索引类型',
dataIndex: 'indexType',
key: 'indexType',
width: 140,
render: (text: string) => text || '-',
},
{
title: '唯一性',
dataIndex: 'nonUnique',
key: 'nonUnique',
width: 110,
render: (v: number) => (
<Tag color={v === 0 ? 'gold' : 'default'}>
{v === 0 ? '唯一' : '普通'}
</Tag>
),
},
]);
}, []);
// Checkbox 选择列(不参与 resize支持全选
const allIndexKeys = groupedIndexes.map(idx => idx.key);
const isAllSelected = allIndexKeys.length > 0 && selectedIndexKeys.length === allIndexKeys.length;
const isIndeterminate = selectedIndexKeys.length > 0 && selectedIndexKeys.length < allIndexKeys.length;
const selectColumn = {
title: () => (
<Checkbox
checked={isAllSelected}
indeterminate={isIndeterminate}
onChange={(e) => {
setSelectedIndexKeys(e.target.checked ? allIndexKeys : []);
}}
style={{ margin: 0 }}
/>
),
dataIndex: '_select',
key: '_select',
width: 48,
render: (_: any, record: any) => (
<Checkbox
checked={selectedIndexKeys.includes(record.key)}
onChange={(e) => {
e.stopPropagation();
setSelectedIndexKeys(prev =>
e.target.checked
? [...prev, record.key]
: prev.filter(k => k !== record.key)
);
}}
style={{ margin: 0 }}
/>
),
};
const resizableIndexColumns = [
selectColumn,
...indexColumns.map((col, index) => ({
...col,
onHeaderCell: (column: any) => ({
width: column.width,
onResizeStart: handleIndexResizeStart(index),
}),
})),
];
const columnsTabContent = (
<div
@@ -2039,7 +2195,7 @@ END;`;
{readOnly ? (
<Table
dataSource={columns}
columns={resizableColumns}
columns={columnsWithSelect}
rowKey="_key"
rowClassName={(record: EditableColumn) => record._key === focusColumnKey ? 'table-designer-focus-row' : ''}
size="small"
@@ -2058,11 +2214,7 @@ END;`;
<SortableContext items={columns.map(c => c._key)} strategy={verticalListSortingStrategy}>
<Table
dataSource={columns}
columns={resizableColumns}
rowSelection={{
selectedRowKeys: selectedColumnRowKeys,
onChange: (nextSelectedRowKeys) => setSelectedColumnRowKeys(nextSelectedRowKeys as string[]),
}}
columns={columnsWithSelect}
rowKey="_key"
rowClassName={(record: EditableColumn) => record._key === focusColumnKey ? 'table-designer-focus-row' : ''}
size="small"
@@ -2078,6 +2230,86 @@ END;`;
</SortableContext>
</DndContext>
)}
</div>
);
return (
<div ref={shellRef} className="table-designer-shell" 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,
.table-designer-shell .ant-table-container {
background: transparent !important;
}
.table-designer-shell .ant-table-wrapper {
border: none !important;
overflow: hidden !important;
}
.table-designer-shell .ant-table-container {
border: none !important;
}
.table-designer-shell .ant-table-thead > tr > th {
background: transparent !important;
border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'} !important;
border-inline-end: 1px solid transparent !important;
}
.table-designer-shell .ant-table-tbody > tr > td,
.table-designer-shell .ant-table-tbody .ant-table-row > .ant-table-cell {
background: transparent !important;
border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important;
border-inline-end: 1px solid transparent !important;
}
.table-designer-shell .ant-table-tbody td .ant-input {
padding-left: 0 !important;
padding-right: 0 !important;
}
.table-designer-shell .ant-table-tbody td .ant-select .ant-select-selector {
padding-left: 0 !important;
}
.table-designer-shell .ant-table-thead > tr > th::before {
display: none !important;
}
.table-designer-shell .ant-table-thead > tr > th {
cursor: default !important;
user-select: none !important;
-webkit-user-select: none !important;
}
.table-designer-shell .ant-table-tbody > tr:hover > td,
.table-designer-shell .ant-table-tbody .ant-table-row:hover > .ant-table-cell {
background: ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.02)'} !important;
}
.table-designer-shell .ant-tabs-nav {
margin-bottom: 8px !important;
}
.table-designer-shell .ant-tabs-nav::before {
border-bottom-color: ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'} !important;
}
.table-designer-shell .ant-tabs-ink-bar {
will-change: transform;
transition: width 0.15s ease, left 0.15s ease, transform 0.15s ease !important;
}
.table-designer-shell .ant-tabs-tab {
transition: color 0.15s ease !important;
}
.table-designer-shell .ant-tabs-content-holder,
.table-designer-shell .ant-tabs-content,
.table-designer-shell .ant-tabs-tabpane {
height: 100%;
}
.table-designer-shell .react-resizable-handle {
position: absolute !important;
right: 0 !important;
top: 0 !important;
bottom: 0 !important;
width: 10px !important;
height: auto !important;
background-position: top right !important;
cursor: col-resize !important;
z-index: 10;
touch-action: none;
}
`}</style>
<div
ref={ghostRef}
style={{
@@ -2093,52 +2325,6 @@ END;`;
willChange: 'transform',
}}
/>
</div>
);
return (
<div className="table-designer-shell" style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, padding: '6px 0' }}>
<style>{`
.table-designer-shell .ant-table,
.table-designer-shell .ant-table-wrapper,
.table-designer-shell .ant-table-container {
background: transparent !important;
}
.table-designer-shell .ant-table-wrapper,
.table-designer-shell .ant-table-container {
border: none !important;
overflow: hidden !important;
}
.table-designer-shell .ant-table-thead > tr > th {
background: transparent !important;
border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'} !important;
border-inline-end: 1px solid transparent !important;
}
.table-designer-shell .ant-table-tbody > tr > td,
.table-designer-shell .ant-table-tbody .ant-table-row > .ant-table-cell {
background: transparent !important;
border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important;
border-inline-end: 1px solid transparent !important;
}
.table-designer-shell .ant-table-thead > tr > th::before {
display: none !important;
}
.table-designer-shell .ant-table-tbody > tr:hover > td,
.table-designer-shell .ant-table-tbody .ant-table-row:hover > .ant-table-cell {
background: ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.02)'} !important;
}
.table-designer-shell .ant-tabs-nav {
margin-bottom: 8px !important;
}
.table-designer-shell .ant-tabs-nav::before {
border-bottom-color: ${darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'} !important;
}
.table-designer-shell .ant-tabs-content-holder,
.table-designer-shell .ant-tabs-content,
.table-designer-shell .ant-tabs-tabpane {
height: 100%;
}
`}</style>
<div
style={{
padding: '10px 12px 8px 12px',
@@ -2211,7 +2397,7 @@ END;`;
</div>
<Tabs
activeKey={activeKey}
onChange={setActiveKey}
onChange={(key) => React.startTransition(() => setActiveKey(key))}
style={{
flex: 1,
minHeight: 0,
@@ -2234,20 +2420,20 @@ END;`;
key: 'indexes',
label: '索引',
children: (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div className="index-table-wrap" style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{!readOnly && (
<div style={{ display: 'flex', gap: 8 }}>
<Button size="small" icon={<PlusOutlined />} disabled={!supportsIndexSchemaOps()} onClick={openCreateIndexModal}></Button>
<Button size="small" icon={<EditOutlined />} disabled={!supportsIndexSchemaOps() || !selectedIndex} onClick={openEditIndexModal}></Button>
<Button size="small" icon={<DeleteOutlined />} danger disabled={!supportsIndexSchemaOps() || !selectedIndex} onClick={handleDeleteIndex}></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>
{!supportsIndexSchemaOps() && (
<span style={{ marginLeft: 'auto', color: '#faad14', fontSize: 12, alignSelf: 'center' }}>
</span>
)}
{supportsIndexSchemaOps() && selectedIndex && (
{supportsIndexSchemaOps() && selectedIndexKeys.length > 0 && (
<span style={{ marginLeft: 'auto', color: '#888', fontSize: 12, alignSelf: 'center' }}>
{selectedIndex.name}
{selectedIndexKeys.length}
</span>
)}
</div>
@@ -2257,75 +2443,22 @@ END;`;
</div>
<Table
dataSource={groupedIndexes}
columns={[
{
title: '索引名',
dataIndex: 'name',
key: 'name',
width: 240,
render: (text: string) => (
<Tooltip title={text}>
<span style={{ display: 'inline-block', maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{text}
</span>
</Tooltip>
),
},
{
title: '字段',
dataIndex: 'columnNames',
key: 'columnNames',
render: (columnNames: string[]) => {
if (!columnNames || columnNames.length === 0) {
return '-';
}
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{columnNames.map((columnName, idx) => (
<Tag key={`${columnName}-${idx}`}>
{columnName}
</Tag>
))}
</div>
);
}
},
{
title: '索引类型',
dataIndex: 'indexType',
key: 'indexType',
width: 140,
render: (text: string) => text || '-',
},
{
title: '唯一性',
dataIndex: 'nonUnique',
key: 'nonUnique',
width: 110,
render: (v: number) => (
<Tag color={v === 0 ? 'gold' : 'default'}>
{v === 0 ? '唯一' : '普通'}
</Tag>
),
},
]}
columns={resizableIndexColumns}
rowKey="key"
size="small"
pagination={false}
loading={loading}
scroll={{ x: 960, y: tableHeight }}
rowSelection={{
type: 'radio',
selectedRowKeys: selectedIndex ? [selectedIndex.key] : [],
onChange: (_, selectedRows) => setSelectedIndex((selectedRows[0] as IndexDisplayRow) || null),
components={{
header: { cell: ResizableTitle },
}}
onRow={(record) => ({
onClick: () => {
if (selectedIndex?.key === record.key) {
setSelectedIndex(null);
} else {
setSelectedIndex(record);
}
setSelectedIndexKeys(prev =>
prev.includes(record.key)
? prev.filter(k => k !== record.key)
: [...prev, record.key]
);
},
style: { cursor: 'pointer' }
})}

View File

@@ -0,0 +1,454 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal } from 'antd';
import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App';
import type { TabData } from '../types';
interface TableOverviewProps {
tab: TabData;
}
interface TableStatRow {
name: string;
comment: string;
rows: number;
dataSize: number;
indexSize: number;
engine: string;
createTime: string;
updateTime: string;
}
type SortField = 'name' | 'rows' | 'dataSize';
type SortOrder = 'asc' | 'desc';
const formatSize = (bytes: number): string => {
if (!bytes || bytes <= 0) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
};
const formatRows = (count: number): string => {
if (count === undefined || count === null || count < 0) return '—';
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
return String(count);
};
const getMetadataDialect = (connType: string, driver?: string): string => {
const type = (connType || '').trim().toLowerCase();
if (type === 'custom') {
const d = (driver || '').trim().toLowerCase();
if (d === 'diros' || d === 'doris') return 'mysql';
return d;
}
if (type === 'mariadb' || type === 'diros' || type === 'sphinx') return 'mysql';
if (type === 'dameng') return 'dm';
return type;
};
const buildTableStatusSQL = (dialect: string, dbName: string, schemaName?: string): string => {
const escapeLiteral = (s: string) => s.replace(/'/g, "''");
switch (dialect) {
case 'mysql':
return `SHOW TABLE STATUS FROM \`${dbName.replace(/`/g, '``')}\``;
case 'postgres':
case 'kingbase':
case 'vastbase': {
const schema = schemaName || 'public';
return `
SELECT
c.relname AS table_name,
obj_description(c.oid, 'pg_class') AS table_comment,
c.reltuples::bigint AS table_rows,
pg_total_relation_size(c.oid) AS data_length,
pg_indexes_size(c.oid) AS index_length
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
AND n.nspname = '${escapeLiteral(schema)}'
ORDER BY c.relname`;
}
case 'sqlserver': {
const safeDB = `[${dbName.replace(/]/g, ']]')}]`;
return `
SELECT
t.name AS table_name,
ep.value AS table_comment,
SUM(p.rows) AS table_rows,
SUM(a.total_pages) * 8 * 1024 AS data_length,
SUM(a.used_pages) * 8 * 1024 AS index_length
FROM ${safeDB}.sys.tables t
LEFT JOIN ${safeDB}.sys.extended_properties ep ON ep.major_id = t.object_id AND ep.minor_id = 0 AND ep.name = 'MS_Description'
LEFT JOIN ${safeDB}.sys.partitions p ON t.object_id = p.object_id AND p.index_id IN (0, 1)
LEFT JOIN ${safeDB}.sys.allocation_units a ON p.partition_id = a.container_id
WHERE t.type = 'U'
GROUP BY t.name, ep.value
ORDER BY t.name`;
}
case 'clickhouse':
return `SELECT name AS table_name, comment AS table_comment, total_rows AS table_rows, total_bytes AS data_length, 0 AS index_length FROM system.tables WHERE database = '${escapeLiteral(dbName)}' AND engine NOT IN ('View', 'MaterializedView') ORDER BY name`;
case 'dm':
case 'oracle': {
const owner = (schemaName || dbName).toUpperCase();
return `SELECT table_name, comments AS table_comment, num_rows AS table_rows, 0 AS data_length, 0 AS index_length FROM all_tab_comments JOIN all_tables USING (table_name, owner) WHERE owner = '${escapeLiteral(owner)}' ORDER BY table_name`;
}
default:
return `SELECT table_name, '' AS table_comment, 0 AS table_rows, 0 AS data_length, 0 AS index_length FROM information_schema.tables WHERE table_schema = '${escapeLiteral(dbName)}' AND table_type = 'BASE TABLE' ORDER BY table_name`;
}
};
const parseTableStats = (dialect: string, rows: Record<string, any>[]): TableStatRow[] => {
return rows.map((row) => {
const get = (keys: string[]): any => {
for (const k of keys) {
for (const rk of Object.keys(row)) {
if (rk.toLowerCase() === k.toLowerCase() && row[rk] !== null && row[rk] !== undefined) return row[rk];
}
}
return undefined;
};
const strVal = (keys: string[]) => String(get(keys) ?? '').trim();
const numVal = (keys: string[]) => {
const v = get(keys);
if (v === null || v === undefined || v === '') return 0;
const n = Number(v);
return isNaN(n) ? 0 : Math.max(0, Math.round(n));
};
return {
name: strVal(['Name', 'table_name', 'tablename', 'TABLE_NAME']),
comment: strVal(['Comment', 'table_comment', 'TABLE_COMMENT', 'comments']),
rows: numVal(['Rows', 'table_rows', 'TABLE_ROWS', 'num_rows', 'reltuples', 'total_rows']),
dataSize: numVal(['Data_length', 'data_length', 'DATA_LENGTH', 'total_bytes']),
indexSize: numVal(['Index_length', 'index_length', 'INDEX_LENGTH']),
engine: strVal(['Engine', 'engine']),
createTime: strVal(['Create_time', 'create_time']),
updateTime: strVal(['Update_time', 'update_time']),
};
}).filter(t => t.name);
};
const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const addTab = useStore(state => state.addTab);
const darkMode = theme === 'dark';
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 connection = useMemo(() => connections.find(c => c.id === tab.connectionId), [connections, tab.connectionId]);
const loadData = useCallback(async () => {
if (!connection) return;
setLoading(true);
try {
const config = {
...connection.config,
port: Number(connection.config.port),
password: connection.config.password || '',
database: connection.config.database || '',
useSSH: connection.config.useSSH || false,
ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
};
const dialect = getMetadataDialect(connection.config.type, (connection.config as any)?.driver);
const sql = buildTableStatusSQL(dialect, tab.dbName || '', (tab as any).schemaName);
const res = await DBQuery(config as any, tab.dbName || '', sql);
if (res.success && Array.isArray(res.data)) {
setTables(parseTableStats(dialect, res.data));
} else {
message.error('获取表信息失败: ' + (res.message || '未知错误'));
}
} catch (e: any) {
message.error('获取表信息失败: ' + (e?.message || String(e)));
} finally {
setLoading(false);
}
}, [connection, tab.dbName]);
useEffect(() => { loadData(); }, [loadData]);
const sortedFiltered = useMemo(() => {
let list = [...tables];
if (searchText.trim()) {
const kw = searchText.trim().toLowerCase();
list = list.filter(t => t.name.toLowerCase().includes(kw) || t.comment.toLowerCase().includes(kw));
}
list.sort((a, b) => {
let cmp = 0;
if (sortField === 'name') cmp = a.name.toLowerCase().localeCompare(b.name.toLowerCase());
else if (sortField === 'rows') cmp = a.rows - b.rows;
else if (sortField === 'dataSize') cmp = a.dataSize - b.dataSize;
return sortOrder === 'asc' ? cmp : -cmp;
});
return list;
}, [tables, searchText, sortField, sortOrder]);
const openTable = useCallback((tableName: string) => {
if (!connection) return;
addTab({
id: `${connection.id}-${tab.dbName}-${tableName}`,
title: tableName,
type: 'table',
connectionId: connection.id,
dbName: tab.dbName,
tableName,
});
}, [connection, tab.dbName, addTab]);
const openDesign = useCallback((tableName: string) => {
if (!connection) return;
addTab({
id: `design-${connection.id}-${tab.dbName}-${tableName}`,
title: `设计表 (${tableName})`,
type: 'design',
connectionId: connection.id,
dbName: tab.dbName,
tableName,
initialTab: 'columns',
readOnly: false,
});
}, [connection, tab.dbName, addTab]);
const buildConfig = useCallback(() => {
if (!connection) return null;
return {
...connection.config,
port: Number(connection.config.port),
password: connection.config.password || '',
database: connection.config.database || '',
useSSH: connection.config.useSSH || false,
ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
};
}, [connection]);
const handleCopyStructure = useCallback(async (tableName: string) => {
const config = buildConfig();
if (!config) return;
const res = await DBShowCreateTable(config as any, tab.dbName || '', tableName);
if (res.success) {
navigator.clipboard.writeText(res.data as string);
message.success('表结构已复制到剪贴板');
} else {
message.error(res.message);
}
}, [buildConfig, tab.dbName]);
const handleExport = useCallback(async (tableName: string, format: string) => {
const config = buildConfig();
if (!config) return;
const hide = message.loading(`正在导出 ${tableName}${format.toUpperCase()}...`, 0);
const res = await ExportTable(config as any, tab.dbName || '', tableName, format);
hide();
if (res.success) {
message.success('导出成功');
} else if (res.message !== '已取消') {
message.error('导出失败: ' + res.message);
}
}, [buildConfig, tab.dbName]);
const handleDeleteTable = useCallback((tableName: string) => {
const config = buildConfig();
if (!config) return;
Modal.confirm({
title: '确认删除表',
content: `确定删除表 "${tableName}" 吗?该操作不可恢复。`,
okButtonProps: { danger: true },
onOk: async () => {
const res = await DropTable(config as any, tab.dbName || '', tableName);
if (res.success) {
message.success('表删除成功');
loadData();
} else {
message.error('删除失败: ' + res.message);
}
},
});
}, [buildConfig, tab.dbName, loadData]);
const handleRenameTable = useCallback((tableName: string) => {
const config = buildConfig();
if (!config) return;
let newName = tableName;
Modal.confirm({
title: '重命名表',
content: (
<Input
defaultValue={tableName}
onChange={e => { newName = e.target.value; }}
placeholder="输入新表名"
autoFocus
style={{ marginTop: 8 }}
/>
),
onOk: async () => {
const trimmed = newName.trim();
if (!trimmed) { message.error('表名不能为空'); return Promise.reject(); }
if (trimmed === tableName) { message.warning('新旧表名相同'); return; }
const res = await RenameTable(config as any, tab.dbName || '', tableName, trimmed);
if (res.success) {
message.success('表重命名成功');
loadData();
} else {
message.error('重命名失败: ' + res.message);
}
},
});
}, [buildConfig, tab.dbName, loadData]);
// --- 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)';
const cardBorder = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)';
const textPrimary = darkMode ? 'rgba(255,255,255,0.88)' : 'rgba(0,0,0,0.88)';
const textSecondary = darkMode ? 'rgba(255,255,255,0.55)' : 'rgba(0,0,0,0.55)';
const textMuted = darkMode ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.35)';
const accentColor = '#1677ff';
const containerBg = darkMode ? 'rgba(0,0,0,0.15)' : 'rgba(0,0,0,0.01)';
const toggleSort = (field: SortField) => {
if (sortField === field) {
setSortOrder(o => o === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder(field === 'name' ? 'asc' : 'desc');
}
};
const sortMenuItems = [
{ key: 'name', label: `按名称${sortField === 'name' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('name') },
{ key: 'rows', label: `按行数${sortField === 'rows' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('rows') },
{ key: 'dataSize', label: `按大小${sortField === 'dataSize' ? (sortOrder === 'asc' ? ' ↑' : ' ↓') : ''}`, onClick: () => toggleSort('dataSize') },
];
const totalRows = tables.reduce((s, t) => s + t.rows, 0);
const totalSize = tables.reduce((s, t) => s + t.dataSize + t.indexSize, 0);
if (loading) {
return (
<div 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' }}>
{/* 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)}
</span>
<div style={{ flex: 1 }} />
<Input
placeholder="搜索表名或注释..."
prefix={<SearchOutlined style={{ color: textMuted }} />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
allowClear
style={{ width: 240 }}
size="small"
/>
<Dropdown menu={{ items: sortMenuItems }} trigger={['click']}>
<Tooltip title="排序"><SortAscendingOutlined style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
</Dropdown>
<Tooltip title="刷新"><ReloadOutlined onClick={loadData} style={{ fontSize: 16, color: textSecondary, cursor: 'pointer' }} /></Tooltip>
</div>
{/* Cards Grid */}
<div style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px 16px' }}>
{sortedFiltered.length === 0 ? (
<Empty description={searchText ? '无匹配结果' : '暂无表'} style={{ marginTop: 80 }} />
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 12,
}}>
{sortedFiltered.map(t => (
<Dropdown
key={t.name}
trigger={['contextMenu']}
menu={{
items: [
{ key: 'new-query', label: '新建查询', icon: <ConsoleSqlOutlined />, onClick: () => {
addTab({
id: `query-${Date.now()}`,
title: '新建查询',
type: 'query',
connectionId: tab.connectionId,
dbName: tab.dbName,
query: `SELECT * FROM ${t.name};`,
});
}},
{ type: 'divider' },
{ key: 'design-table', label: '设计表', icon: <EditOutlined />, onClick: () => openDesign(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: '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') },
]},
],
}}
>
<div
onDoubleClick={() => openTable(t.name)}
style={{
background: cardBg,
border: `1px solid ${cardBorder}`,
borderRadius: 10,
padding: '14px 16px',
cursor: 'pointer',
transition: '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; }}
>
<div 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' }}>
{t.name}
</span>
</Tooltip>
</div>
{t.comment && (
<Tooltip title={t.comment} mouseEnterDelay={0.4}>
<div style={{ fontSize: 12, color: textSecondary, marginBottom: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{t.comment}
</div>
</Tooltip>
)}
<div 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>
</div>
</Dropdown>
))}
</div>
)}
</div>
</div>
);
};
export default TableOverview;

View File

@@ -116,7 +116,7 @@ export interface TriggerDefinition {
export interface TabData {
id: string;
title: string;
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger' | 'view-def' | 'routine-def';
type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'trigger' | 'view-def' | 'routine-def' | 'table-overview';
connectionId: string;
dbName?: string;
tableName?: string;