From 7f00139847ee2265da82ac2a6ad603215dcd969e Mon Sep 17 00:00:00 2001 From: Syngnat Date: Mon, 2 Mar 2026 16:34:09 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(frontend-interact?= =?UTF-8?q?ion):=20=E7=BB=9F=E4=B8=80=E6=A0=87=E7=AD=BE=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E4=B8=8E=E6=9A=97=E8=89=B2=E4=B8=BB=E9=A2=98=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构Tab拖拽排序实现,统一为可配置拖拽引擎 - 规范拖拽与点击事件边界,提升交互一致性 - 统一多组件暗色透明样式策略,减少硬编码色值 - 提升Redis/表格/连接面板在透明模式下的观感一致性 - refs #144 --- frontend/src/App.css | 43 ++++++ frontend/src/App.tsx | 24 ++- frontend/src/components/ConnectionModal.tsx | 6 +- frontend/src/components/DataGrid.tsx | 12 +- frontend/src/components/RedisViewer.tsx | 37 +++-- frontend/src/components/TabManager.tsx | 157 ++++++++++++++++++-- frontend/src/components/TableDesigner.tsx | 3 +- frontend/src/store.ts | 18 +++ 8 files changed, 265 insertions(+), 35 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 713d6b9..72a84c1 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -67,6 +67,49 @@ body[data-theme='dark'] { 在透明窗口环境下会显著加剧 GPU 负载 */ } +/* 暗色 + 透明:提升选中/焦点可读性,避免默认蓝色在半透明背景下发灰 */ +body[data-theme='dark'] .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected, +body[data-theme='dark'] .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected:hover { + background: rgba(246, 196, 83, 0.24) !important; + color: rgba(255, 236, 179, 0.98) !important; +} + +body[data-theme='dark'] .ant-checkbox-checked .ant-checkbox-inner { + background-color: #f6c453 !important; + border-color: #f6c453 !important; +} + +body[data-theme='dark'] .ant-checkbox-indeterminate .ant-checkbox-inner::after { + background-color: #f6c453 !important; +} + +body[data-theme='dark'] .ant-checkbox:hover .ant-checkbox-inner, +body[data-theme='dark'] .ant-checkbox-wrapper:hover .ant-checkbox-inner { + border-color: #f6c453 !important; +} + +body[data-theme='dark'] .ant-radio-checked .ant-radio-inner { + border-color: #f6c453 !important; + background-color: #f6c453 !important; +} + +body[data-theme='dark'] .ant-radio-wrapper:hover .ant-radio-inner, +body[data-theme='dark'] .ant-radio:hover .ant-radio-inner { + border-color: #f6c453 !important; +} + +body[data-theme='dark'] .ant-switch.ant-switch-checked { + background: #d8a93b !important; +} + +body[data-theme='dark'] .ant-table-tbody > tr.ant-table-row-selected > td { + background: rgba(246, 196, 83, 0.18) !important; +} + +body[data-theme='dark'] .ant-table-tbody > tr.ant-table-row-selected:hover > td { + background: rgba(246, 196, 83, 0.26) !important; +} + /* 连接配置弹窗:滚动仅在弹窗 body 内部,不使用外层 wrap 滚动条 */ .connection-modal-wrap { overflow: hidden !important; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8c85a79..9908c88 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -782,6 +782,7 @@ function App() { } as any; const showLinuxResizeHandles = isLinuxRuntime; + const resizeGuideColor = darkMode ? 'rgba(246, 196, 83, 0.55)' : 'rgba(24, 144, 255, 0.5)'; return ( = ({ const rowModBg = darkMode ? getRowBg(22, 34, 56) : getRowBg(230, 247, 255); const rowAddedHover = darkMode ? getRowBg(31, 61, 31) : getRowBg(217, 247, 190); const rowModHover = darkMode ? getRowBg(29, 53, 94) : getRowBg(186, 231, 255); + const selectionAccentHex = darkMode ? '#f6c453' : '#1890ff'; + const selectionAccentRgb = darkMode ? '246, 196, 83' : '24, 144, 255'; const [form] = Form.useForm(); const [modal, contextHolder] = Modal.useModal(); @@ -3224,16 +3226,16 @@ const DataGrid: React.FC = ({ .${gridId} .ant-table-thead > tr > th .ant-table-column-sorter, .${gridId} .ant-table-thead > tr > th .ant-table-column-sorter * { cursor: pointer !important; } .${gridId} .ant-table-tbody > tr:hover > td { background-color: ${darkMode ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.02)'} !important; } - .${gridId} .ant-table-tbody > tr.ant-table-row-selected > td { background-color: ${darkMode ? 'rgba(24, 144, 255, 0.15)' : 'rgba(24, 144, 255, 0.08)'} !important; } - .${gridId} .ant-table-tbody > tr.ant-table-row-selected:hover > td { background-color: ${darkMode ? 'rgba(24, 144, 255, 0.25)' : 'rgba(24, 144, 255, 0.12)'} !important; } + .${gridId} .ant-table-tbody > tr.ant-table-row-selected > td { background-color: ${darkMode ? `rgba(${selectionAccentRgb}, 0.18)` : `rgba(${selectionAccentRgb}, 0.08)`} !important; } + .${gridId} .ant-table-tbody > tr.ant-table-row-selected:hover > td { background-color: ${darkMode ? `rgba(${selectionAccentRgb}, 0.28)` : `rgba(${selectionAccentRgb}, 0.12)`} !important; } .${gridId} .row-added td { background-color: ${rowAddedBg} !important; color: ${darkMode ? '#e6fffb' : 'inherit'}; } .${gridId} .row-modified td { background-color: ${rowModBg} !important; color: ${darkMode ? '#e6f7ff' : 'inherit'}; } .${gridId} .ant-table-tbody > tr.row-added:hover > td { background-color: ${rowAddedHover} !important; } .${gridId} .ant-table-tbody > tr.row-modified:hover > td { background-color: ${rowModHover} !important; } .${gridId}.cell-edit-mode .ant-table-tbody > tr > td[data-col-name] { user-select: none; -webkit-user-select: none; cursor: crosshair; } .${gridId}.cell-edit-mode .ant-table-tbody > tr > td[data-cell-selected="true"] { - box-shadow: inset 0 0 0 2px #1890ff; - background-image: linear-gradient(${darkMode ? 'rgba(24, 144, 255, 0.18)' : 'rgba(24, 144, 255, 0.08)'}, ${darkMode ? 'rgba(24, 144, 255, 0.18)' : 'rgba(24, 144, 255, 0.08)'}); + box-shadow: inset 0 0 0 2px ${selectionAccentHex}; + background-image: linear-gradient(${darkMode ? `rgba(${selectionAccentRgb}, 0.20)` : `rgba(${selectionAccentRgb}, 0.08)`}, ${darkMode ? `rgba(${selectionAccentRgb}, 0.20)` : `rgba(${selectionAccentRgb}, 0.08)`}); } .${gridId} .ant-table-content, .${gridId} .ant-table-body { @@ -3263,7 +3265,7 @@ const DataGrid: React.FC = ({ bottom: 0, // Fits container height left: 0, width: '2px', - background: '#1890ff', + background: selectionAccentHex, zIndex: 9999, display: 'none', pointerEvents: 'none', diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index 23855aa..62eab17 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -5,6 +5,7 @@ import { useStore } from '../store'; import { RedisKeyInfo, RedisValue, StreamEntry } from '../types'; import Editor from '@monaco-editor/react'; import type { DataNode } from 'antd/es/tree'; +import { normalizeOpacityForPlatform } from '../utils/appearance'; const { Search } = Input; @@ -394,8 +395,21 @@ const buildRedisKeyTree = ( }; const RedisViewer: React.FC = ({ connectionId, redisDB }) => { - const { connections } = useStore(); + const connections = useStore(state => state.connections); + const theme = useStore(state => state.theme); + const appearance = useStore(state => state.appearance); + const darkMode = theme === 'dark'; + const opacity = normalizeOpacityForPlatform(appearance.opacity); const connection = connections.find(c => c.id === connectionId); + const keyAccentColor = darkMode ? '#ffd666' : '#1677ff'; + const jsonAccentColor = darkMode ? '#f6c453' : '#1890ff'; + const valueToolbarBg = darkMode + ? `rgba(38, 38, 38, ${opacity})` + : `rgba(245, 245, 245, ${opacity})`; + const valueToolbarBorder = darkMode + ? `1px solid rgba(255, 255, 255, ${Math.max(0.12, Math.min(0.24, opacity * 0.22))})` + : `1px solid rgba(0, 0, 0, ${Math.max(0.08, Math.min(0.2, opacity * 0.12))})`; + const valueToolbarText = darkMode ? 'rgba(255, 255, 255, 0.78)' : '#666'; const [keys, setKeys] = useState([]); const [loading, setLoading] = useState(false); @@ -805,7 +819,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { overflow: 'hidden', }} > - + = ({ connectionId, redisDB }) => {
- + {encoding && `编码: ${encoding}`} setViewMode(e.target.value)}> @@ -920,6 +934,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { = ({ connectionId, redisDB }) => { return ( {tooltipContent}} styles={{ root: { maxWidth: 600 } }}> @@ -1248,7 +1263,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { return ( {tooltipContent}} styles={{ root: { maxWidth: 600 } }}> @@ -1403,7 +1418,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { return ( {tooltipContent}} styles={{ root: { maxWidth: 600 } }}> @@ -1557,7 +1572,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { return ( {tooltipContent}} styles={{ root: { maxWidth: 600 } }}> @@ -1771,7 +1786,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { return ( {tooltipContent}} styles={{ root: { maxWidth: 720 } }}> @@ -1964,6 +1979,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { setEditValue(value || '')} options={{ @@ -2028,6 +2044,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { { jsonEditValueRef.current = value || ''; }} onMount={(editor) => { jsonEditValueRef.current = jsonEditConfig?.value || ''; }} diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index e0a6b48..7f61c83 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -1,6 +1,11 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useRef, useState } from 'react'; import { Tabs, Dropdown } from 'antd'; import type { MenuProps } from 'antd'; +import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core'; +import type { DragStartEvent, DragEndEvent } from '@dnd-kit/core'; +import { SortableContext, useSortable, horizontalListSortingStrategy } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { restrictToHorizontalAxis } from '@dnd-kit/modifiers'; import { useStore } from '../store'; import DataViewer from './DataViewer'; import QueryEditor from './QueryEditor'; @@ -29,6 +34,54 @@ const buildTabDisplayTitle = (tab: TabData, connectionName: string | undefined): return `[${prefix}] ${tab.title}`; }; +type SortableTabLabelProps = { + tabId: string; + displayTitle: string; + menuItems: MenuProps['items']; + draggingTabId: string | null; + onSelect: (tabId: string) => void; +}; + +const SortableTabLabel: React.FC = ({ + tabId, + displayTitle, + menuItems, + draggingTabId, + onSelect, +}) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tabId }); + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition: transition || 'transform 180ms cubic-bezier(0.22, 1, 0.36, 1)', + opacity: isDragging ? 0.88 : 1, + cursor: isDragging ? 'grabbing' : 'grab', + display: 'inline-flex', + alignItems: 'center', + maxWidth: '100%', + touchAction: 'none', + }; + const isDragBlocked = !!draggingTabId && draggingTabId !== tabId; + + return ( + + { + if (!isDragBlocked) onSelect(tabId); + }} + onContextMenu={(e) => e.preventDefault()} + title="拖拽调整标签顺序" + > + {displayTitle} + + + ); +}; + const TabManager: React.FC = () => { const tabs = useStore(state => state.tabs); const connections = useStore(state => state.connections); @@ -39,6 +92,14 @@ const TabManager: React.FC = () => { const closeTabsToLeft = useStore(state => state.closeTabsToLeft); const closeTabsToRight = useStore(state => state.closeTabsToRight); const closeAllTabs = useStore(state => state.closeAllTabs); + const moveTab = useStore(state => state.moveTab); + const [draggingTabId, setDraggingTabId] = useState(null); + const suppressClickUntilRef = useRef(0); + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }) + ); const onChange = (newActiveKey: string) => { setActiveTab(newActiveKey); @@ -50,6 +111,33 @@ const TabManager: React.FC = () => { } }; + const handleTabSelect = (tabId: string) => { + if (Date.now() < suppressClickUntilRef.current) return; + setActiveTab(tabId); + }; + + const handleDragStart = (event: DragStartEvent) => { + const sourceId = String(event.active.id || '').trim(); + setDraggingTabId(sourceId || null); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const sourceId = String(event.active.id || '').trim(); + const targetId = String(event.over?.id || '').trim(); + setDraggingTabId(null); + if (!sourceId || !targetId || sourceId === targetId) { + return; + } + suppressClickUntilRef.current = Date.now() + 120; + moveTab(sourceId, targetId); + }; + + const handleDragCancel = () => { + setDraggingTabId(null); + }; + + const tabIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]); + const items = useMemo(() => tabs.map((tab, index) => { const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name; const displayTitle = buildTabDisplayTitle(tab, connectionName); @@ -100,14 +188,18 @@ const TabManager: React.FC = () => { return { label: ( - - e.preventDefault()}>{displayTitle} - + ), key: tab.id, children: content, }; - }), [tabs, connections, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]); + }), [tabs, connections, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs, draggingTabId]); return ( <> @@ -158,16 +250,53 @@ const TabManager: React.FC = () => { .main-tabs .ant-tabs-nav::before { border-bottom: none !important; } + .main-tabs .ant-tabs-tab { + transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1), background-color 120ms ease; + } + .main-tabs .tab-dnd-label { + user-select: none; + -webkit-user-select: none; + } + .main-tabs .tab-dnd-label.is-dragging { + cursor: grabbing !important; + } + body[data-theme='dark'] .main-tabs .ant-tabs-tab-btn:focus-visible { + outline: none !important; + border-radius: 6px; + box-shadow: 0 0 0 2px rgba(255, 214, 102, 0.72); + background: rgba(255, 214, 102, 0.16); + } + body[data-theme='light'] .main-tabs .ant-tabs-tab-btn:focus-visible { + outline: none !important; + border-radius: 6px; + box-shadow: 0 0 0 2px rgba(9, 109, 217, 0.32); + background: rgba(9, 109, 217, 0.08); + } + 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; + } `} - + + + + + ); }; diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index f5d96c6..f2d6ef6 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -259,6 +259,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { const connections = useStore(state => state.connections); const theme = useStore(state => state.theme); const darkMode = theme === 'dark'; + const resizeGuideColor = darkMode ? '#f6c453' : '#1890ff'; const readOnly = !!tab.readOnly; const [tableHeight, setTableHeight] = useState(500); @@ -1973,7 +1974,7 @@ END;`; bottom: 0, left: 0, width: '2px', - background: '#1890ff', + background: resizeGuideColor, zIndex: 9999, display: 'none', pointerEvents: 'none', diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 88e2d4a..beaea1b 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -337,6 +337,7 @@ interface AppState { closeTabsToRight: (id: string) => void; closeTabsByConnection: (connectionId: string) => void; closeTabsByDatabase: (connectionId: string, dbName: string) => void; + moveTab: (sourceId: string, targetId: string) => void; closeAllTabs: () => void; setActiveTab: (id: string) => void; setActiveContext: (context: { connectionId: string; dbName: string } | null) => void; @@ -571,6 +572,23 @@ export const useStore = create()( }; }), + moveTab: (sourceId, targetId) => set((state) => { + const fromId = String(sourceId || '').trim(); + const toId = String(targetId || '').trim(); + if (!fromId || !toId || fromId === toId) { + return state; + } + const fromIndex = state.tabs.findIndex((tab) => tab.id === fromId); + const toIndex = state.tabs.findIndex((tab) => tab.id === toId); + if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) { + return state; + } + const nextTabs = [...state.tabs]; + const [movingTab] = nextTabs.splice(fromIndex, 1); + nextTabs.splice(toIndex, 0, movingTab); + return { tabs: nextTabs }; + }), + closeAllTabs: () => set(() => ({ tabs: [], activeTabId: null })), setActiveTab: (id) => set({ activeTabId: id }),