From 72de16995a76610143f075894393ca32d0908439 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 19 Mar 2026 10:25:06 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(TableDesigner):=20=E7=B4=A2?= =?UTF-8?q?=E5=BC=95=E8=A1=A8=E6=94=AF=E6=8C=81=E5=88=97=E5=AE=BD=E6=8B=96?= =?UTF-8?q?=E6=8B=BD=E5=92=8CCheckbox=E5=A4=9A=E9=80=89=E5=85=A8=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拖拽调整:索引表header支持拖拽调整列宽,带三角形角标与DataGrid一致 - 多选重构:索引选择从Radio单选改为Checkbox多选,支持全选/取消全选/半选指示 - 选择列固定:Checkbox列固定48px宽度,不参与拖拽resize,header和body对齐一致 - 按钮逻辑:编辑按钮要求恰好选中1个索引,删除按钮要求选中≥1个索引 - 样式优化:索引表header禁用文字选中和光标效果,保持干净交互体验 --- frontend/src/components/TableDesigner.tsx | 279 ++++++++++++++-------- 1 file changed, 176 insertions(+), 103 deletions(-) diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 00e963b..0df2840 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -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'; @@ -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(null); + const [selectedIndexKeys, setSelectedIndexKeys] = useState([]); 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(null); + const shellRef = useRef(null); const pendingFocusColumnKeyRef = useRef(null); const focusHighlightTimerRef = useRef(null); const [focusColumnKey, setFocusColumnKey] = useState(''); @@ -329,7 +330,8 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { // --- Resizable Columns State --- const [tableColumns, setTableColumns] = useState([]); - const resizeDragRef = useRef<{ startX: number; startWidth: number; index: number; containerLeft: number } | null>(null); + const [indexColumns, setIndexColumns] = useState([]); + const resizeDragRef = useRef<{ startX: number; startWidth: number; index: number; containerLeft: number; setter: React.Dispatch> } | null>(null); const resizeRafRef = useRef(null); const latestResizeXRef = useRef(null); const ghostRef = useRef(null); @@ -548,17 +550,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>) => (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 +577,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 +600,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 +1088,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,17 +1171,12 @@ ${selectedTrigger.statement}`; ); useEffect(() => { - if (!selectedIndex) return; - const freshIndex = groupedIndexes.find(idx => idx.key === selectedIndex.key); - if (!freshIndex) { - setSelectedIndex(null); - return; + if (selectedIndexKeys.length === 0) return; + const validKeys = selectedIndexKeys.filter(key => groupedIndexes.some(idx => idx.key === key)); + if (validKeys.length !== selectedIndexKeys.length) { + setSelectedIndexKeys(validKeys); } - // 索引仍存在但内容可能已变(如字段列表),同步为最新对象 - if (freshIndex !== selectedIndex) { - setSelectedIndex(freshIndex); - } - }, [groupedIndexes, selectedIndex]); + }, [groupedIndexes, selectedIndexKeys]); useEffect(() => { if (!selectedForeignKey) return; @@ -2023,6 +2028,109 @@ END;`; }), })); + // --- Index Columns Init --- + useEffect(() => { + setIndexColumns([ + { + title: '索引名', + dataIndex: 'name', + key: 'name', + width: 240, + render: (text: string) => ( + + + {text} + + + ), + }, + { + title: '字段', + dataIndex: 'columnNames', + key: 'columnNames', + width: 320, + render: (columnNames: string[]) => { + if (!columnNames || columnNames.length === 0) { + return '-'; + } + return ( +
+ {columnNames.map((columnName: string, idx: number) => ( + + {columnName} + + ))} +
+ ); + } + }, + { + title: '索引类型', + dataIndex: 'indexType', + key: 'indexType', + width: 140, + render: (text: string) => text || '-', + }, + { + title: '唯一性', + dataIndex: 'nonUnique', + key: 'nonUnique', + width: 110, + render: (v: number) => ( + + {v === 0 ? '唯一' : '普通'} + + ), + }, + ]); + }, []); + + // 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: () => ( + { + setSelectedIndexKeys(e.target.checked ? allIndexKeys : []); + }} + style={{ margin: 0 }} + /> + ), + dataIndex: '_select', + key: '_select', + width: 48, + render: (_: any, record: any) => ( + { + 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 = (
)} -
); return ( -
+
+
+
{!readOnly && (
- - + + {!supportsIndexSchemaOps() && ( 当前数据库暂不支持索引编辑,仅支持查看 )} - {supportsIndexSchemaOps() && selectedIndex && ( + {supportsIndexSchemaOps() && selectedIndexKeys.length > 0 && ( - 已选择:{selectedIndex.name} + 已选择:{selectedIndexKeys.length} 个索引 )}
@@ -2263,75 +2389,22 @@ END;`;
( - - - {text} - - - ), - }, - { - title: '字段', - dataIndex: 'columnNames', - key: 'columnNames', - render: (columnNames: string[]) => { - if (!columnNames || columnNames.length === 0) { - return '-'; - } - return ( -
- {columnNames.map((columnName, idx) => ( - - {columnName} - - ))} -
- ); - } - }, - { - title: '索引类型', - dataIndex: 'indexType', - key: 'indexType', - width: 140, - render: (text: string) => text || '-', - }, - { - title: '唯一性', - dataIndex: 'nonUnique', - key: 'nonUnique', - width: 110, - render: (v: number) => ( - - {v === 0 ? '唯一' : '普通'} - - ), - }, - ]} + 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' } })}