release/0.5.8 (#233)

This commit is contained in:
Syngnat
2026-03-13 15:29:53 +08:00
committed by GitHub
27 changed files with 2019 additions and 573 deletions

1
.gitignore vendored
View File

@@ -17,6 +17,7 @@ dist/
GoNavi-Wails
GoNavi-Wails.exe
.ace-tool/
.superpowers/
.claude/
tmpclaude-*

View File

@@ -1 +1 @@
d0f9366af59a6367ad3c7e2d4185ead4
5b8157374dae5f9340e31b2d0bd2c00e

View File

@@ -37,6 +37,91 @@ body, #root {
padding-right: 8px;
}
.redis-viewer-workbench .ant-tree {
background: transparent;
}
.redis-viewer-workbench .ant-tree .ant-tree-list-holder-inner,
.redis-viewer-workbench .ant-tree .ant-tree-list-holder-inner .ant-tree-treenode {
width: 100% !important;
}
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper {
min-height: 36px;
border-radius: 14px;
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
background: transparent !important;
border: none !important;
box-shadow: none !important;
outline: none !important;
flex: 1 1 auto;
min-width: 0;
width: auto !important;
}
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:hover,
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:active,
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:focus,
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper:focus-visible,
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected,
.redis-viewer-workbench .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-selected:hover {
background: transparent !important;
border-color: transparent !important;
box-shadow: none !important;
outline: none !important;
}
.redis-viewer-workbench .ant-tree .ant-tree-treenode {
padding: 2px 0;
width: 100%;
border-radius: 14px;
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
border: none;
align-items: center;
position: relative;
z-index: 0;
display: flex !important;
box-sizing: border-box;
}
.redis-viewer-workbench .ant-tree .ant-tree-switcher {
width: 0 !important;
min-width: 0 !important;
margin-inline-end: 0 !important;
padding: 0 !important;
overflow: hidden !important;
background: transparent !important;
}
.redis-viewer-workbench .ant-tree .ant-tree-switcher:hover,
.redis-viewer-workbench .ant-tree .ant-tree-switcher:active,
.redis-viewer-workbench .ant-tree .ant-tree-switcher:focus {
background: transparent !important;
}
.redis-viewer-workbench .redis-tree-expander-button:hover,
.redis-viewer-workbench .redis-tree-expander-button:focus-visible {
background: transparent !important;
outline: none;
}
.redis-viewer-workbench .ant-radio-group .ant-radio-button-wrapper {
border-radius: 10px;
margin-inline-end: 6px;
}
.redis-viewer-workbench .ant-radio-group .ant-radio-button-wrapper:last-child {
margin-inline-end: 0;
}
.redis-viewer-workbench .ant-table {
background: transparent;
}
.redis-viewer-workbench .ant-table-wrapper .ant-table-thead > tr > th {
font-weight: 700;
}
/* Scrollbar styling for dark mode */
body[data-theme='dark'] ::-webkit-scrollbar {
width: 10px;
@@ -97,6 +182,16 @@ body[data-theme='dark'] .ant-tree .ant-tree-node-content-wrapper.ant-tree-node-s
color: rgba(255, 236, 179, 0.98) !important;
}
body[data-theme='dark'] .redis-viewer-workbench .ant-tree .ant-tree-treenode:hover {
background: rgba(255, 255, 255, 0.05) !important;
}
body[data-theme='dark'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected,
body[data-theme='dark'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected:hover {
background: linear-gradient(90deg, rgba(246, 196, 83, 0.22), rgba(246, 196, 83, 0.08)) !important;
border: 1px solid rgba(246, 196, 83, 0.24) !important;
}
body[data-theme='dark'] .ant-checkbox-checked .ant-checkbox-inner {
background-color: #f6c453 !important;
border-color: #f6c453 !important;
@@ -135,6 +230,41 @@ body[data-theme='dark'] .ant-table-tbody .ant-table-row.ant-table-row-selected:h
background: rgba(246, 196, 83, 0.26) !important;
}
body[data-theme='dark'] .redis-viewer-workbench .ant-radio-button-wrapper {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.08);
color: rgba(230, 234, 242, 0.9);
}
body[data-theme='dark'] .redis-viewer-workbench .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) {
background: rgba(246, 196, 83, 0.16);
border-color: rgba(246, 196, 83, 0.3);
color: #f6c453;
}
body[data-theme='light'] .redis-viewer-workbench .ant-tree .ant-tree-treenode:hover {
background: rgba(15, 23, 42, 0.04) !important;
}
body[data-theme='light'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected,
body[data-theme='light'] .redis-viewer-workbench .ant-tree .ant-tree-treenode.ant-tree-treenode-selected:hover {
color: rgba(15, 23, 42, 0.92) !important;
background: linear-gradient(90deg, rgba(22, 119, 255, 0.12), rgba(22, 119, 255, 0.04)) !important;
border: 1px solid rgba(22, 119, 255, 0.18) !important;
}
body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper {
background: rgba(255, 255, 255, 0.72);
border-color: rgba(15, 23, 42, 0.08);
color: rgba(51, 65, 85, 0.88);
}
body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) {
background: rgba(22, 119, 255, 0.1);
border-color: rgba(22, 119, 255, 0.22);
color: #1677ff;
}
/* 连接配置弹窗:滚动仅在弹窗 body 内部,不使用外层 wrap 滚动条 */
.connection-modal-wrap {
overflow: hidden !important;

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select } from 'antd';
import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined } from '@ant-design/icons';
import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowMaximise, WindowMinimise, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime';
@@ -12,6 +12,7 @@ import LogPanel from './components/LogPanel';
import { useStore } from './store';
import { SavedConnection } from './types';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance';
import { buildOverlayWorkbenchTheme } from './utils/overlayWorkbenchTheme';
import {
SHORTCUT_ACTION_META,
SHORTCUT_ACTION_ORDER,
@@ -477,15 +478,7 @@ function App() {
justifyContent: 'center',
gap: 6,
}), [blurFilter, darkMode, effectiveUiScale, isOpaqueUtilityMode, utilityButtonBgColor, utilityButtonBorderColor, utilityButtonShadow]);
const utilityDropdownShellStyle = useMemo(() => ({
borderRadius: 14,
padding: 6,
background: darkMode ? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)' : 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
boxShadow: darkMode ? '0 20px 48px rgba(0,0,0,0.32)' : '0 16px 36px rgba(15,23,42,0.12)',
backdropFilter: darkMode ? 'blur(16px)' : 'none',
overflow: 'hidden',
}), [darkMode]);
const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]);
const sidebarQuickActionBaseStyle = useMemo(() => ({
height: Math.max(34, Math.round(36 * effectiveUiScale)),
@@ -517,63 +510,56 @@ function App() {
color: '#2a1f00',
}), [sidebarQuickActionBaseStyle]);
const utilityMenuTheme = useMemo(() => ({
components: {
Menu: {
popupBg: 'transparent',
darkPopupBg: 'transparent',
itemBg: 'transparent',
darkItemBg: 'transparent',
subMenuItemBg: 'transparent',
itemColor: darkMode ? 'rgba(255,255,255,0.88)' : '#162033',
itemHoverColor: darkMode ? '#fff7d6' : '#0f172a',
itemHoverBg: darkMode ? 'rgba(255,214,102,0.10)' : 'rgba(24,144,255,0.08)',
itemSelectedColor: darkMode ? '#ffd666' : '#1677ff',
itemSelectedBg: darkMode ? 'rgba(255,214,102,0.14)' : 'rgba(24,144,255,0.12)',
itemBorderRadius: 10,
itemMarginBlock: 4,
itemMarginInline: 0,
itemPaddingInline: 12,
itemHeight: 40,
groupTitleColor: darkMode ? 'rgba(255,255,255,0.48)' : 'rgba(16,24,40,0.48)',
},
},
}), [darkMode]);
const renderUtilityDropdown = (menu: React.ReactNode) => (
<ConfigProvider theme={utilityMenuTheme}>
<div style={{ ...utilityDropdownShellStyle, minWidth: 220 }}>
{menu}
</div>
</ConfigProvider>
);
const utilityModalShellStyle = useMemo(() => ({
background: darkMode ? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)' : 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
boxShadow: darkMode ? '0 24px 56px rgba(0,0,0,0.32)' : '0 18px 42px rgba(15,23,42,0.12)',
backdropFilter: darkMode ? 'blur(18px)' : 'none',
}), [darkMode]);
background: overlayTheme.shellBg,
border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow,
backdropFilter: overlayTheme.shellBackdropFilter,
}), [overlayTheme]);
const utilityPanelStyle = useMemo(() => ({
padding: 16,
borderRadius: 14,
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.84)',
}), [darkMode]);
border: overlayTheme.sectionBorder,
background: overlayTheme.sectionBg,
}), [overlayTheme]);
const utilityMutedTextStyle = useMemo(() => ({
color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)',
color: overlayTheme.mutedText,
fontSize: 12,
lineHeight: 1.6,
}), [darkMode]);
}), [overlayTheme]);
const renderUtilityModalTitle = (icon: React.ReactNode, title: string, description: string) => (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: darkMode ? 'rgba(255,214,102,0.12)' : 'rgba(24,144,255,0.1)', color: darkMode ? '#ffd666' : '#1677ff', flexShrink: 0 }}>
<div style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
{icon}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: darkMode ? '#f5f7ff' : '#162033' }}>{title}</div>
<div style={{ marginTop: 4, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', fontSize: 12, lineHeight: 1.6 }}>{description}</div>
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>{title}</div>
<div style={{ marginTop: 4, color: overlayTheme.mutedText, fontSize: 12, lineHeight: 1.6 }}>{description}</div>
</div>
</div>
);
const utilityActionCardStyle = useMemo(() => ({
width: '100%',
minHeight: 68,
borderRadius: 14,
border: overlayTheme.sectionBorder,
background: overlayTheme.sectionBg,
color: overlayTheme.titleText,
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
gap: 14,
paddingInline: 16,
boxShadow: 'none',
fontSize: 15,
fontWeight: 600,
}), [overlayTheme]);
const utilityActionHintStyle = useMemo(() => ({
fontSize: 12,
color: overlayTheme.mutedText,
fontWeight: 400,
marginTop: 2,
}), [overlayTheme]);
const sidebarHorizontalPadding = 10;
@@ -975,40 +961,7 @@ function App() {
}
};
const toolsMenu: MenuProps['items'] = [
{
key: 'import',
label: '导入连接配置',
icon: <UploadOutlined />,
onClick: handleImportConnections
},
{
key: 'export',
label: '导出连接配置',
icon: <DownloadOutlined />,
onClick: handleExportConnections
},
{
key: 'sync',
label: '数据同步',
icon: <UploadOutlined rotate={90} />,
onClick: () => setIsSyncModalOpen(true)
},
{
key: 'drivers',
label: '驱动管理',
icon: <SettingOutlined />,
onClick: () => setIsDriverModalOpen(true)
},
{ type: 'divider' },
{
key: 'shortcut-settings',
label: '快捷键管理',
icon: <LinkOutlined />,
onClick: () => setIsShortcutModalOpen(true)
}
];
const [isToolsModalOpen, setIsToolsModalOpen] = useState(false);
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
const [themeModalSection, setThemeModalSection] = useState<'theme' | 'appearance'>('theme');
const [isAppearanceModalOpen, setIsAppearanceModalOpen] = useState(false);
@@ -1493,9 +1446,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%' }}>
<Dropdown menu={{ items: toolsMenu }} placement="bottomLeft" dropdownRender={renderUtilityDropdown}>
<Button type="text" icon={<ToolOutlined />} title="工具" style={utilityButtonStyle}></Button>
</Dropdown>
<Button type="text" icon={<ToolOutlined />} title="工具" style={utilityButtonStyle} onClick={() => setIsToolsModalOpen(true)}></Button>
<Button type="text" icon={<GlobalOutlined />} title="代理" style={utilityButtonStyle} onClick={() => setIsProxyModalOpen(true)}></Button>
<Button type="text" icon={<SkinOutlined />} title="主题" style={utilityButtonStyle} onClick={() => setIsThemeModalOpen(true)}></Button>
<Button type="text" icon={<InfoCircleOutlined />} title="关于" style={utilityButtonStyle} onClick={() => setIsAboutOpen(true)}></Button>
@@ -1589,6 +1540,79 @@ function App() {
initialValues={editingConnection}
onOpenDriverManager={handleOpenDriverManagerFromConnection}
/>
<Modal
title={renderUtilityModalTitle(<ToolOutlined />, '工具中心', '集中处理连接配置、同步、驱动和快捷键相关操作。')}
open={isToolsModalOpen}
onCancel={() => setIsToolsModalOpen(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: 'import',
icon: <UploadOutlined />,
title: '导入连接配置',
description: '从本地文件恢复连接列表。',
onClick: () => {
setIsToolsModalOpen(false);
void handleImportConnections();
},
},
{
key: 'export',
icon: <DownloadOutlined />,
title: '导出连接配置',
description: '导出当前连接与可见配置字段。',
onClick: () => {
setIsToolsModalOpen(false);
void handleExportConnections();
},
},
{
key: 'sync',
icon: <UploadOutlined rotate={90} />,
title: '数据同步',
description: '进入跨源同步工作流。',
onClick: () => {
setIsToolsModalOpen(false);
setIsSyncModalOpen(true);
},
},
{
key: 'drivers',
icon: <SettingOutlined />,
title: '驱动管理',
description: '安装、更新或移除数据库驱动。',
onClick: () => {
setIsToolsModalOpen(false);
setIsDriverModalOpen(true);
},
},
{
key: 'shortcut-settings',
icon: <LinkOutlined />,
title: '快捷键管理',
description: '查看并调整全局快捷键绑定。',
onClick: () => {
setIsToolsModalOpen(false);
setIsShortcutModalOpen(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: 'flex', flexDirection: 'column', alignItems: 'flex-start', minWidth: 0 }}>
<span>{item.title}</span>
<span style={utilityActionHintStyle}>{item.description}</span>
</span>
</Button>
))}
</div>
</Modal>
<DataSyncModal
open={isSyncModalOpen}
onClose={() => setIsSyncModalOpen(false)}

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Select, Alert, Card, Row, Col, Typography, Collapse, Space, Table, Tag } from 'antd';
import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled, LinkOutlined, EditOutlined, AppstoreOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types';
@@ -157,6 +158,7 @@ const ConnectionModal: React.FC<{
const step1SidebarDividerColor = darkMode ? STEP1_SIDEBAR_DIVIDER_DARK : STEP1_SIDEBAR_DIVIDER_LIGHT;
const step1SidebarActiveBg = darkMode ? 'rgba(246, 196, 83, 0.20)' : '#e6f4ff';
const step1SidebarActiveColor = darkMode ? '#ffd666' : '#1677ff';
const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]);
const tunnelSectionStyle: React.CSSProperties = {
padding: '12px',
@@ -168,35 +170,33 @@ const ConnectionModal: React.FC<{
const modalShellStyle = useMemo(() => ({
background: darkMode
? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)'
: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
boxShadow: darkMode ? '0 24px 56px rgba(0,0,0,0.38)' : '0 18px 42px rgba(15,23,42,0.12)',
backdropFilter: darkMode ? 'blur(18px)' : 'none',
}), [darkMode]);
background: overlayTheme.shellBg,
border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow,
backdropFilter: overlayTheme.shellBackdropFilter,
}), [overlayTheme]);
const modalInnerSectionStyle = useMemo(() => ({
padding: 14,
borderRadius: 14,
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.84)',
}), [darkMode]);
border: overlayTheme.sectionBorder,
background: overlayTheme.sectionBg,
}), [overlayTheme]);
const modalMutedTextStyle = useMemo(() => ({
color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)',
color: overlayTheme.mutedText,
fontSize: 12,
lineHeight: 1.6,
}), [darkMode]);
}), [overlayTheme]);
const renderConnectionModalTitle = (icon: React.ReactNode, title: string, description: string) => (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: darkMode ? 'rgba(255,214,102,0.12)' : 'rgba(24,144,255,0.1)', color: darkMode ? '#ffd666' : '#1677ff', flexShrink: 0 }}>
<div style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
{icon}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: darkMode ? '#f5f7ff' : '#162033' }}>{title}</div>
<div style={{ marginTop: 4, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', fontSize: 12, lineHeight: 1.6 }}>{description}</div>
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>{title}</div>
<div style={{ marginTop: 4, color: overlayTheme.mutedText, fontSize: 12, lineHeight: 1.6 }}>{description}</div>
</div>
</div>
);

View File

@@ -31,6 +31,7 @@ import 'react-resizable/css/styles.css';
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { calculateTableBodyBottomPadding } from './dataGridLayout';
// --- Error Boundary ---
interface DataGridErrorBoundaryState {
@@ -919,12 +920,14 @@ const DataGrid: React.FC<DataGridProps> = ({
const toolbarBottomPadding = 6;
const filterTopPadding = 2;
const panelFrameColor = darkMode ? 'rgba(0, 0, 0, 0.42)' : 'rgba(0, 0, 0, 0.18)';
const floatingScrollbarGap = 6;
const floatingScrollbarGap = 8;
const floatingScrollbarBottomOffset = 0;
const floatingScrollbarInset = 10;
const floatingScrollbarHeight = 10;
const floatingScrollbarThumbBg = darkMode ? 'rgba(255,255,255,0.34)' : 'rgba(0,0,0,0.22)';
const floatingScrollbarThumbBorderColor = darkMode ? 'rgba(255,255,255,0.10)' : 'rgba(255,255,255,0.32)';
const floatingScrollbarThumbShadow = darkMode ? '0 4px 12px rgba(0,0,0,0.28)' : '0 4px 10px rgba(0,0,0,0.12)';
const floatingScrollbarThumbBg = darkMode ? 'rgba(255,255,255,0.68)' : 'rgba(0,0,0,0.44)';
const floatingScrollbarThumbBorderColor = darkMode ? 'rgba(255,255,255,0.26)' : 'rgba(255,255,255,0.52)';
const floatingScrollbarThumbShadow = darkMode ? '0 4px 14px rgba(0,0,0,0.42)' : '0 4px 10px rgba(0,0,0,0.20)';
const verticalScrollbarTrackBg = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
const horizontalScrollbarTrackBg = 'transparent';
const horizontalScrollbarTrackBorderColor = 'transparent';
const horizontalScrollbarTrackShadow = 'none';
@@ -1012,6 +1015,7 @@ const DataGrid: React.FC<DataGridProps> = ({
// 批量编辑模式状态
const [cellEditMode, setCellEditMode] = useState(false);
const [selectedCells, setSelectedCells] = useState<Set<string>>(new Set());
const [copiedCellPatch, setCopiedCellPatch] = useState<{ sourceRowKey: string; values: Record<string, any> } | null>(null);
const [batchEditModalOpen, setBatchEditModalOpen] = useState(false);
const [batchEditValue, setBatchEditValue] = useState('');
const [batchEditSetNull, setBatchEditSetNull] = useState(false);
@@ -1309,19 +1313,33 @@ const DataGrid: React.FC<DataGridProps> = ({
const rawHeaderHeight = headerEl ? headerEl.getBoundingClientRect().height : NaN;
const headerHeight =
Number.isFinite(rawHeaderHeight) && rawHeaderHeight >= 24 && rawHeaderHeight <= 120 ? rawHeaderHeight : 42;
const paginationEl = target.querySelector('.data-grid-pagination-wrap') as HTMLElement | null;
const rawPaginationHeight = paginationEl ? paginationEl.getBoundingClientRect().height : 0;
const paginationHeight =
Number.isFinite(rawPaginationHeight) && rawPaginationHeight > 0 ? rawPaginationHeight : 0;
const bodyEl = target.querySelector('.ant-table-body') as HTMLElement | null;
const virtualHolderEl = target.querySelector('.rc-virtual-list-holder') as HTMLElement | null;
const scrollableEl = virtualHolderEl || bodyEl;
const virtualBodyEl = target.querySelector('.ant-table-tbody-virtual-holder') as HTMLElement | null;
const rcVirtualHolderEl = target.querySelector('.rc-virtual-list-holder') as HTMLElement | null;
const virtualScrollbarEl = target.querySelector('.ant-table-tbody-virtual-scrollbar-horizontal') as HTMLElement | null;
const scrollableEl = virtualBodyEl || rcVirtualHolderEl || bodyEl;
const hasHorizontalOverflow = !!scrollableEl && (scrollableEl.scrollWidth - scrollableEl.clientWidth > 1);
// 外部横向滚动条采用悬浮覆盖,不再通过压缩表格高度制造独立底部空白层
// 只给 body 增加底部内边距,确保最后一行可以完整滚到胶囊条上方
const nextBodyBottomPadding = hasHorizontalOverflow
? floatingScrollbarHeight + floatingScrollbarGap + 4
: 0;
// 普通表格可通过 body 底部内边距避开悬浮横向滚动条
// 但虚拟表格的内部横向滚动轨道会直接覆盖在可视区底部,需要同时从 y 高度里扣掉安全区
const nextBodyBottomPadding = calculateTableBodyBottomPadding({
hasHorizontalOverflow,
floatingScrollbarHeight,
floatingScrollbarGap,
});
setTableBodyBottomPadding(nextBodyBottomPadding);
const extraBottom = 2;
const nextHeight = Math.max(100, Math.floor(height - headerHeight - extraBottom));
const virtualScrollbarViewportReserve = hasHorizontalOverflow && !!virtualScrollbarEl
? Math.ceil(virtualScrollbarEl.getBoundingClientRect().height || (floatingScrollbarHeight + floatingScrollbarGap + 4))
: 0;
const nextHeight = Math.max(
100,
Math.floor(height - headerHeight - paginationHeight - extraBottom - virtualScrollbarViewportReserve)
);
setTableHeight(nextHeight);
}, [floatingScrollbarGap, floatingScrollbarHeight]);
@@ -1407,6 +1425,7 @@ const DataGrid: React.FC<DataGridProps> = ({
setModifiedRows({});
setDeletedRowKeys(new Set());
setSelectedRowKeys([]);
setCopiedCellPatch(null);
setRowEditorOpen(false);
setRowEditorRowKey('');
rowEditorBaseRawRef.current = {};
@@ -1775,6 +1794,163 @@ const DataGrid: React.FC<DataGridProps> = ({
};
}, [cellEditMode, displayColumnNames, columnIndexMap, updateCellSelection]);
const handleCopySelectedColumnsFromRow = useCallback(() => {
const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells;
if (activeSelection.size === 0) {
void message.info('请先在同一行选中要复制的单元格');
return;
}
const parsed = Array.from(activeSelection)
.map((cellKey) => splitCellKey(cellKey))
.filter((item): item is { rowKey: string; colName: string } => !!item);
if (parsed.length === 0) {
void message.info('未识别到可复制的单元格');
return;
}
const sourceRowKeySet = new Set(parsed.map((item) => item.rowKey));
if (sourceRowKeySet.size !== 1) {
void message.info('复制列值时请只选择同一行的单元格');
return;
}
const sourceRowKey = parsed[0].rowKey;
const selectedColumnNames = Array.from(new Set(parsed.map((item) => item.colName)));
if (selectedColumnNames.length === 0) {
void message.info('未识别到可复制的列');
return;
}
const sourceBaseRow = displayDataRef.current.find((row) => {
const key = row?.[GONAVI_ROW_KEY];
return key !== undefined && key !== null && rowKeyStr(key) === sourceRowKey;
});
const sourceAddedRow = addedRows.find((row) => {
const key = row?.[GONAVI_ROW_KEY];
return key !== undefined && key !== null && rowKeyStr(key) === sourceRowKey;
});
const sourceModified = modifiedRows[sourceRowKey];
const values: Record<string, any> = {};
selectedColumnNames.forEach((colName) => {
if (sourceAddedRow) {
values[colName] = sourceAddedRow[colName];
return;
}
if (sourceModified && Object.prototype.hasOwnProperty.call(sourceModified as any, colName)) {
values[colName] = (sourceModified as any)[colName];
return;
}
values[colName] = sourceBaseRow?.[colName];
});
setCopiedCellPatch({ sourceRowKey, values });
void message.success(`已复制 ${selectedColumnNames.length} 列,可粘贴到目标行`);
}, [selectedCells, rowKeyStr, addedRows, modifiedRows]);
const handlePasteCopiedColumnsToSelectedRows = useCallback((fallbackRowKey?: React.Key) => {
if (!copiedCellPatch || Object.keys(copiedCellPatch.values).length === 0) {
void message.info('请先复制列值');
return;
}
const targetKeySet = new Set<string>();
const selectedKeys = selectedRowKeysRef.current;
if (selectedKeys.length > 0) {
selectedKeys.forEach((key) => targetKeySet.add(rowKeyStr(key)));
} else if (fallbackRowKey !== undefined && fallbackRowKey !== null) {
targetKeySet.add(rowKeyStr(fallbackRowKey));
} else {
void message.info('请先选择目标行');
return;
}
targetKeySet.delete(copiedCellPatch.sourceRowKey);
if (targetKeySet.size === 0) {
void message.info('目标行不能仅为源行,请选择其他行');
return;
}
const addedRowMap = new Map<string, any>();
addedRows.forEach((row) => {
const key = row?.[GONAVI_ROW_KEY];
if (key === undefined || key === null) return;
addedRowMap.set(rowKeyStr(key), row);
});
const baseRowMap = new Map<string, any>();
displayDataRef.current.forEach((row) => {
const key = row?.[GONAVI_ROW_KEY];
if (key === undefined || key === null) return;
baseRowMap.set(rowKeyStr(key), row);
});
const patchesByRow = new Map<string, Record<string, any>>();
let updatedCellCount = 0;
targetKeySet.forEach((targetRowKey) => {
const patch: Record<string, any> = {};
const existing = modifiedRows[targetRowKey];
const addedRow = addedRowMap.get(targetRowKey);
const baseRow = baseRowMap.get(targetRowKey);
Object.entries(copiedCellPatch.values).forEach(([colName, nextValue]) => {
let currentValue: any;
if (addedRow) {
currentValue = addedRow[colName];
} else if (existing && Object.prototype.hasOwnProperty.call(existing as any, GONAVI_ROW_KEY)) {
currentValue = (existing as any)[colName];
} else if (existing && Object.prototype.hasOwnProperty.call(existing as any, colName)) {
currentValue = (existing as any)[colName];
} else {
currentValue = baseRow?.[colName];
}
if (isCellValueEqualForDiff(currentValue, nextValue)) return;
patch[colName] = nextValue;
updatedCellCount++;
});
if (Object.keys(patch).length > 0) {
patchesByRow.set(targetRowKey, patch);
}
});
if (patchesByRow.size === 0 || updatedCellCount === 0) {
void message.info('目标行无需更新');
return;
}
setAddedRows(prev => prev.map((row) => {
const key = row?.[GONAVI_ROW_KEY];
if (key === undefined || key === null) return row;
const patch = patchesByRow.get(rowKeyStr(key));
if (!patch) return row;
return { ...row, ...patch };
}));
setModifiedRows(prev => {
let next: Record<string, any> | null = null;
patchesByRow.forEach((patch, keyStr) => {
if (addedRowMap.has(keyStr)) return;
const existing = prev[keyStr];
const merged = existing ? { ...(existing as any), ...patch } : patch;
if (!next) next = { ...prev };
next[keyStr] = merged;
});
return next || prev;
});
void message.success(`已粘贴到 ${patchesByRow.size} 行,共 ${updatedCellCount} 个单元格`);
setCellContextMenu(prev => ({ ...prev, visible: false }));
}, [copiedCellPatch, addedRows, modifiedRows, rowKeyStr]);
// 批量填充到选中行
const handleBatchFillToSelected = useCallback((sourceRecord: Item, dataIndex: string) => {
const sourceValue = sourceRecord[dataIndex];
@@ -3083,7 +3259,7 @@ const DataGrid: React.FC<DataGridProps> = ({
// macOS 在“自动隐藏滚动条”模式下容易误判为无横向滚动,预留 2px 触发稳定滚动轨道。
return Math.max(baseWidth, tableViewportWidth + 2);
}, [totalWidth, isMacLike, tableViewportWidth]);
const horizontalScrollVisible = viewMode === 'table' && !enableVirtual && tableScrollX > tableViewportWidth + 1;
const horizontalScrollVisible = viewMode === 'table' && tableScrollX > tableViewportWidth + 1;
const horizontalScrollWidth = Math.max(externalScrollbarMinWidth, tableScrollX);
const tableScrollConfig = useMemo(() => ({ x: tableScrollX, y: tableHeight }), [tableScrollX, tableHeight]);
const tableComponents = useMemo(() => {
@@ -3100,11 +3276,41 @@ const DataGrid: React.FC<DataGridProps> = ({
}, [enableInlineEditableCell, useContextMenuRow]);
const tableOnRow = useMemo(() => (useContextMenuRow ? rowPropsFactory : undefined), [useContextMenuRow, rowPropsFactory]);
const resolveVirtualHorizontalElements = useCallback((tableContainer: HTMLElement) => {
const holderEl = tableContainer.querySelector('.ant-table-tbody-virtual-holder') as HTMLElement | null;
const innerEl = holderEl?.querySelector('.ant-table-tbody-virtual-holder-inner') as HTMLElement | null;
const headerEl = tableContainer.querySelector('.ant-table-header') as HTMLElement | null;
return { holderEl, innerEl, headerEl };
}, []);
const readVirtualHorizontalOffset = useCallback((tableContainer: HTMLElement): number => {
const { innerEl, headerEl } = resolveVirtualHorizontalElements(tableContainer);
const marginLeft = innerEl ? Math.abs(parseFloat(innerEl.style.marginLeft) || 0) : 0;
const headerLeft = headerEl ? Math.max(0, headerEl.scrollLeft) : 0;
return Math.max(marginLeft, headerLeft);
}, [resolveVirtualHorizontalElements]);
const applyVirtualHorizontalOffset = useCallback((tableContainer: HTMLElement, nextOffset: number) => {
const { holderEl, innerEl, headerEl } = resolveVirtualHorizontalElements(tableContainer);
if (!(holderEl instanceof HTMLElement) || !(innerEl instanceof HTMLElement)) {
return false;
}
const maxScroll = Math.max(0, tableScrollX - holderEl.clientWidth);
const clampedOffset = Math.max(0, Math.min(maxScroll, nextOffset));
innerEl.style.marginLeft = `${-clampedOffset}px`;
if (headerEl) {
headerEl.scrollLeft = clampedOffset;
}
return true;
}, [resolveVirtualHorizontalElements, tableScrollX]);
const pickHorizontalScrollTargets = useCallback((tableContainer: HTMLElement): HTMLElement[] => {
const virtualBody = tableContainer.querySelector('.ant-table-tbody-virtual-holder');
const body = tableContainer.querySelector('.ant-table-body');
const content = tableContainer.querySelector('.ant-table-content');
const virtualHolder = tableContainer.querySelector('.rc-virtual-list-holder');
const candidates = [virtualHolder, body, content].filter((node): node is HTMLElement => node instanceof HTMLElement);
const candidates = [virtualBody, virtualHolder, body, content].filter((node): node is HTMLElement => node instanceof HTMLElement);
if (candidates.length === 0) {
return [];
}
@@ -3124,6 +3330,19 @@ const DataGrid: React.FC<DataGridProps> = ({
if (!(externalScroll instanceof HTMLDivElement) || horizontalSyncSourceRef.current === 'external') {
return;
}
const tableContainer = tableContainerRef.current;
if (enableVirtual && tableContainer instanceof HTMLElement) {
const nextScrollLeft = readVirtualHorizontalOffset(tableContainer);
if (Math.abs(lastTableScrollLeftRef.current - nextScrollLeft) < 1 && Math.abs(externalScroll.scrollLeft - nextScrollLeft) < 1) {
return;
}
lastTableScrollLeftRef.current = nextScrollLeft;
if (Math.abs(externalScroll.scrollLeft - nextScrollLeft) > 1) {
externalScroll.scrollLeft = nextScrollLeft;
lastExternalScrollLeftRef.current = nextScrollLeft;
}
return;
}
const nextTargets = targets && targets.length > 0 ? targets : tableScrollTargetsRef.current;
if (!nextTargets || nextTargets.length === 0) {
return;
@@ -3141,7 +3360,7 @@ const DataGrid: React.FC<DataGridProps> = ({
externalScroll.scrollLeft = nextScrollLeft;
lastExternalScrollLeftRef.current = nextScrollLeft;
}
}, []);
}, [enableVirtual, readVirtualHorizontalOffset]);
const applyExternalScrollToTableTargets = useCallback(() => {
const externalScroll = externalHorizontalScrollRef.current;
@@ -3163,6 +3382,14 @@ const DataGrid: React.FC<DataGridProps> = ({
lastExternalScrollLeftRef.current = externalScroll.scrollLeft;
horizontalSyncSourceRef.current = 'external';
const tableContainer = tableContainerRef.current;
if (enableVirtual && tableContainer instanceof HTMLElement) {
if (applyVirtualHorizontalOffset(tableContainer, externalScroll.scrollLeft)) {
lastTableScrollLeftRef.current = externalScroll.scrollLeft;
}
horizontalSyncSourceRef.current = '';
return;
}
liveTargets.forEach((target) => {
if (target.scrollWidth <= target.clientWidth + 1) {
return;
@@ -3173,9 +3400,9 @@ const DataGrid: React.FC<DataGridProps> = ({
});
lastTableScrollLeftRef.current = externalScroll.scrollLeft;
horizontalSyncSourceRef.current = '';
}, []);
}, [applyVirtualHorizontalOffset, enableVirtual]);
// 非虚拟模式:外部水平滚动条的 wheel 处理(通过原生事件绑定,确保 preventDefault 生效)
// 外部水平滚动条的 wheel 处理(通过原生事件绑定,确保 preventDefault 生效)
useEffect(() => {
const externalScroll = externalHorizontalScrollRef.current;
if (!externalScroll || !horizontalScrollVisible) return;
@@ -3200,10 +3427,10 @@ const DataGrid: React.FC<DataGridProps> = ({
};
}, [horizontalScrollVisible]);
// 非虚拟模式:支持在数据区直接使用触摸板/Shift+滚轮进行横向滚动。
// 某些平台在表格内容未铺满一页时,不会把水平手势正确路由到表格 body导致只能在表头/底部滚动条区域滚动
// 支持在数据区直接使用触摸板/Shift+滚轮进行横向滚动。
// 虚拟表格与普通表格统一走外部横向滚动条,避免内部轨道覆盖最后一行
useEffect(() => {
if (viewMode !== 'table' || enableVirtual) return;
if (viewMode !== 'table') return;
const container = tableContainerRef.current;
if (!(container instanceof HTMLElement)) return;
@@ -3230,20 +3457,47 @@ const DataGrid: React.FC<DataGridProps> = ({
if (!isTableDataAreaTarget(event.target)) return;
const targets = pickHorizontalScrollTargets(container);
const activeTarget = targets.find((target) => target.scrollWidth > target.clientWidth + 1) || targets[0];
if (!(activeTarget instanceof HTMLElement)) return;
const maxScrollLeft = Math.max(0, activeTarget.scrollWidth - activeTarget.clientWidth);
if (maxScrollLeft <= 0) return;
const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, activeTarget.scrollLeft + horizontalDelta));
if (Math.abs(nextScrollLeft - activeTarget.scrollLeft) < 1) return;
event.preventDefault();
event.stopPropagation();
horizontalSyncSourceRef.current = 'table';
activeTarget.scrollLeft = nextScrollLeft;
let nextScrollLeft = 0;
if (enableVirtual) {
const currentOffset = readVirtualHorizontalOffset(container);
const { holderEl } = resolveVirtualHorizontalElements(container);
if (!(holderEl instanceof HTMLElement)) {
horizontalSyncSourceRef.current = '';
return;
}
const maxScrollLeft = Math.max(0, tableScrollX - holderEl.clientWidth);
if (maxScrollLeft <= 0) {
horizontalSyncSourceRef.current = '';
return;
}
nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, currentOffset + horizontalDelta));
if (Math.abs(nextScrollLeft - currentOffset) < 1) {
horizontalSyncSourceRef.current = '';
return;
}
applyVirtualHorizontalOffset(container, nextScrollLeft);
} else {
const activeTarget = targets.find((target) => target.scrollWidth > target.clientWidth + 1) || targets[0];
if (!(activeTarget instanceof HTMLElement)) {
horizontalSyncSourceRef.current = '';
return;
}
const maxScrollLeft = Math.max(0, activeTarget.scrollWidth - activeTarget.clientWidth);
if (maxScrollLeft <= 0) {
horizontalSyncSourceRef.current = '';
return;
}
nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, activeTarget.scrollLeft + horizontalDelta));
if (Math.abs(nextScrollLeft - activeTarget.scrollLeft) < 1) {
horizontalSyncSourceRef.current = '';
return;
}
activeTarget.scrollLeft = nextScrollLeft;
}
lastTableScrollLeftRef.current = nextScrollLeft;
const externalScroll = externalHorizontalScrollRef.current;
@@ -3258,13 +3512,13 @@ const DataGrid: React.FC<DataGridProps> = ({
return () => {
container.removeEventListener('wheel', handleContainerHorizontalWheel, { capture: true } as EventListenerOptions);
};
}, [viewMode, enableVirtual, pickHorizontalScrollTargets]);
}, [applyVirtualHorizontalOffset, enableVirtual, pickHorizontalScrollTargets, readVirtualHorizontalOffset, resolveVirtualHorizontalElements, tableScrollX, viewMode]);
useEffect(() => {
if (viewMode !== 'table') return;
const rafId = requestAnimationFrame(() => recalculateTableMetrics(containerRef.current));
return () => cancelAnimationFrame(rafId);
}, [viewMode, totalWidth, mergedDisplayData.length, recalculateTableMetrics]);
}, [viewMode, totalWidth, mergedDisplayData.length, pagination?.total, pagination?.pageSize, recalculateTableMetrics]);
useEffect(() => {
if (viewMode !== 'table' || !onScrollSnapshotChange) return;
@@ -3356,71 +3610,6 @@ const DataGrid: React.FC<DataGridProps> = ({
return () => cancelAnimationFrame(rafId);
}, [viewMode, mergedDisplayData.length, scrollSnapshot, pickHorizontalScrollTargets, pickVerticalScrollTarget, onScrollSnapshotChange]);
// 虚拟模式下,在容器级别监听 wheel 事件,当鼠标在底部水平滚动条区域时拦截并转为水平滚动
useEffect(() => {
if (viewMode !== 'table' || !enableVirtual) return;
const container = tableContainerRef.current;
if (!container) return;
// 滚动条区域高度:滚动条高度 + 间距 + 容错
const scrollbarZoneHeight = floatingScrollbarHeight + floatingScrollbarGap + 8;
const handleContainerWheel = (e: WheelEvent) => {
// 判断鼠标是否在底部滚动条区域
const containerRect = container.getBoundingClientRect();
if (e.clientY < containerRect.bottom - scrollbarZoneHeight) return;
// 适配 antd 的虚拟列表类名
const holderEl = container.querySelector('.ant-table-tbody-virtual-holder') as HTMLElement | null;
const innerEl = holderEl?.querySelector('.ant-table-tbody-virtual-holder-inner') as HTMLElement | null;
if (!innerEl || !holderEl) return;
const dominantDelta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
if (Math.abs(dominantDelta) < 0.5) return;
e.preventDefault();
e.stopPropagation();
// 读取当前 marginLeft负值表示向右偏移
const currentMarginLeft = parseFloat(innerEl.style.marginLeft) || 0;
const contentWidth = tableScrollX;
const viewportWidth = holderEl.clientWidth;
const maxScroll = Math.max(0, contentWidth - viewportWidth);
const currentOffset = Math.abs(currentMarginLeft);
const newOffset = Math.min(maxScroll, Math.max(0, currentOffset + dominantDelta));
// 直接更新内容位置
innerEl.style.marginLeft = `${-newOffset}px`;
// 同步 scrollbar thumb 位置
const scrollbarEl = container.querySelector('.ant-table-tbody-virtual-scrollbar-horizontal') as HTMLElement | null;
if (scrollbarEl && maxScroll > 0) {
const thumbEl = scrollbarEl.querySelector('[class*="scrollbar-thumb"]') as HTMLElement | null;
if (thumbEl) {
const ratio = newOffset / maxScroll;
const thumbWidth = parseFloat(thumbEl.style.width) || thumbEl.offsetWidth;
const trackWidth = scrollbarEl.clientWidth;
const thumbMaxOffset = trackWidth - thumbWidth;
thumbEl.style.left = `${ratio * thumbMaxOffset}px`;
}
}
// 同步表头水平位置
const headerEl = container.querySelector('.ant-table-header') as HTMLElement | null;
if (headerEl) {
headerEl.scrollLeft = newOffset;
}
};
container.addEventListener('wheel', handleContainerWheel, { passive: false, capture: true });
return () => {
container.removeEventListener('wheel', handleContainerWheel, { capture: true } as EventListenerOptions);
};
}, [viewMode, enableVirtual, tableScrollX, floatingScrollbarHeight, floatingScrollbarGap]);
useEffect(() => {
if (viewMode !== 'table') return;
const tableContainer = tableContainerRef.current;
@@ -3576,15 +3765,35 @@ const DataGrid: React.FC<DataGridProps> = ({
{cellEditMode && selectedCells.size > 0 && (
<>
<Button
type="primary"
onClick={() => {
setBatchEditValue('');
setBatchEditSetNull(false);
icon={<CopyOutlined />}
onClick={handleCopySelectedColumnsFromRow}
>
({selectedCells.size})
</Button>
<Button
type="primary"
onClick={() => {
setBatchEditValue('');
setBatchEditSetNull(false);
setBatchEditModalOpen(true);
}}
>
({selectedCells.size})
</Button>
</>
)}
{cellEditMode && copiedCellPatch && (
<>
<Button
icon={<VerticalAlignBottomOutlined />}
disabled={selectedRowKeys.length === 0}
onClick={() => handlePasteCopiedColumnsToSelectedRows()}
>
({selectedCells.size})
({selectedRowKeys.length})
</Button>
<span style={{ fontSize: '12px', color: '#888' }}>
{Object.keys(copiedCellPatch.values).length}
</span>
</>
)}
<div style={{ width: 1, background: toolbarDividerColor, height: 20, margin: '0 8px' }} />
@@ -3786,7 +3995,7 @@ const DataGrid: React.FC<DataGridProps> = ({
)}
</div>
<div ref={containerRef} style={{ flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, background: bgContent, borderRadius: panelRadius, border: `1px solid ${panelFrameColor}`, boxSizing: 'border-box' }}>
<div ref={containerRef} style={{ flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, display: 'flex', flexDirection: 'column', background: bgContent, borderRadius: panelRadius, border: `1px solid ${panelFrameColor}`, boxSizing: 'border-box' }}>
{contextHolder}
<Modal
title="编辑行"
@@ -3937,7 +4146,13 @@ const DataGrid: React.FC<DataGridProps> = ({
<div
ref={tableContainerRef}
className={`data-grid-table-wrap${horizontalScrollVisible ? ' data-grid-table-wrap-external-active' : ''}`}
style={{ height: '100%', minHeight: 0, position: 'relative' }}
style={{
flex: '1 1 auto',
minHeight: 0,
position: 'relative',
boxSizing: 'border-box',
paddingBottom: enableVirtual ? tableBodyBottomPadding : 0,
}}
>
<Form component={false} form={form}>
<DataContext.Provider value={dataContextValue}>
@@ -4105,6 +4320,26 @@ const DataGrid: React.FC<DataGridProps> = ({
<VerticalAlignBottomOutlined style={{ marginRight: 8 }} />
({selectedRowKeys.length})
</div>
<div
style={{
padding: '8px 12px',
cursor: copiedCellPatch ? 'pointer' : 'not-allowed',
transition: 'background 0.2s',
opacity: copiedCellPatch ? 1 : 0.5,
}}
onMouseEnter={(e) => {
if (copiedCellPatch) e.currentTarget.style.background = darkMode ? '#303030' : '#f5f5f5';
}}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
onClick={() => {
if (!copiedCellPatch) return;
const fallbackKey = cellContextMenu.record?.[GONAVI_ROW_KEY];
handlePasteCopiedColumnsToSelectedRows(fallbackKey);
}}
>
<VerticalAlignBottomOutlined style={{ marginRight: 8 }} />
</div>
<div style={{ height: 1, background: darkMode ? '#303030' : '#f0f0f0', margin: '4px 0' }} />
</>
)}
@@ -4244,7 +4479,7 @@ const DataGrid: React.FC<DataGridProps> = ({
</div>
{pagination && (
<div style={{ padding: '12px 0 0', borderTop: 'none', display: 'flex', justifyContent: 'flex-end' }}>
<div className="data-grid-pagination-wrap" style={{ padding: '12px 0 0', borderTop: 'none', display: 'flex', justifyContent: 'flex-end' }}>
<div className="data-grid-pagination-shell">
<div className="data-grid-pagination-summary" aria-live="polite">
<span className="data-grid-pagination-kicker"></span>
@@ -4357,6 +4592,16 @@ const DataGrid: React.FC<DataGridProps> = ({
box-sizing: border-box;
scroll-padding-bottom: ${tableBodyBottomPadding}px;
}
.${gridId} .ant-table-tbody-virtual-holder,
.${gridId} .rc-virtual-list-holder {
padding-bottom: ${tableBodyBottomPadding}px;
box-sizing: border-box;
scroll-padding-bottom: ${tableBodyBottomPadding}px;
}
.${gridId} .ant-table-tbody-virtual-holder-inner {
padding-bottom: ${tableBodyBottomPadding}px;
box-sizing: border-box;
}
.${gridId} .data-grid-table-wrap {
width: 100%;
max-width: 100%;
@@ -4366,22 +4611,7 @@ const DataGrid: React.FC<DataGridProps> = ({
display: none !important;
}
.${gridId} .ant-table-tbody-virtual-scrollbar.ant-table-tbody-virtual-scrollbar-horizontal {
height: ${floatingScrollbarHeight + 4}px !important;
bottom: ${floatingScrollbarGap}px !important;
left: ${floatingScrollbarInset}px !important;
right: ${floatingScrollbarInset}px !important;
background: transparent !important;
visibility: visible !important;
pointer-events: auto !important;
z-index: 24;
}
.${gridId} .ant-table-tbody-virtual-scrollbar.ant-table-tbody-virtual-scrollbar-horizontal .ant-table-tbody-virtual-scrollbar-thumb {
background: ${horizontalScrollbarThumbBg} !important;
border: 1px solid ${horizontalScrollbarThumbBorderColor} !important;
border-radius: 999px !important;
box-shadow: ${horizontalScrollbarThumbShadow} !important;
height: ${floatingScrollbarHeight}px !important;
margin-top: 2px;
display: none !important;
}
.${gridId} .data-grid-table-wrap.data-grid-table-wrap-external-active .ant-table-content {
overflow-x: hidden !important;
@@ -4390,6 +4620,10 @@ const DataGrid: React.FC<DataGridProps> = ({
overflow-x: hidden !important;
overflow-y: auto !important;
}
.${gridId} .data-grid-table-wrap.data-grid-table-wrap-external-active .ant-table-tbody-virtual-holder,
.${gridId} .data-grid-table-wrap.data-grid-table-wrap-external-active .rc-virtual-list-holder {
overflow-x: hidden !important;
}
.${gridId} .ant-table-body {
scrollbar-width: thin;
scrollbar-color: ${floatingScrollbarThumbBg} transparent;
@@ -4399,8 +4633,9 @@ const DataGrid: React.FC<DataGridProps> = ({
height: 0;
}
.${gridId} .ant-table-body::-webkit-scrollbar-track {
background: transparent;
background: ${verticalScrollbarTrackBg};
margin: 8px 0;
border-radius: 999px;
}
.${gridId} .ant-table-body::-webkit-scrollbar-thumb {
background: ${floatingScrollbarThumbBg};
@@ -4417,8 +4652,9 @@ const DataGrid: React.FC<DataGridProps> = ({
height: 0;
}
.${gridId} .rc-virtual-list-holder::-webkit-scrollbar-track {
background: transparent;
background: ${verticalScrollbarTrackBg};
margin: 8px 0;
border-radius: 999px;
}
.${gridId} .rc-virtual-list-holder::-webkit-scrollbar-thumb {
background: ${floatingScrollbarThumbBg};
@@ -4430,7 +4666,7 @@ const DataGrid: React.FC<DataGridProps> = ({
position: absolute;
left: ${floatingScrollbarInset}px;
right: ${floatingScrollbarInset}px;
bottom: ${floatingScrollbarGap}px;
bottom: ${floatingScrollbarBottomOffset}px;
height: ${floatingScrollbarHeight + 4}px;
overflow-x: auto;
overflow-y: hidden;

View File

@@ -247,6 +247,20 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const currentConnCaps = getDataSourceCapabilities(currentConnConfig);
const currentConnType = currentConnCaps.type;
const forceReadOnly = currentConnCaps.forceReadOnlyQueryResult;
const persistViewerSnapshot = useCallback((tabId: string, overrides?: Partial<ViewerFilterSnapshot>) => {
const normalizedTabId = String(tabId || '').trim();
if (!normalizedTabId) return;
viewerFilterSnapshotsByTab.set(normalizedTabId, {
showFilter,
conditions: normalizeViewerFilterConditions(filterConditions),
currentPage: pagination.current,
pageSize: pagination.pageSize,
sortInfo,
scrollTop: scrollSnapshotRef.current.top,
scrollLeft: scrollSnapshotRef.current.left,
...overrides,
});
}, [showFilter, filterConditions, pagination.current, pagination.pageSize, sortInfo]);
useEffect(() => {
const snapshot = getViewerFilterSnapshot(tab.id);
@@ -258,16 +272,14 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
}, [tab.id]);
useEffect(() => {
viewerFilterSnapshotsByTab.set(tab.id, {
showFilter,
conditions: normalizeViewerFilterConditions(filterConditions),
currentPage: pagination.current,
pageSize: pagination.pageSize,
sortInfo,
scrollTop: scrollSnapshotRef.current.top,
scrollLeft: scrollSnapshotRef.current.left,
});
}, [tab.id, showFilter, filterConditions, pagination.current, pagination.pageSize, sortInfo]);
persistViewerSnapshot(tab.id);
}, [tab.id, persistViewerSnapshot]);
useEffect(() => {
return () => {
persistViewerSnapshot(tab.id);
};
}, [tab.id, persistViewerSnapshot]);
useEffect(() => {
const snapshot = getViewerFilterSnapshot(tab.id);
@@ -298,13 +310,11 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const handleTableScrollSnapshotChange = useCallback((snapshot: ViewerScrollSnapshot) => {
scrollSnapshotRef.current = snapshot;
const cached = getViewerFilterSnapshot(tab.id);
viewerFilterSnapshotsByTab.set(tab.id, {
...cached,
persistViewerSnapshot(tab.id, {
scrollTop: snapshot.top,
scrollLeft: snapshot.left,
});
}, [tab.id]);
}, [tab.id, persistViewerSnapshot]);
const handleDuckDBManualCount = useCallback(async () => {
if (latestDbTypeRef.current !== 'duckdb') {

File diff suppressed because it is too large Load Diff

View File

@@ -32,7 +32,8 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge,
CheckOutlined,
FilterOutlined
} from '@ant-design/icons';
import { useStore } from '../store';
import { useStore } from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { SavedConnection } from '../types';
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
@@ -121,41 +122,40 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
};
const bgMain = getBg('#141414');
const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]);
const modalPanelStyle = useMemo(() => ({
background: darkMode
? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)'
: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
boxShadow: darkMode ? '0 20px 48px rgba(0,0,0,0.38)' : '0 18px 42px rgba(15,23,42,0.12)',
backdropFilter: darkMode ? 'blur(18px)' : 'none',
}), [darkMode]);
background: overlayTheme.shellBg,
border: overlayTheme.shellBorder,
boxShadow: overlayTheme.shellShadow,
backdropFilter: overlayTheme.shellBackdropFilter,
}), [overlayTheme]);
const modalSectionStyle = useMemo(() => ({
padding: 14,
borderRadius: 14,
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.84)',
}), [darkMode]);
border: overlayTheme.sectionBorder,
background: overlayTheme.sectionBg,
}), [overlayTheme]);
const modalScrollSectionStyle = useMemo(() => ({
maxHeight: 400,
overflow: 'auto' as const,
border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)',
border: overlayTheme.sectionBorder,
borderRadius: 14,
padding: 12,
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.8)',
}), [darkMode]);
background: overlayTheme.sectionBg,
}), [overlayTheme]);
const modalHintTextStyle = useMemo(() => ({
color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)',
color: overlayTheme.mutedText,
fontSize: 12,
lineHeight: 1.6,
}), [darkMode]);
}), [overlayTheme]);
const renderSidebarModalTitle = (icon: React.ReactNode, title: string, description: string) => (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ width: 34, height: 34, borderRadius: 12, display: 'grid', placeItems: 'center', background: darkMode ? 'rgba(255,214,102,0.12)' : 'rgba(24,144,255,0.1)', color: darkMode ? '#ffd666' : '#1677ff', flexShrink: 0 }}>
<div style={{ width: 34, height: 34, borderRadius: 12, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
{icon}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700, color: darkMode ? '#f5f7ff' : '#162033' }}>{title}</div>
<div style={{ marginTop: 4, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', fontSize: 12, lineHeight: 1.6 }}>{description}</div>
<div style={{ fontSize: 16, fontWeight: 700, color: overlayTheme.titleText }}>{title}</div>
<div style={{ marginTop: 4, color: overlayTheme.mutedText, fontSize: 12, lineHeight: 1.6 }}>{description}</div>
</div>
</div>
);
@@ -2535,12 +2535,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const searchScopePopoverContent = useMemo(() => {
const smartSelected = searchScopes.includes('smart');
const scopedOptions = SEARCH_SCOPE_OPTIONS.filter((option) => option.value !== 'smart');
const borderColor = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(16,24,40,0.08)';
const mutedTextColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)';
const titleColor = darkMode ? 'rgba(255,255,255,0.92)' : '#162033';
const panelBg = darkMode
? 'linear-gradient(180deg, rgba(17,24,39,0.96) 0%, rgba(10,15,26,0.98) 100%)'
: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)';
const borderColor = overlayTheme.sectionBorder.replace('1px solid ', '');
const mutedTextColor = overlayTheme.mutedText;
const titleColor = overlayTheme.titleText;
const panelBg = overlayTheme.shellBg;
const smartBg = smartSelected
? (darkMode ? 'linear-gradient(135deg, rgba(255,214,102,0.22) 0%, rgba(255,179,71,0.16) 100%)' : 'linear-gradient(135deg, rgba(255,214,102,0.26) 0%, rgba(255,244,204,0.92) 100%)')
: (darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.72)');
@@ -2591,7 +2589,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
</div>
</label>
<div style={{ height: 1, background: borderColor, opacity: 0.9 }} />
<div style={{ height: 1, background: overlayTheme.divider, opacity: 0.9 }} />
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div style={{ fontSize: 12, fontWeight: 700, letterSpacing: 0.3, color: mutedTextColor, textTransform: 'uppercase' }}></div>
@@ -2628,7 +2626,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
</div>
</div>
);
}, [darkMode, searchScopes]);
}, [darkMode, overlayTheme, searchScopes]);
const parseHostOnlyToken = (value: unknown): string[] => {
const raw = String(value || '').trim();

View File

@@ -144,12 +144,8 @@ const TabManager: React.FC = () => {
const items = useMemo(() => tabs.map((tab, index) => {
const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name;
const displayTitle = buildTabDisplayTitle(tab, connectionName);
const keepMountedWhenInactive = tab.type === 'query' || tab.type === 'redis-command';
const shouldRenderContent = activeTabId === tab.id || keepMountedWhenInactive;
let content;
if (!shouldRenderContent) {
content = null;
} else if (tab.type === 'query') {
if (tab.type === 'query') {
content = <QueryEditor tab={tab} />;
} else if (tab.type === 'table') {
content = <DataViewer tab={tab} />;
@@ -203,7 +199,7 @@ const TabManager: React.FC = () => {
key: tab.id,
children: content,
};
}), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
}), [tabs, connections, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
return (
<>
@@ -297,6 +293,7 @@ const TabManager: React.FC = () => {
<Tabs
className="main-tabs"
type="editable-card"
destroyInactiveTabPane={false}
onChange={(newActiveKey) => {
if (Date.now() < suppressClickUntilRef.current) return;
onChange(newActiveKey);

View File

@@ -0,0 +1,35 @@
import { strict as assert } from 'node:assert';
import { calculateTableBodyBottomPadding } from './dataGridLayout';
assert.equal(
calculateTableBodyBottomPadding({
hasHorizontalOverflow: false,
floatingScrollbarHeight: 10,
floatingScrollbarGap: 6,
}),
0,
'无横向滚动条时不应增加底部间距'
);
assert.equal(
calculateTableBodyBottomPadding({
hasHorizontalOverflow: true,
floatingScrollbarHeight: 10,
floatingScrollbarGap: 6,
}),
28,
'默认悬浮滚动条应预留滚动条高度、间距和额外安全区'
);
assert.equal(
calculateTableBodyBottomPadding({
hasHorizontalOverflow: true,
floatingScrollbarHeight: 14,
floatingScrollbarGap: 4,
}),
30,
'较粗滚动条场景下应同步放大底部安全区'
);
console.log('dataGridLayout tests passed');

View File

@@ -0,0 +1,23 @@
export interface TableBodyBottomPaddingOptions {
hasHorizontalOverflow: boolean;
floatingScrollbarHeight: number;
floatingScrollbarGap: number;
}
const MIN_SCROLLBAR_CLEARANCE = 8;
const FLOATING_SCROLLBAR_VISUAL_EXTRA = 4;
export const calculateTableBodyBottomPadding = ({
hasHorizontalOverflow,
floatingScrollbarHeight,
floatingScrollbarGap,
}: TableBodyBottomPaddingOptions): number => {
if (!hasHorizontalOverflow) {
return 0;
}
const safeScrollbarHeight = Math.max(0, Math.ceil(floatingScrollbarHeight));
const safeScrollbarGap = Math.max(0, Math.ceil(floatingScrollbarGap));
return safeScrollbarHeight + FLOATING_SCROLLBAR_VISUAL_EXTRA + safeScrollbarGap + MIN_SCROLLBAR_CLEARANCE;
};

View File

@@ -0,0 +1,105 @@
import type { RedisKeyInfo } from '../types';
import {
applyRenamedRedisKeyState,
applyTreeNodeCheck,
buildCheckedTreeNodeState,
buildRedisKeyTree,
isGroupFullyChecked,
} from './redisViewerTree';
const assert = (condition: unknown, message: string) => {
if (!condition) {
throw new Error(message);
}
};
const assertEqual = (actual: unknown, expected: unknown, message: string) => {
const actualText = JSON.stringify(actual);
const expectedText = JSON.stringify(expected);
if (actualText !== expectedText) {
throw new Error(`${message}\nactual: ${actualText}\nexpected: ${expectedText}`);
}
};
const sampleKeys: RedisKeyInfo[] = [
{ key: 'app:user:1', type: 'string', ttl: -1 },
{ key: 'app:user:2', type: 'string', ttl: -1 },
{ key: 'app:order:1', type: 'hash', ttl: 120 },
{ key: 'misc', type: 'set', ttl: -1 },
];
const tree = buildRedisKeyTree(sampleKeys, true);
const appGroup = tree.treeData.find((node) => node.key === 'group:app');
const userGroup = appGroup?.children?.find((node) => node.key === 'group:app:user');
assert(appGroup, '应生成 group:app 节点');
assert(userGroup, '应生成 group:app:user 节点');
assertEqual(
appGroup?.descendantRawKeys,
['app:order:1', 'app:user:1', 'app:user:2'],
'app 分组应收集全部后代 key'
);
const selectedAfterGroupCheck = applyTreeNodeCheck([], appGroup!, true);
assertEqual(
selectedAfterGroupCheck,
['app:order:1', 'app:user:1', 'app:user:2'],
'勾选分组应递归选中全部后代 key'
);
const checkedState = buildCheckedTreeNodeState(selectedAfterGroupCheck, tree);
assertEqual(
checkedState.checked,
['key:app:order:1', 'group:app:order', 'key:app:user:1', 'key:app:user:2', 'group:app:user', 'group:app'],
'全部后代已选中时,父分组和叶子都应进入 checked'
);
assertEqual(checkedState.halfChecked, [], '全部后代已选中时不应有 halfChecked');
assertEqual(isGroupFullyChecked(appGroup!, selectedAfterGroupCheck), true, '全部后代已选中时,分组应视为 fully checked');
const selectedAfterGroupUncheck = applyTreeNodeCheck(selectedAfterGroupCheck, appGroup!, false);
assertEqual(selectedAfterGroupUncheck, [], '取消勾选分组应移除全部后代 key');
assertEqual(isGroupFullyChecked(appGroup!, selectedAfterGroupUncheck), false, '取消后分组不应再是 fully checked');
const partialState = buildCheckedTreeNodeState(['app:user:1'], tree);
assertEqual(
partialState.halfChecked,
['group:app:user', 'group:app'],
'仅部分后代选中时,相关分组应进入 halfChecked'
);
assertEqual(isGroupFullyChecked(appGroup!, ['app:user:1']), false, '部分选中时分组不应是 fully checked');
const renamedState = applyRenamedRedisKeyState(
{
keys: sampleKeys,
selectedKey: 'app:user:2',
selectedKeys: ['app:user:1', 'app:user:2', 'misc'],
},
'app:user:2',
'app:user:200'
);
assertEqual(
renamedState.keys.map((item) => item.key),
['app:user:1', 'app:user:200', 'app:order:1', 'misc'],
'重命名后 keys 列表应替换旧 key'
);
assertEqual(renamedState.selectedKey, 'app:user:200', '当前详情选中的 key 应切换为新 key');
assertEqual(
renamedState.selectedKeys,
['app:user:1', 'app:user:200', 'misc'],
'批量选中集合中的旧 key 应映射为新 key'
);
const unrelatedRenameState = applyRenamedRedisKeyState(
{
keys: sampleKeys,
selectedKey: 'misc',
selectedKeys: ['app:user:1'],
},
'app:order:1',
'app:order:9'
);
assertEqual(unrelatedRenameState.selectedKey, 'misc', '非当前详情 key 的重命名不应影响 selectedKey');
assertEqual(unrelatedRenameState.selectedKeys, ['app:user:1'], '非已勾选 key 的重命名不应污染选中集合');
console.log('redisViewerTree tests passed');

View File

@@ -0,0 +1,260 @@
import type { DataNode } from 'antd/es/tree';
import type { RedisKeyInfo } from '../types';
const KEY_GROUP_DELIMITER = ':';
const EMPTY_SEGMENT_LABEL = '(empty)';
type RedisKeyTreeLeaf = {
keyInfo: RedisKeyInfo;
label: string;
};
type RedisKeyTreeGroup = {
name: string;
path: string;
children: Map<string, RedisKeyTreeGroup>;
leaves: RedisKeyTreeLeaf[];
leafCount: number;
};
export type RedisTreeDataNode = DataNode & {
nodeType: 'group' | 'leaf';
groupName?: string;
groupLeafCount?: number;
leafLabel?: string;
rawKey?: string;
keyType?: string;
ttl?: number;
descendantRawKeys?: string[];
};
export type RedisKeyTreeResult = {
treeData: RedisTreeDataNode[];
groupKeys: string[];
};
export type RedisTreeCheckedState = {
checked: string[];
halfChecked: string[];
};
export type RenamedRedisKeyStateInput = {
keys: RedisKeyInfo[];
selectedKey: string | null;
selectedKeys: string[];
};
export type RenamedRedisKeyStateResult = {
keys: RedisKeyInfo[];
selectedKey: string | null;
selectedKeys: string[];
};
const normalizeKeySegment = (segment: string): string => {
return segment === '' ? EMPTY_SEGMENT_LABEL : segment;
};
const createTreeGroup = (name: string, path: string): RedisKeyTreeGroup => {
return { name, path, children: new Map(), leaves: [], leafCount: 0 };
};
const calculateGroupLeafCount = (group: RedisKeyTreeGroup): number => {
let count = group.leaves.length;
group.children.forEach((child) => {
count += calculateGroupLeafCount(child);
});
group.leafCount = count;
return count;
};
export const buildLeafNodeKey = (rawKey: string): string => `key:${rawKey}`;
export const parseRawKeyFromNodeKey = (nodeKey: React.Key): string | null => {
const keyText = String(nodeKey);
if (!keyText.startsWith('key:')) {
return null;
}
return keyText.slice(4);
};
export const buildRedisKeyTree = (
keys: RedisKeyInfo[],
sortLeafNodes: boolean
): RedisKeyTreeResult => {
const root = createTreeGroup('__root__', '__root__');
keys.forEach((keyInfo) => {
const segments = keyInfo.key.split(KEY_GROUP_DELIMITER);
if (segments.length <= 1) {
root.leaves.push({ keyInfo, label: keyInfo.key });
return;
}
const groupSegments = segments.slice(0, -1);
const leafLabel = normalizeKeySegment(segments[segments.length - 1]);
let current = root;
const pathParts: string[] = [];
groupSegments.forEach((segment) => {
const normalized = normalizeKeySegment(segment);
pathParts.push(normalized);
const groupPath = pathParts.join(KEY_GROUP_DELIMITER);
let child = current.children.get(normalized);
if (!child) {
child = createTreeGroup(normalized, groupPath);
current.children.set(normalized, child);
}
current = child;
});
current.leaves.push({ keyInfo, label: leafLabel });
});
calculateGroupLeafCount(root);
const groupKeys: string[] = [];
const toTreeNodes = (group: RedisKeyTreeGroup): RedisTreeDataNode[] => {
const childGroups = Array.from(group.children.values()).sort((a, b) => a.name.localeCompare(b.name));
const childLeaves = sortLeafNodes
? [...group.leaves].sort((a, b) => a.keyInfo.key.localeCompare(b.keyInfo.key))
: group.leaves;
const groupNodes: RedisTreeDataNode[] = childGroups.map((child) => {
const children = toTreeNodes(child);
const descendantRawKeys = children.flatMap((node) => {
if (node.nodeType === 'leaf') {
return node.rawKey ? [node.rawKey] : [];
}
return node.descendantRawKeys || [];
});
const groupNodeKey = `group:${child.path}`;
groupKeys.push(groupNodeKey);
return {
key: groupNodeKey,
title: child.name,
nodeType: 'group',
groupName: child.name,
groupLeafCount: child.leafCount,
selectable: false,
descendantRawKeys,
children,
};
});
const leafNodes: RedisTreeDataNode[] = childLeaves.map((leaf) => {
return {
key: buildLeafNodeKey(leaf.keyInfo.key),
isLeaf: true,
title: leaf.label,
nodeType: 'leaf',
leafLabel: leaf.label,
rawKey: leaf.keyInfo.key,
keyType: leaf.keyInfo.type,
ttl: leaf.keyInfo.ttl,
};
});
return [...groupNodes, ...leafNodes];
};
return {
treeData: toTreeNodes(root),
groupKeys,
};
};
export const applyTreeNodeCheck = (
selectedKeys: string[],
node: RedisTreeDataNode,
checked: boolean
): string[] => {
if (node.nodeType === 'leaf') {
if (!node.rawKey) {
return selectedKeys;
}
if (checked) {
return Array.from(new Set([...selectedKeys, node.rawKey]));
}
return selectedKeys.filter((item) => item !== node.rawKey);
}
const descendantRawKeys = node.descendantRawKeys || [];
if (descendantRawKeys.length === 0) {
return selectedKeys;
}
if (checked) {
return Array.from(new Set([...selectedKeys, ...descendantRawKeys]));
}
const removeSet = new Set(descendantRawKeys);
return selectedKeys.filter((item) => !removeSet.has(item));
};
const walkGroupStates = (
nodes: RedisTreeDataNode[],
selectedKeySet: Set<string>,
checked: string[],
halfChecked: string[]
) => {
nodes.forEach((node) => {
if (node.nodeType === 'leaf') {
if (node.rawKey && selectedKeySet.has(node.rawKey)) {
checked.push(String(node.key));
}
return;
}
walkGroupStates((node.children || []) as RedisTreeDataNode[], selectedKeySet, checked, halfChecked);
const descendantRawKeys = node.descendantRawKeys || [];
if (descendantRawKeys.length === 0) {
return;
}
const selectedCount = descendantRawKeys.filter((rawKey) => selectedKeySet.has(rawKey)).length;
if (selectedCount === descendantRawKeys.length) {
checked.push(String(node.key));
return;
}
if (selectedCount > 0) {
halfChecked.push(String(node.key));
}
});
};
export const buildCheckedTreeNodeState = (
selectedKeys: string[],
keyTree: RedisKeyTreeResult
): RedisTreeCheckedState => {
const selectedKeySet = new Set(selectedKeys);
const checked: string[] = [];
const halfChecked: string[] = [];
walkGroupStates(keyTree.treeData, selectedKeySet, checked, halfChecked);
return { checked, halfChecked };
};
export const isGroupFullyChecked = (
node: RedisTreeDataNode,
selectedKeys: string[]
): boolean => {
if (node.nodeType !== 'group') {
return false;
}
const descendantRawKeys = node.descendantRawKeys || [];
if (descendantRawKeys.length === 0) {
return false;
}
const selectedKeySet = new Set(selectedKeys);
return descendantRawKeys.every((rawKey) => selectedKeySet.has(rawKey));
};
export const applyRenamedRedisKeyState = (
state: RenamedRedisKeyStateInput,
oldKey: string,
newKey: string
): RenamedRedisKeyStateResult => {
return {
keys: state.keys.map((item) => (item.key === oldKey ? { ...item, key: newKey } : item)),
selectedKey: state.selectedKey === oldKey ? newKey : state.selectedKey,
selectedKeys: state.selectedKeys.map((item) => (item === oldKey ? newKey : item)),
};
};

View File

@@ -0,0 +1,34 @@
import { strict as assert } from 'node:assert';
import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme';
const darkTheme = buildRedisWorkbenchTheme({
darkMode: true,
opacity: 0.72,
blur: 14,
});
assert.equal(darkTheme.isDark, true);
assert.match(darkTheme.panelBg, /^rgba\(/);
assert.match(darkTheme.toolbarPrimaryBg, /^linear-gradient\(/);
assert.notEqual(darkTheme.actionDangerBg, darkTheme.actionSecondaryBg);
assert.notEqual(darkTheme.treeSelectedBg, darkTheme.treeHoverBg);
assert.match(darkTheme.appBg, /rgba\(15, 15, 17,/);
assert.match(darkTheme.panelBg, /rgba\(24, 24, 28,/);
assert.match(darkTheme.panelBgStrong, /rgba\(31, 31, 36,/);
assert.equal(darkTheme.backdropFilter, 'blur(14px)');
const lightTheme = buildRedisWorkbenchTheme({
darkMode: false,
opacity: 1,
blur: 0,
});
assert.equal(lightTheme.isDark, false);
assert.match(lightTheme.panelBg, /^rgba\(/);
assert.match(lightTheme.contentEmptyBg, /^linear-gradient\(/);
assert.notEqual(lightTheme.textPrimary, lightTheme.textSecondary);
assert.notEqual(lightTheme.statusTagBg, lightTheme.statusTagMutedBg);
assert.equal(lightTheme.backdropFilter, 'none');
console.log('redisViewerWorkbenchTheme tests passed');

View File

@@ -0,0 +1,129 @@
type RedisWorkbenchThemeInput = {
darkMode: boolean;
opacity: number;
blur: number;
};
type RedisWorkbenchTheme = {
isDark: boolean;
appBg: string;
panelBg: string;
panelBgStrong: string;
panelBgSubtle: string;
panelBorder: string;
panelInset: string;
toolbarPrimaryBg: string;
contentEmptyBg: string;
textPrimary: string;
textSecondary: string;
textMuted: string;
accent: string;
accentSoft: string;
accentBorder: string;
actionSecondaryBg: string;
actionSecondaryBorder: string;
actionDangerBg: string;
actionDangerBorder: string;
actionDangerText: string;
statusTagBg: string;
statusTagBorder: string;
statusTagMutedBg: string;
statusTagMutedBorder: string;
treeHoverBg: string;
treeSelectedBg: string;
treeSelectedBorder: string;
divider: string;
shadow: string;
backdropFilter: string;
};
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
export const buildRedisWorkbenchTheme = ({
darkMode,
opacity,
blur,
}: RedisWorkbenchThemeInput): RedisWorkbenchTheme => {
const normalizedOpacity = clamp(opacity, 0.1, 1);
const normalizedBlur = Math.max(0, Math.round(blur));
const isTranslucent = normalizedOpacity < 0.999 || normalizedBlur > 0;
if (darkMode) {
const appTopAlpha = isTranslucent ? Math.max(0.08, Math.min(0.22, normalizedOpacity * 0.16)) : 0.92;
const appBottomAlpha = isTranslucent ? Math.max(0.12, Math.min(0.28, normalizedOpacity * 0.22)) : 0.96;
const panelAlpha = isTranslucent ? Math.max(0.06, Math.min(0.16, normalizedOpacity * 0.1)) : 0.34;
const strongAlpha = isTranslucent ? Math.max(0.1, Math.min(0.22, normalizedOpacity * 0.16)) : 0.42;
const subtleAlpha = isTranslucent ? Math.max(0.03, Math.min(0.08, normalizedOpacity * 0.05)) : 0.08;
return {
isDark: true,
appBg: `linear-gradient(180deg, rgba(15, 15, 17, ${appTopAlpha}) 0%, rgba(11, 11, 13, ${appBottomAlpha}) 100%)`,
panelBg: `rgba(24, 24, 28, ${panelAlpha})`,
panelBgStrong: `rgba(31, 31, 36, ${strongAlpha})`,
panelBgSubtle: `rgba(255, 255, 255, ${subtleAlpha})`,
panelBorder: `1px solid rgba(255, 255, 255, ${isTranslucent ? Math.max(0.12, Math.min(0.24, normalizedOpacity * 0.2)) : 0.08})`,
panelInset: `inset 0 1px 0 rgba(255,255,255,${isTranslucent ? Math.max(0.05, Math.min(0.12, normalizedOpacity * 0.1)) : 0.04})`,
toolbarPrimaryBg: `linear-gradient(135deg, rgba(246,196,83,0.22) 0%, rgba(246,196,83,0.12) 100%)`,
contentEmptyBg: `linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.015) 100%)`,
textPrimary: 'rgba(245, 247, 251, 0.96)',
textSecondary: 'rgba(218, 224, 235, 0.82)',
textMuted: 'rgba(168, 177, 194, 0.72)',
accent: '#f6c453',
accentSoft: 'rgba(246, 196, 83, 0.18)',
accentBorder: 'rgba(246, 196, 83, 0.3)',
actionSecondaryBg: 'rgba(255, 255, 255, 0.04)',
actionSecondaryBorder: 'rgba(255, 255, 255, 0.09)',
actionDangerBg: 'rgba(255, 95, 95, 0.12)',
actionDangerBorder: 'rgba(255, 95, 95, 0.28)',
actionDangerText: '#ff8f8f',
statusTagBg: 'rgba(25, 106, 255, 0.16)',
statusTagBorder: 'rgba(25, 106, 255, 0.28)',
statusTagMutedBg: 'rgba(255, 255, 255, 0.04)',
statusTagMutedBorder: 'rgba(255, 255, 255, 0.08)',
treeHoverBg: 'rgba(255, 255, 255, 0.045)',
treeSelectedBg: 'linear-gradient(90deg, rgba(246,196,83,0.2) 0%, rgba(246,196,83,0.08) 100%)',
treeSelectedBorder: 'rgba(246, 196, 83, 0.24)',
divider: 'rgba(255, 255, 255, 0.07)',
shadow: '0 20px 48px rgba(0, 0, 0, 0.26)',
backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
};
}
const appTopAlpha = isTranslucent ? Math.max(0.16, Math.min(0.36, normalizedOpacity * 0.24)) : 0.98;
const appBottomAlpha = isTranslucent ? Math.max(0.22, Math.min(0.44, normalizedOpacity * 0.32)) : 0.96;
const panelAlpha = isTranslucent ? Math.max(0.18, Math.min(0.4, normalizedOpacity * 0.26)) : 0.94;
const strongAlpha = isTranslucent ? Math.max(0.26, Math.min(0.52, normalizedOpacity * 0.34)) : 0.98;
return {
isDark: false,
appBg: `linear-gradient(180deg, rgba(248, 250, 252, ${appTopAlpha}) 0%, rgba(242, 245, 248, ${appBottomAlpha}) 100%)`,
panelBg: `rgba(255, 255, 255, ${panelAlpha})`,
panelBgStrong: `rgba(255, 255, 255, ${strongAlpha})`,
panelBgSubtle: 'rgba(15, 23, 42, 0.03)',
panelBorder: `1px solid rgba(15, 23, 42, ${isTranslucent ? Math.max(0.1, Math.min(0.18, normalizedOpacity * 0.12)) : 0.08})`,
panelInset: `inset 0 1px 0 rgba(255,255,255,${isTranslucent ? 0.38 : 0.72})`,
toolbarPrimaryBg: 'linear-gradient(135deg, rgba(22,119,255,0.12) 0%, rgba(22,119,255,0.06) 100%)',
contentEmptyBg: 'linear-gradient(180deg, rgba(15,23,42,0.02) 0%, rgba(15,23,42,0.01) 100%)',
textPrimary: 'rgba(15, 23, 42, 0.92)',
textSecondary: 'rgba(51, 65, 85, 0.82)',
textMuted: 'rgba(100, 116, 139, 0.76)',
accent: '#1677ff',
accentSoft: 'rgba(22, 119, 255, 0.12)',
accentBorder: 'rgba(22, 119, 255, 0.22)',
actionSecondaryBg: 'rgba(255, 255, 255, 0.72)',
actionSecondaryBorder: 'rgba(15, 23, 42, 0.08)',
actionDangerBg: 'rgba(255, 77, 79, 0.08)',
actionDangerBorder: 'rgba(255, 77, 79, 0.24)',
actionDangerText: '#cf1322',
statusTagBg: 'rgba(22, 119, 255, 0.1)',
statusTagBorder: 'rgba(22, 119, 255, 0.16)',
statusTagMutedBg: 'rgba(15, 23, 42, 0.04)',
statusTagMutedBorder: 'rgba(15, 23, 42, 0.08)',
treeHoverBg: 'rgba(15, 23, 42, 0.035)',
treeSelectedBg: 'linear-gradient(90deg, rgba(22,119,255,0.12) 0%, rgba(22,119,255,0.05) 100%)',
treeSelectedBorder: 'rgba(22, 119, 255, 0.18)',
divider: 'rgba(15, 23, 42, 0.08)',
shadow: '0 22px 52px rgba(15, 23, 42, 0.08)',
backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none',
};
};
export type { RedisWorkbenchTheme, RedisWorkbenchThemeInput };

View File

@@ -0,0 +1,17 @@
import { strict as assert } from 'node:assert';
import { buildOverlayWorkbenchTheme } from './overlayWorkbenchTheme';
const darkTheme = buildOverlayWorkbenchTheme(true);
assert.equal(darkTheme.isDark, true);
assert.match(darkTheme.shellBg, /rgba\(15, 15, 17,/);
assert.match(darkTheme.sectionBg, /rgba\(255,?\s*255,?\s*255,?\s*0\.03\)/);
assert.equal(darkTheme.iconColor, '#ffd666');
const lightTheme = buildOverlayWorkbenchTheme(false);
assert.equal(lightTheme.isDark, false);
assert.match(lightTheme.shellBg, /rgba\(255,255,255,0\.98\)/);
assert.match(lightTheme.sectionBg, /rgba\(255,?\s*255,?\s*255,?\s*0\.84\)/);
assert.equal(lightTheme.iconColor, '#1677ff');
console.log('overlayWorkbenchTheme tests passed');

View File

@@ -0,0 +1,59 @@
type OverlayWorkbenchTheme = {
isDark: boolean;
shellBg: string;
shellBorder: string;
shellShadow: string;
shellBackdropFilter: string;
sectionBg: string;
sectionBorder: string;
mutedText: string;
titleText: string;
iconBg: string;
iconColor: string;
hoverBg: string;
selectedBg: string;
selectedText: string;
divider: string;
};
export const buildOverlayWorkbenchTheme = (darkMode: boolean): OverlayWorkbenchTheme => {
if (darkMode) {
return {
isDark: true,
shellBg: 'linear-gradient(180deg, rgba(15, 15, 17, 0.96) 0%, rgba(11, 11, 13, 0.98) 100%)',
shellBorder: '1px solid rgba(255,255,255,0.08)',
shellShadow: '0 24px 56px rgba(0,0,0,0.34)',
shellBackdropFilter: 'blur(18px)',
sectionBg: 'rgba(255,255,255,0.03)',
sectionBorder: '1px solid rgba(255,255,255,0.08)',
mutedText: 'rgba(255,255,255,0.5)',
titleText: '#f5f7ff',
iconBg: 'rgba(255,214,102,0.12)',
iconColor: '#ffd666',
hoverBg: 'rgba(255,214,102,0.10)',
selectedBg: 'rgba(255,214,102,0.14)',
selectedText: '#ffd666',
divider: 'rgba(255,255,255,0.08)',
};
}
return {
isDark: false,
shellBg: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
shellBorder: '1px solid rgba(16,24,40,0.08)',
shellShadow: '0 18px 42px rgba(15,23,42,0.12)',
shellBackdropFilter: 'none',
sectionBg: 'rgba(255,255,255,0.84)',
sectionBorder: '1px solid rgba(16,24,40,0.08)',
mutedText: 'rgba(16,24,40,0.55)',
titleText: '#162033',
iconBg: 'rgba(24,144,255,0.1)',
iconColor: '#1677ff',
hoverBg: 'rgba(24,144,255,0.08)',
selectedBg: 'rgba(24,144,255,0.12)',
selectedText: '#1677ff',
divider: 'rgba(16,24,40,0.08)',
};
};
export type { OverlayWorkbenchTheme };

View File

@@ -131,6 +131,8 @@ export function RedisGetServerInfo(arg1:connection.ConnectionConfig):Promise<con
export function RedisGetValue(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
export function RedisKeyExists(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
export function RedisListPush(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
export function RedisListSet(arg1:connection.ConnectionConfig,arg2:string,arg3:number,arg4:string):Promise<connection.QueryResult>;

View File

@@ -254,6 +254,10 @@ export function RedisGetValue(arg1, arg2) {
return window['go']['app']['App']['RedisGetValue'](arg1, arg2);
}
export function RedisKeyExists(arg1, arg2) {
return window['go']['app']['App']['RedisKeyExists'](arg1, arg2);
}
export function RedisListPush(arg1, arg2, arg3) {
return window['go']['app']['App']['RedisListPush'](arg1, arg2, arg3);
}

View File

@@ -453,6 +453,23 @@ func (a *App) RedisRenameKey(config connection.ConnectionConfig, oldKey, newKey
return connection.QueryResult{Success: true, Message: "重命名成功"}
}
// RedisKeyExists checks whether a key already exists
func (a *App) RedisKeyExists(config connection.ConnectionConfig, key string) connection.QueryResult {
config.Type = "redis"
client, err := a.getRedisClient(config)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
exists, err := client.KeyExists(key)
if err != nil {
logger.Error(err, "RedisKeyExists 检查失败key=%s", key)
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: map[string]bool{"exists": exists}}
}
// RedisDeleteHashField deletes fields from a hash
func (a *App) RedisDeleteHashField(config connection.ConnectionConfig, key string, fields []string) connection.QueryResult {
config.Type = "redis"

View File

@@ -162,3 +162,45 @@ func findKingbaseQualifiedSeparator(raw string) int {
return -1
}
// buildKingbaseSearchPathCommon 统一构建 Kingbase search_path。
// 返回 search_path SQL 片段和规范化后的 schema 列表(用于调试/扩展)。
func buildKingbaseSearchPathCommon(rawSchemas []string) (string, []string) {
if len(rawSchemas) == 0 {
return "", nil
}
seen := make(map[string]struct{}, len(rawSchemas)+1)
quotedParts := make([]string, 0, len(rawSchemas)+1)
normalizedSchemas := make([]string, 0, len(rawSchemas)+1)
appendSchema := func(raw string) {
cleaned := normalizeKingbaseIdentCommon(raw)
if cleaned == "" {
return
}
if strings.EqualFold(cleaned, "public") {
cleaned = "public"
}
key := strings.ToLower(cleaned)
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
normalizedSchemas = append(normalizedSchemas, cleaned)
escaped := strings.ReplaceAll(cleaned, `"`, `""`)
quotedParts = append(quotedParts, `"`+escaped+`"`)
}
for _, raw := range rawSchemas {
appendSchema(raw)
}
if _, ok := seen["public"]; !ok {
appendSchema("public")
}
if len(quotedParts) == 0 {
return "", normalizedSchemas
}
return strings.Join(quotedParts, ", "), normalizedSchemas
}

View File

@@ -50,3 +50,43 @@ func TestSplitKingbaseQualifiedNameCommon(t *testing.T) {
})
}
}
func TestBuildKingbaseSearchPathCommon(t *testing.T) {
tests := []struct {
name string
in []string
want string
wantLen int
}{
{
name: "normal schemas",
in: []string{"ldf_server", "public"},
want: `"ldf_server", "public"`,
wantLen: 2,
},
{
name: "quoted and escaped schemas should not be double quoted",
in: []string{`"ldf_server"`, `""bcs_barcode""`, `\"public\"`},
want: `"ldf_server", "bcs_barcode", "public"`,
wantLen: 3,
},
{
name: "dedupe ignoring case and keep public fallback",
in: []string{"LDF_SERVER", "ldf_server", "PUBLIC"},
want: `"LDF_SERVER", "public"`,
wantLen: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, parts := buildKingbaseSearchPathCommon(tt.in)
if got != tt.want {
t.Fatalf("buildKingbaseSearchPathCommon(%v)=%q,want=%q", tt.in, got, tt.want)
}
if len(parts) != tt.wantLen {
t.Fatalf("buildKingbaseSearchPathCommon(%v) parts=%v, len=%d, wantLen=%d", tt.in, parts, len(parts), tt.wantLen)
}
})
}
}

View File

@@ -198,7 +198,7 @@ func (k *KingbaseDB) getSearchPathStr() string {
}
defer rows.Close()
var schemas []string
var rawSchemas []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
@@ -206,17 +206,12 @@ func (k *KingbaseDB) getSearchPathStr() string {
}
name = strings.TrimSpace(name)
if name != "" {
// 使用 SQL 标准的双引号包裹标识符
escaped := strings.ReplaceAll(name, `"`, `""`)
schemas = append(schemas, `"`+escaped+`"`)
rawSchemas = append(rawSchemas, name)
}
}
if len(schemas) == 0 {
return ""
}
return strings.Join(schemas, ", ")
searchPath, _ := buildKingbaseSearchPathCommon(rawSchemas)
return searchPath
}
func (k *KingbaseDB) Close() error {

View File

@@ -582,27 +582,8 @@ func (d *OptionalDriverAgentDB) listKingbaseSchemas(ctx context.Context) ([]stri
}
func buildKingbaseSearchPathFromSchemas(schemas []string) string {
if len(schemas) == 0 {
return ""
}
seen := make(map[string]struct{}, len(schemas)+1)
parts := make([]string, 0, len(schemas)+1)
for _, name := range schemas {
trimmed := normalizeKingbaseAgentIdent(name)
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
parts = append(parts, quoteKingbaseAgentIdent(trimmed))
}
if _, ok := seen["public"]; !ok {
parts = append(parts, "public")
}
return strings.Join(parts, ", ")
searchPath, _ := buildKingbaseSearchPathCommon(schemas)
return searchPath
}
func quoteKingbaseAgentIdent(name string) string {

View File

@@ -3,6 +3,7 @@ package redis
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/url"
@@ -18,6 +19,8 @@ import (
"github.com/redis/go-redis/v9"
)
var ErrRedisKeyGone = errors.New("Redis Key 不存在或已过期")
// RedisClientImpl implements RedisClient using go-redis
type RedisClientImpl struct {
client redis.UniversalClient
@@ -472,20 +475,29 @@ func (r *RedisClientImpl) loadRedisKeyInfos(ctx context.Context, keys []string)
if ttlErr != nil && ttlErr != redis.Nil {
ttlValue = -2
}
ttlSeconds := toRedisTTLSeconds(ttlValue)
if isRedisKeyGone(keyType, ttlSeconds) {
continue
}
result = append(result, RedisKeyInfo{
Key: r.toDisplayKey(key),
Type: keyType,
TTL: toRedisTTLSeconds(ttlValue),
TTL: ttlSeconds,
})
}
return result
}
for i, key := range keys {
keyType := typeResults[i].Val()
ttlSeconds := toRedisTTLSeconds(ttlResults[i].Val())
if isRedisKeyGone(keyType, ttlSeconds) {
continue
}
result = append(result, RedisKeyInfo{
Key: r.toDisplayKey(key),
Type: typeResults[i].Val(),
TTL: toRedisTTLSeconds(ttlResults[i].Val()),
Type: keyType,
TTL: ttlSeconds,
})
}
return result
@@ -501,6 +513,17 @@ func toRedisTTLSeconds(ttl time.Duration) int64 {
return int64(ttl.Seconds())
}
func isRedisKeyGone(keyType string, ttl int64) bool {
return keyType == "none" || ttl == -2
}
func normalizeRedisGetValueError(keyType string, ttl int64) error {
if isRedisKeyGone(keyType, ttl) {
return ErrRedisKeyGone
}
return nil
}
// GetKeyType returns the type of a key
func (r *RedisClientImpl) GetKeyType(key string) (string, error) {
if r.client == nil {
@@ -594,6 +617,9 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) {
}
ttl, _ := r.GetTTL(key)
if err := normalizeRedisGetValueError(keyType, ttl); err != nil {
return nil, err
}
physicalKey := r.toPhysicalKey(key)
result := &RedisValue{

View File

@@ -1,6 +1,9 @@
package redis
import "testing"
import (
"errors"
"testing"
)
func TestSanitizeRedisPassword(t *testing.T) {
tests := []struct {
@@ -79,3 +82,40 @@ func TestSanitizeRedisPassword(t *testing.T) {
})
}
}
func TestIsRedisKeyGone(t *testing.T) {
tests := []struct {
name string
keyType string
ttl int64
want bool
}{
{name: "type none", keyType: "none", ttl: -2, want: true},
{name: "type none without ttl", keyType: "none", ttl: -1, want: true},
{name: "missing by ttl", keyType: "string", ttl: -2, want: true},
{name: "normal string", keyType: "string", ttl: 30, want: false},
{name: "permanent hash", keyType: "hash", ttl: -1, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isRedisKeyGone(tt.keyType, tt.ttl); got != tt.want {
t.Fatalf("isRedisKeyGone(%q, %d)=%v, want %v", tt.keyType, tt.ttl, got, tt.want)
}
})
}
}
func TestNormalizeRedisGetValueError(t *testing.T) {
err := normalizeRedisGetValueError("none", -2)
if !errors.Is(err, ErrRedisKeyGone) {
t.Fatalf("expected ErrRedisKeyGone, got %v", err)
}
if err == nil || err.Error() != "Redis Key 不存在或已过期" {
t.Fatalf("unexpected error text: %v", err)
}
if normalizeRedisGetValueError("hash", -1) != nil {
t.Fatal("expected nil for supported existing key")
}
}