mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-05 05:59:43 +08:00
✨ feat(frontend/backend): 批量操作与表格编辑增强并完善事务支持
- 批量导出/备份:表与数据库支持全选/反选/智能上下文 - 右键菜单:单元格菜单支持设置 NULL - 编辑优化:大字段弹窗、仅值变化标记、提交只发送差异字段 - 事务支持:PostgreSQL/SQLite/Oracle/DaMeng/KingBase ApplyChanges - MySQL 修复:提交前归一化 datetime,避免写入失败 - 性能优化:移除 activeCell 重渲染、useRef 存储选中节点、防重加载 - Redis 优化:二进制智能解码与视图模式切换 - 资源更新:替换前端 favicon/logo
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Select, Alert, Card, Row, Col, Typography, Collapse } from 'antd';
|
||||
import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { DBConnect, DBGetDatabases, TestConnection, RedisConnect } from '../../wailsjs/go/app/App';
|
||||
import { DBGetDatabases, TestConnection, RedisConnect } from '../../wailsjs/go/app/App';
|
||||
import { SavedConnection } from '../types';
|
||||
|
||||
const { Meta } = Card;
|
||||
@@ -17,6 +17,8 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
const [testResult, setTestResult] = useState<{ type: 'success' | 'error', message: string } | null>(null);
|
||||
const [dbList, setDbList] = useState<string[]>([]);
|
||||
const [redisDbList, setRedisDbList] = useState<number[]>([]); // Redis databases 0-15
|
||||
const testInFlightRef = useRef(false);
|
||||
const testTimerRef = useRef<number | null>(null);
|
||||
const addConnection = useStore((state) => state.addConnection);
|
||||
const updateConnection = useStore((state) => state.updateConnection);
|
||||
|
||||
@@ -64,6 +66,15 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
}
|
||||
}, [open, initialValues]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (testTimerRef.current !== null) {
|
||||
window.clearTimeout(testTimerRef.current);
|
||||
testTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
@@ -71,45 +82,46 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
|
||||
const config = await buildConfig(values);
|
||||
|
||||
// Use different API for Redis
|
||||
const isRedisType = values.type === 'redis';
|
||||
const res = isRedisType
|
||||
? await RedisConnect(config as any)
|
||||
: await DBConnect(config as any);
|
||||
const newConn = {
|
||||
id: initialValues ? initialValues.id : Date.now().toString(),
|
||||
name: values.name || (values.type === 'sqlite' ? 'SQLite DB' : (values.type === 'redis' ? `Redis ${values.host}` : values.host)),
|
||||
config: config,
|
||||
includeDatabases: values.includeDatabases,
|
||||
includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined
|
||||
};
|
||||
|
||||
if (initialValues) {
|
||||
updateConnection(newConn);
|
||||
message.success('配置已更新(未连接)');
|
||||
} else {
|
||||
addConnection(newConn);
|
||||
message.success('配置已保存(未连接)');
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (res.success) {
|
||||
const newConn = {
|
||||
id: initialValues ? initialValues.id : Date.now().toString(),
|
||||
name: values.name || (values.type === 'sqlite' ? 'SQLite DB' : (values.type === 'redis' ? `Redis ${values.host}` : values.host)),
|
||||
config: config,
|
||||
includeDatabases: values.includeDatabases,
|
||||
includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined
|
||||
};
|
||||
|
||||
if (initialValues) {
|
||||
updateConnection(newConn);
|
||||
message.success('连接已更新!');
|
||||
} else {
|
||||
addConnection(newConn);
|
||||
message.success('连接已保存!');
|
||||
}
|
||||
|
||||
form.resetFields();
|
||||
setUseSSH(false);
|
||||
setDbType('mysql');
|
||||
setStep(1);
|
||||
onClose();
|
||||
} else {
|
||||
message.error('连接失败: ' + res.message);
|
||||
}
|
||||
form.resetFields();
|
||||
setUseSSH(false);
|
||||
setDbType('mysql');
|
||||
setStep(1);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const requestTest = () => {
|
||||
if (loading) return;
|
||||
if (testTimerRef.current !== null) return;
|
||||
testTimerRef.current = window.setTimeout(() => {
|
||||
testTimerRef.current = null;
|
||||
handleTest();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
if (testInFlightRef.current) return;
|
||||
testInFlightRef.current = true;
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
@@ -122,7 +134,6 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
? await RedisConnect(config as any)
|
||||
: await TestConnection(config as any);
|
||||
|
||||
setLoading(false);
|
||||
if (res.success) {
|
||||
setTestResult({ type: 'success', message: res.message });
|
||||
if (isRedisType) {
|
||||
@@ -140,6 +151,9 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
setTestResult({ type: 'error', message: "测试失败: " + res.message });
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
} finally {
|
||||
testInFlightRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -254,7 +268,10 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="host" label={isSqlite ? "文件路径 (绝对路径)" : "主机地址 (Host)"} rules={[{ required: true, message: '请输入地址/路径' }]} style={{ flex: 1 }}>
|
||||
<Input placeholder={isSqlite ? "/path/to/db.sqlite" : "localhost"} />
|
||||
<Input
|
||||
placeholder={isSqlite ? "/path/to/db.sqlite" : "localhost"}
|
||||
onDoubleClick={requestTest}
|
||||
/>
|
||||
</Form.Item>
|
||||
{!isSqlite && (
|
||||
<Form.Item name="port" label="端口 (Port)" rules={[{ required: true, message: '请输入端口号' }]} style={{ width: 100 }}>
|
||||
@@ -371,7 +388,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
|
||||
}
|
||||
return [
|
||||
!initialValues && <Button key="back" onClick={() => setStep(1)} style={{ float: 'left' }}>上一步</Button>,
|
||||
<Button key="test" loading={loading} onClick={handleTest}>测试连接</Button>,
|
||||
<Button key="test" loading={loading} onClick={requestTest}>测试连接</Button>,
|
||||
<Button key="cancel" onClick={onClose}>取消</Button>,
|
||||
<Button key="submit" type="primary" loading={loading} onClick={handleOk}>保存</Button>
|
||||
];
|
||||
|
||||
@@ -45,6 +45,35 @@ const toFormText = (val: any): string => {
|
||||
return toEditableText(val);
|
||||
};
|
||||
|
||||
const INLINE_EDIT_MAX_CHARS = 2000;
|
||||
|
||||
const shouldOpenModalEditor = (val: any): boolean => {
|
||||
if (val === null || val === undefined) return false;
|
||||
if (typeof val === 'string') {
|
||||
return val.length > INLINE_EDIT_MAX_CHARS || val.includes('\n');
|
||||
}
|
||||
if (typeof val === 'object') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const getCellFieldName = (record: Item, dataIndex: string) => {
|
||||
const rowKey = record?.[GONAVI_ROW_KEY];
|
||||
if (rowKey === undefined || rowKey === null) return dataIndex;
|
||||
return [String(rowKey), dataIndex];
|
||||
};
|
||||
|
||||
const setCellFieldValue = (form: any, fieldName: string | (string | number)[], value: any) => {
|
||||
if (!form) return;
|
||||
if (Array.isArray(fieldName)) {
|
||||
const [rowKey, colKey] = fieldName;
|
||||
form.setFieldsValue({ [rowKey]: { [colKey]: value } });
|
||||
return;
|
||||
}
|
||||
form.setFieldsValue({ [fieldName]: value });
|
||||
};
|
||||
|
||||
const looksLikeJsonText = (text: string): boolean => {
|
||||
const raw = (text || '').trim();
|
||||
if (!raw) return false;
|
||||
@@ -96,6 +125,9 @@ const ResizableTitle = (props: any) => {
|
||||
|
||||
// --- Contexts ---
|
||||
const EditableContext = React.createContext<any>(null);
|
||||
const CellContextMenuContext = React.createContext<{
|
||||
showMenu: (e: React.MouseEvent, record: Item, dataIndex: string, title: React.ReactNode) => void;
|
||||
} | null>(null);
|
||||
const DataContext = React.createContext<{
|
||||
selectedRowKeysRef: React.MutableRefObject<React.Key[]>;
|
||||
displayDataRef: React.MutableRefObject<any[]>;
|
||||
@@ -134,7 +166,9 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
}) => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const inputRef = useRef<any>(null);
|
||||
const cellRef = useRef<HTMLTableCellElement>(null);
|
||||
const form = useContext(EditableContext);
|
||||
const cellContextMenuContext = useContext(CellContextMenuContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
@@ -146,29 +180,73 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
setEditing(!editing);
|
||||
const raw = record[dataIndex];
|
||||
const initialValue = typeof raw === 'string' ? normalizeDateTimeString(raw) : raw;
|
||||
form.setFieldsValue({ [dataIndex]: initialValue });
|
||||
const fieldName = getCellFieldName(record, dataIndex);
|
||||
setCellFieldValue(form, fieldName, initialValue);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
if (!form) return;
|
||||
const values = await form.validateFields([dataIndex]);
|
||||
const fieldName = getCellFieldName(record, dataIndex);
|
||||
await form.validateFields([fieldName]);
|
||||
const nextValue = form.getFieldValue(fieldName);
|
||||
const prevText = toFormText(record?.[dataIndex]);
|
||||
const nextText = toFormText(nextValue);
|
||||
toggleEdit();
|
||||
handleSave({ ...record, ...values });
|
||||
// 仅当值发生变化时才标记为修改,避免“双击-失焦”导致整行进入 modified 状态(蓝色高亮不清除)。
|
||||
if (nextText !== prevText) {
|
||||
handleSave({ ...record, [dataIndex]: nextValue });
|
||||
}
|
||||
// 保存后移除焦点
|
||||
if (inputRef.current) {
|
||||
inputRef.current.blur();
|
||||
}
|
||||
} catch (errInfo) {
|
||||
console.log('Save failed:', errInfo);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
if (!editable) return;
|
||||
e.preventDefault();
|
||||
if (cellContextMenuContext) {
|
||||
cellContextMenuContext.showMenu(e, record, dataIndex, title);
|
||||
}
|
||||
};
|
||||
|
||||
let childNode = children;
|
||||
|
||||
if (editable) {
|
||||
childNode = editing ? (
|
||||
<Form.Item style={{ margin: 0 }} name={dataIndex}>
|
||||
<Input ref={inputRef} onPressEnter={save} onBlur={save} />
|
||||
<Form.Item style={{ margin: 0 }} name={getCellFieldName(record, dataIndex)}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
onPressEnter={save}
|
||||
onBlur={save}
|
||||
onFocus={(e) => {
|
||||
// Enter 编辑态时直接全选,便于快速替换;同时避免双击在 input 内冒泡导致关闭编辑态。
|
||||
try {
|
||||
(e.target as HTMLInputElement)?.select?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
(e.target as HTMLInputElement)?.select?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="editable-cell-value-wrap" style={{ paddingRight: 24, minHeight: 20 }}>
|
||||
<div
|
||||
className="editable-cell-value-wrap"
|
||||
style={{ paddingRight: 24, minHeight: 20 }}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -176,19 +254,20 @@ const EditableCell: React.FC<EditableCellProps> = React.memo(({
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
if (!editable) return;
|
||||
// 已在编辑态时再次双击不应退出编辑;双击应支持在 Input 内进行全选。
|
||||
if (editing) return;
|
||||
const raw = record?.[dataIndex];
|
||||
if (focusCell && shouldOpenModalEditor(raw)) {
|
||||
focusCell(record, dataIndex, title);
|
||||
return;
|
||||
}
|
||||
toggleEdit();
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
restProps?.onClick?.(e);
|
||||
if (!editable) return;
|
||||
if (typeof focusCell === 'function') focusCell(record, dataIndex, title);
|
||||
};
|
||||
|
||||
return (
|
||||
<td
|
||||
{...restProps}
|
||||
onClick={editable ? handleClick : restProps?.onClick}
|
||||
ref={cellRef}
|
||||
onDoubleClick={editable ? handleDoubleClick : restProps?.onDoubleClick}
|
||||
>
|
||||
{childNode}
|
||||
@@ -273,7 +352,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
data, columnNames, loading, tableName, dbName, connectionId, pkColumns = [], readOnly = false,
|
||||
onReload, onSort, onPageChange, pagination, showFilter, onToggleFilter, onApplyFilter
|
||||
}) => {
|
||||
const { connections } = useStore();
|
||||
const connections = useStore(state => state.connections);
|
||||
const addSqlLog = useStore(state => state.addSqlLog);
|
||||
const darkMode = useStore(state => state.darkMode);
|
||||
const selectionColumnWidth = 46;
|
||||
@@ -285,14 +364,77 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const [cellEditorIsJson, setCellEditorIsJson] = useState(false);
|
||||
const [cellEditorMeta, setCellEditorMeta] = useState<{ record: Item; dataIndex: string; title: string } | null>(null);
|
||||
const cellEditorApplyRef = useRef<((val: string) => void) | null>(null);
|
||||
const [activeCell, setActiveCell] = useState<{ rowKey: string; dataIndex: string; title: string } | null>(null);
|
||||
const [rowEditorOpen, setRowEditorOpen] = useState(false);
|
||||
const [rowEditorRowKey, setRowEditorRowKey] = useState<string>('');
|
||||
const rowEditorBaseRef = useRef<Record<string, string>>({});
|
||||
const rowEditorDisplayRef = useRef<Record<string, string>>({});
|
||||
const rowEditorNullColsRef = useRef<Set<string>>(new Set());
|
||||
const [rowEditorForm] = Form.useForm();
|
||||
|
||||
|
||||
// Cell Context Menu State
|
||||
const [cellContextMenu, setCellContextMenu] = useState<{
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
record: Item | null;
|
||||
dataIndex: string;
|
||||
title: string;
|
||||
}>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
record: null,
|
||||
dataIndex: '',
|
||||
title: '',
|
||||
});
|
||||
const [cellSetValueInput, setCellSetValueInput] = useState('');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const pendingScrollToBottomRef = useRef(false);
|
||||
|
||||
const scrollTableBodyToBottom = useCallback(() => {
|
||||
const root = containerRef.current;
|
||||
if (!root) return;
|
||||
const body = root.querySelector('.ant-table-body') as HTMLElement | null;
|
||||
if (!body) return;
|
||||
body.scrollTop = body.scrollHeight;
|
||||
}, []);
|
||||
|
||||
// Close cell context menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (cellContextMenu.visible) {
|
||||
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}
|
||||
// Remove focus from any focused cell when clicking outside the table
|
||||
const target = e.target as HTMLElement;
|
||||
const tableContainer = containerRef.current;
|
||||
if (tableContainer && !tableContainer.contains(target)) {
|
||||
// Remove focus from any input elements in the table
|
||||
const focusedElement = document.activeElement as HTMLElement;
|
||||
if (focusedElement && focusedElement.tagName === 'INPUT' && tableContainer.contains(focusedElement)) {
|
||||
focusedElement.blur();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}, [cellContextMenu.visible]);
|
||||
|
||||
const showCellContextMenu = useCallback((e: React.MouseEvent, record: Item, dataIndex: string, title: React.ReactNode) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const titleText = typeof title === 'string' ? title : (typeof title === 'number' ? String(title) : String(dataIndex));
|
||||
setCellContextMenu({
|
||||
visible: true,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
record,
|
||||
dataIndex,
|
||||
title: titleText,
|
||||
});
|
||||
setCellSetValueInput(toFormText(record[dataIndex]));
|
||||
}, []);
|
||||
|
||||
// Helper to export specific data
|
||||
const exportData = async (rows: any[], format: string) => {
|
||||
const hide = message.loading(`正在导出 ${rows.length} 条数据...`, 0);
|
||||
@@ -327,10 +469,9 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
setCellEditorOpen(true);
|
||||
cellEditorApplyRef.current = typeof onApplyValue === 'function' ? onApplyValue : null;
|
||||
}, []);
|
||||
|
||||
|
||||
// Dynamic Height
|
||||
const [tableHeight, setTableHeight] = useState(500);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
@@ -382,13 +523,22 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
useEffect(() => { selectedRowKeysRef.current = selectedRowKeys; }, [selectedRowKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingScrollToBottomRef.current) return;
|
||||
pendingScrollToBottomRef.current = false;
|
||||
// 等待 Table 渲染出新增行后再滚动到底部(virtual 模式也适用)
|
||||
requestAnimationFrame(() => {
|
||||
scrollTableBodyToBottom();
|
||||
requestAnimationFrame(() => scrollTableBodyToBottom());
|
||||
});
|
||||
}, [addedRows.length, scrollTableBodyToBottom]);
|
||||
|
||||
// Reset local state when data source likely changes (e.g. tableName change)
|
||||
useEffect(() => {
|
||||
setAddedRows([]);
|
||||
setModifiedRows({});
|
||||
setDeletedRowKeys(new Set());
|
||||
setSelectedRowKeys([]);
|
||||
setActiveCell(null);
|
||||
setRowEditorOpen(false);
|
||||
setRowEditorRowKey('');
|
||||
rowEditorBaseRef.current = {};
|
||||
@@ -550,6 +700,18 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
}
|
||||
}, [addedRows]);
|
||||
|
||||
const handleCellSetNull = useCallback(() => {
|
||||
if (!cellContextMenu.record) return;
|
||||
handleCellSave({ ...cellContextMenu.record, [cellContextMenu.dataIndex]: null });
|
||||
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}, [cellContextMenu, handleCellSave]);
|
||||
|
||||
const handleCellSetValue = useCallback(() => {
|
||||
if (!cellContextMenu.record) return;
|
||||
handleCellSave({ ...cellContextMenu.record, [cellContextMenu.dataIndex]: cellSetValueInput });
|
||||
setCellContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}, [cellContextMenu, cellSetValueInput, handleCellSave]);
|
||||
|
||||
const handleCellEditorSave = useCallback(() => {
|
||||
if (!cellEditorMeta) return;
|
||||
const apply = cellEditorApplyRef.current;
|
||||
@@ -586,13 +748,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
});
|
||||
}, [displayData, modifiedRows]);
|
||||
|
||||
const focusCell = useCallback((record: Item, dataIndex: string, title: React.ReactNode) => {
|
||||
const k = record?.[GONAVI_ROW_KEY];
|
||||
if (k === undefined) return;
|
||||
const titleText = typeof title === 'string' ? title : (typeof title === 'number' ? String(title) : String(dataIndex));
|
||||
setActiveCell({ rowKey: rowKeyStr(k), dataIndex, title: titleText });
|
||||
}, [rowKeyStr]);
|
||||
|
||||
const closeRowEditor = useCallback(() => {
|
||||
setRowEditorOpen(false);
|
||||
setRowEditorRowKey('');
|
||||
@@ -610,9 +765,9 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
}
|
||||
|
||||
const keyStr =
|
||||
selectedRowKeys.length === 1 ? rowKeyStr(selectedRowKeys[0]) : activeCell?.rowKey;
|
||||
selectedRowKeys.length === 1 ? rowKeyStr(selectedRowKeys[0]) : undefined;
|
||||
if (!keyStr) {
|
||||
message.info('请先选择一行(勾选一行或点击任意单元格)');
|
||||
message.info('请先选择一行(勾选复选框)');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -646,7 +801,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
rowEditorForm.setFieldsValue(displayMap);
|
||||
setRowEditorRowKey(keyStr);
|
||||
setRowEditorOpen(true);
|
||||
}, [readOnly, tableName, selectedRowKeys, activeCell, mergedDisplayData, data, addedRows, columnNames, rowEditorForm, rowKeyStr]);
|
||||
}, [readOnly, tableName, selectedRowKeys, mergedDisplayData, data, addedRows, columnNames, rowEditorForm, rowKeyStr]);
|
||||
|
||||
const openRowEditorFieldEditor = useCallback((dataIndex: string) => {
|
||||
if (!dataIndex) return;
|
||||
@@ -695,12 +850,16 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
title: key,
|
||||
dataIndex: key,
|
||||
key: key,
|
||||
ellipsis: true,
|
||||
width: columnWidths[key] || 200,
|
||||
sorter: !!onSort,
|
||||
// 不使用 ellipsis,避免 Ant Design 的 Tooltip 展开行为
|
||||
width: columnWidths[key] || 200,
|
||||
sorter: !!onSort,
|
||||
sortOrder: (sortInfo?.columnKey === key ? sortInfo.order : null) as SortOrder | undefined,
|
||||
editable: !readOnly && !!tableName, // Only editable if table name known
|
||||
render: (text: any) => formatCellValue(text),
|
||||
render: (text: any) => (
|
||||
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{formatCellValue(text)}
|
||||
</div>
|
||||
),
|
||||
onHeaderCell: (column: any) => ({
|
||||
width: column.width,
|
||||
onResizeStart: handleResizeStart(key), // Only need start
|
||||
@@ -718,18 +877,16 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
dataIndex: col.dataIndex,
|
||||
title: col.title,
|
||||
handleSave: handleCellSave,
|
||||
focusCell,
|
||||
className: (activeCell && rowKeyStr(record?.[GONAVI_ROW_KEY]) === activeCell.rowKey && col.dataIndex === activeCell.dataIndex)
|
||||
? 'gonavi-active-cell'
|
||||
: undefined,
|
||||
focusCell: openCellEditor,
|
||||
}),
|
||||
};
|
||||
}), [columns, handleCellSave, openCellEditor, focusCell, activeCell, rowKeyStr]);
|
||||
}), [columns, handleCellSave, openCellEditor]);
|
||||
|
||||
const handleAddRow = () => {
|
||||
const newKey = `new-${Date.now()}`;
|
||||
const newRow: any = { [GONAVI_ROW_KEY]: newKey };
|
||||
columnNames.forEach(col => newRow[col] = '');
|
||||
pendingScrollToBottomRef.current = true;
|
||||
setAddedRows(prev => [...prev, newRow]);
|
||||
};
|
||||
|
||||
@@ -770,9 +927,24 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const pkData: any = {};
|
||||
if (pkColumns.length > 0) pkColumns.forEach(k => pkData[k] = originalRow[k]);
|
||||
else { const { [GONAVI_ROW_KEY]: _rowKey, ...rest } = originalRow; Object.assign(pkData, rest); }
|
||||
|
||||
const { [GONAVI_ROW_KEY]: _rowKey, ...vals } = newRow;
|
||||
updates.push({ keys: pkData, values: vals });
|
||||
|
||||
const hasRowKey = Object.prototype.hasOwnProperty.call(newRow as any, GONAVI_ROW_KEY);
|
||||
let values: any = {};
|
||||
|
||||
if (!hasRowKey) {
|
||||
values = { ...(newRow as any) };
|
||||
} else {
|
||||
columnNames.forEach((col) => {
|
||||
const nextVal = (newRow as any)?.[col];
|
||||
const prevVal = (originalRow as any)?.[col];
|
||||
const nextStr = toFormText(nextVal);
|
||||
const prevStr = toFormText(prevVal);
|
||||
if (nextStr !== prevStr) values[col] = nextVal;
|
||||
});
|
||||
}
|
||||
|
||||
if (Object.keys(values).length === 0) return;
|
||||
updates.push({ keys: pkData, values });
|
||||
});
|
||||
|
||||
if (inserts.length === 0 && updates.length === 0 && deletes.length === 0) {
|
||||
@@ -809,7 +981,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
message: res.message,
|
||||
dbName
|
||||
});
|
||||
message.success("Changes committed successfully!");
|
||||
message.success("事务提交成功");
|
||||
setAddedRows([]);
|
||||
setModifiedRows({});
|
||||
setDeletedRowKeys(new Set());
|
||||
@@ -824,7 +996,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
message: res.message,
|
||||
dbName
|
||||
});
|
||||
message.error("Commit failed: " + res.message);
|
||||
message.error("提交失败: " + res.message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1118,12 +1290,11 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
<div className={gridId} style={{ flex: '1 1 auto', height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
{/* Toolbar */}
|
||||
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
{onReload && <Button icon={<ReloadOutlined />} onClick={() => {
|
||||
{onReload && <Button icon={<ReloadOutlined />} disabled={loading} onClick={() => {
|
||||
setAddedRows([]);
|
||||
setModifiedRows({});
|
||||
setDeletedRowKeys(new Set());
|
||||
setSelectedRowKeys([]);
|
||||
setActiveCell(null);
|
||||
onReload();
|
||||
}}>刷新</Button>}
|
||||
{tableName && <Button icon={<ImportOutlined />} onClick={handleImport}>导入</Button>}
|
||||
@@ -1135,7 +1306,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
<Button icon={<PlusOutlined />} onClick={handleAddRow}>添加行</Button>
|
||||
<Button
|
||||
icon={<EditOutlined />}
|
||||
disabled={selectedRowKeys.length > 1 || (selectedRowKeys.length !== 1 && !activeCell)}
|
||||
disabled={selectedRowKeys.length !== 1}
|
||||
onClick={openRowEditor}
|
||||
>
|
||||
编辑行
|
||||
@@ -1244,7 +1415,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
open={rowEditorOpen}
|
||||
onCancel={closeRowEditor}
|
||||
width={980}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
maskClosable={false}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={closeRowEditor}>取消</Button>,
|
||||
@@ -1290,7 +1461,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
open={cellEditorOpen}
|
||||
onCancel={closeCellEditor}
|
||||
width={960}
|
||||
destroyOnClose
|
||||
destroyOnHidden
|
||||
maskClosable={false}
|
||||
footer={[
|
||||
<Button key="format" onClick={handleFormatJsonInEditor} disabled={!cellEditorIsJson}>
|
||||
@@ -1323,36 +1494,69 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
</Modal>
|
||||
<Form component={false} form={form}>
|
||||
<DataContext.Provider value={{ selectedRowKeysRef, displayDataRef, handleCopyInsert, handleCopyJson, handleCopyCsv, handleExportSelected, copyToClipboard, tableName }}>
|
||||
<EditableContext.Provider value={form}>
|
||||
<Table
|
||||
components={tableComponents}
|
||||
dataSource={mergedDisplayData}
|
||||
columns={mergedColumns}
|
||||
size="small"
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: Math.max(totalWidth, 1000), y: tableHeight }}
|
||||
virtual={enableVirtual}
|
||||
loading={loading}
|
||||
rowKey={GONAVI_ROW_KEY}
|
||||
pagination={false}
|
||||
onChange={handleTableChange}
|
||||
bordered
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys,
|
||||
columnWidth: selectionColumnWidth,
|
||||
}}
|
||||
rowClassName={(record) => {
|
||||
const k = record?.[GONAVI_ROW_KEY];
|
||||
if (k !== undefined && addedRows.some(r => r?.[GONAVI_ROW_KEY] === k)) return 'row-added';
|
||||
if (k !== undefined && (modifiedRows[rowKeyStr(k)] || deletedRowKeys.has(rowKeyStr(k)))) return 'row-modified'; // deleted won't show
|
||||
return '';
|
||||
}}
|
||||
onRow={(record) => ({ record } as any)}
|
||||
/>
|
||||
</EditableContext.Provider>
|
||||
<CellContextMenuContext.Provider value={{ showMenu: showCellContextMenu }}>
|
||||
<EditableContext.Provider value={form}>
|
||||
<Table
|
||||
components={tableComponents}
|
||||
dataSource={mergedDisplayData}
|
||||
columns={mergedColumns}
|
||||
size="small"
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: Math.max(totalWidth, 1000), y: tableHeight }}
|
||||
virtual={enableVirtual}
|
||||
loading={loading}
|
||||
rowKey={GONAVI_ROW_KEY}
|
||||
pagination={false}
|
||||
onChange={handleTableChange}
|
||||
bordered
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys,
|
||||
columnWidth: selectionColumnWidth,
|
||||
}}
|
||||
rowClassName={(record) => {
|
||||
const k = record?.[GONAVI_ROW_KEY];
|
||||
if (k !== undefined && addedRows.some(r => r?.[GONAVI_ROW_KEY] === k)) return 'row-added';
|
||||
if (k !== undefined && (modifiedRows[rowKeyStr(k)] || deletedRowKeys.has(rowKeyStr(k)))) return 'row-modified'; // deleted won't show
|
||||
return '';
|
||||
}}
|
||||
onRow={(record) => ({ record } as any)}
|
||||
/>
|
||||
</EditableContext.Provider>
|
||||
</CellContextMenuContext.Provider>
|
||||
</DataContext.Provider>
|
||||
</Form>
|
||||
|
||||
{/* Cell Context Menu */}
|
||||
{cellContextMenu.visible && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: cellContextMenu.x,
|
||||
top: cellContextMenu.y,
|
||||
zIndex: 10000,
|
||||
background: '#fff',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 4,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
minWidth: 120,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = '#f5f5f5'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||
onClick={handleCellSetNull}
|
||||
>
|
||||
设置为 NULL
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
@@ -1377,10 +1581,6 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
<style>{`
|
||||
.${gridId} .row-added td { background-color: #f6ffed !important; }
|
||||
.${gridId} .row-modified td { background-color: #e6f7ff !important; }
|
||||
.${gridId} td.gonavi-active-cell {
|
||||
outline: 2px solid #1677ff;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Ghost Resize Line for Columns */}
|
||||
|
||||
@@ -11,7 +11,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const [columnNames, setColumnNames] = useState<string[]>([]);
|
||||
const [pkColumns, setPkColumns] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { connections, addSqlLog } = useStore();
|
||||
const connections = useStore(state => state.connections);
|
||||
const addSqlLog = useStore(state => state.addSqlLog);
|
||||
const fetchSeqRef = useRef(0);
|
||||
const countSeqRef = useRef(0);
|
||||
const countKeyRef = useRef<string>('');
|
||||
@@ -149,8 +150,11 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
countKeyRef.current = countKey;
|
||||
const countSeq = ++countSeqRef.current;
|
||||
const countStart = Date.now();
|
||||
// 大表 COUNT(*) 可能非常慢,且在部分运行时环境下会影响后续操作响应;
|
||||
// 这里为统计请求设置更短的超时,避免“后台统计”长期占用资源。
|
||||
const countConfig: any = { ...(config as any), timeout: 5 };
|
||||
|
||||
DBQuery(config as any, dbName, countSql)
|
||||
DBQuery(countConfig, dbName, countSql)
|
||||
.then((resCount: any) => {
|
||||
const countDuration = Date.now() - countStart;
|
||||
|
||||
@@ -209,7 +213,6 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
|
||||
// Handlers memoized
|
||||
const handleReload = useCallback(() => {
|
||||
countKeyRef.current = '';
|
||||
fetchData(pagination.current, pagination.pageSize);
|
||||
}, [fetchData, pagination.current, pagination.pageSize]);
|
||||
const handleSort = useCallback((field: string, order: string) => setSortInfo({ columnKey: field, order }), []);
|
||||
|
||||
@@ -10,7 +10,9 @@ interface LogPanelProps {
|
||||
}
|
||||
|
||||
const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) => {
|
||||
const { sqlLogs, clearSqlLogs, darkMode } = useStore();
|
||||
const sqlLogs = useStore(state => state.sqlLogs);
|
||||
const clearSqlLogs = useStore(state => state.clearSqlLogs);
|
||||
const darkMode = useStore(state => state.darkMode);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
@@ -111,4 +113,4 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
|
||||
);
|
||||
};
|
||||
|
||||
export default LogPanel;
|
||||
export default LogPanel;
|
||||
|
||||
@@ -45,7 +45,8 @@ const QueryEditor: React.FC<{ tab: TabData }> = ({ tab }) => {
|
||||
const tablesRef = useRef<string[]>([]); // Store tables for autocomplete
|
||||
const allColumnsRef = useRef<{tableName: string, name: string, type: string}[]>([]); // Store all columns
|
||||
|
||||
const { connections, addSqlLog } = useStore();
|
||||
const connections = useStore(state => state.connections);
|
||||
const addSqlLog = useStore(state => state.addSqlLog);
|
||||
const currentConnectionIdRef = useRef(currentConnectionId);
|
||||
const currentDbRef = useRef(currentDb);
|
||||
const connectionsRef = useRef(connections);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Table, Input, Button, Space, Tag, message, Modal, Form, InputNumber, Popconfirm, Tooltip } from 'antd';
|
||||
import { Table, Input, Button, Space, Tag, message, Modal, Form, InputNumber, Popconfirm, Tooltip, Radio } from 'antd';
|
||||
import { ReloadOutlined, DeleteOutlined, PlusOutlined, EditOutlined, SearchOutlined, ClockCircleOutlined, CopyOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { RedisKeyInfo, RedisValue } from '../types';
|
||||
@@ -13,6 +13,85 @@ interface RedisViewerProps {
|
||||
redisDB: number;
|
||||
}
|
||||
|
||||
// 尝试多种方式解码二进制数据
|
||||
const tryDecodeValue = (value: string): { displayValue: string; encoding: string; needsHex: boolean } => {
|
||||
if (!value || value.length === 0) {
|
||||
return { displayValue: '', encoding: 'UTF-8', needsHex: false };
|
||||
}
|
||||
|
||||
// 统计字节分布
|
||||
let nullCount = 0;
|
||||
let printableCount = 0;
|
||||
let highByteCount = 0;
|
||||
const sampleSize = Math.min(value.length, 200);
|
||||
|
||||
for (let i = 0; i < sampleSize; i++) {
|
||||
const code = value.charCodeAt(i);
|
||||
if (code === 0) {
|
||||
nullCount++;
|
||||
} else if (code >= 32 && code < 127) {
|
||||
printableCount++;
|
||||
} else if (code >= 128) {
|
||||
highByteCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果超过30%是null字节,很可能是二进制数据,显示十六进制
|
||||
if (nullCount / sampleSize > 0.3) {
|
||||
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
|
||||
}
|
||||
|
||||
// 如果超过70%是可打印ASCII字符,直接显示
|
||||
if (printableCount / sampleSize > 0.7) {
|
||||
return { displayValue: value, encoding: 'UTF-8', needsHex: false };
|
||||
}
|
||||
|
||||
// 尝试UTF-8解码
|
||||
if (highByteCount > 0) {
|
||||
try {
|
||||
const bytes = new Uint8Array(value.length);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
bytes[i] = value.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
||||
|
||||
// 检查解码质量
|
||||
let validChars = 0;
|
||||
let replacementChars = 0;
|
||||
let controlChars = 0;
|
||||
|
||||
for (let i = 0; i < Math.min(decoded.length, 200); i++) {
|
||||
const code = decoded.charCodeAt(i);
|
||||
if (code === 0xFFFD) {
|
||||
replacementChars++;
|
||||
} else if (code < 32 && code !== 9 && code !== 10 && code !== 13) {
|
||||
controlChars++;
|
||||
} else if ((code >= 32 && code < 127) || (code >= 0x4E00 && code <= 0x9FFF) || (code >= 0x3000 && code <= 0x303F)) {
|
||||
// ASCII可打印字符、中文字符、中文标点
|
||||
validChars++;
|
||||
}
|
||||
}
|
||||
|
||||
const totalChecked = Math.min(decoded.length, 200);
|
||||
|
||||
// 如果替换字符超过10%或控制字符超过20%,说明不是有效的UTF-8文本
|
||||
if (replacementChars / totalChecked > 0.1 || controlChars / totalChecked > 0.2) {
|
||||
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
|
||||
}
|
||||
|
||||
// 如果有效字符超过50%,使用UTF-8解码
|
||||
if (validChars / totalChecked > 0.5) {
|
||||
return { displayValue: decoded, encoding: 'UTF-8', needsHex: false };
|
||||
}
|
||||
} catch (e) {
|
||||
// UTF-8解码失败
|
||||
}
|
||||
}
|
||||
|
||||
// 默认显示十六进制
|
||||
return { displayValue: toHexDisplay(value), encoding: 'HEX', needsHex: true };
|
||||
};
|
||||
|
||||
// 检测是否为二进制数据(包含大量不可打印字符)
|
||||
const isBinaryData = (value: string): boolean => {
|
||||
if (!value || value.length === 0) return false;
|
||||
@@ -64,15 +143,16 @@ const tryFormatJson = (value: string): { isJson: boolean; formatted: string } =>
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化字符串值 - 支持 JSON、二进制数据检测
|
||||
const formatStringValue = (value: string): { displayValue: string; isBinary: boolean; isJson: boolean } => {
|
||||
// 格式化字符串值 - 支持 JSON、二进制数据检测和智能解码
|
||||
const formatStringValue = (value: string): { displayValue: string; isBinary: boolean; isJson: boolean; encoding?: string } => {
|
||||
// 先检测是否为二进制数据
|
||||
if (isBinaryData(value)) {
|
||||
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false };
|
||||
const { displayValue, encoding, needsHex } = tryDecodeValue(value);
|
||||
return { displayValue, isBinary: needsHex, isJson: false, encoding };
|
||||
}
|
||||
// 尝试 JSON 格式化
|
||||
const { isJson, formatted } = tryFormatJson(value);
|
||||
return { displayValue: formatted, isBinary: false, isJson };
|
||||
return { displayValue: formatted, isBinary: false, isJson, encoding: 'UTF-8' };
|
||||
};
|
||||
|
||||
// 可拖拽分隔条组件 - 使用直接 DOM 操作避免卡顿
|
||||
@@ -245,6 +325,9 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
|
||||
// 视图模式状态(用于所有数据类型)
|
||||
const [viewMode, setViewMode] = useState<'auto' | 'text' | 'utf8' | 'hex'>('auto');
|
||||
|
||||
// JSON 编辑弹窗状态
|
||||
const [jsonEditModalOpen, setJsonEditModalOpen] = useState(false);
|
||||
const [jsonEditConfig, setJsonEditConfig] = useState<{
|
||||
@@ -513,17 +596,56 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
|
||||
const renderStringValue = () => {
|
||||
const strValue = String(keyValue.value);
|
||||
const { displayValue, isBinary, isJson } = formatStringValue(strValue);
|
||||
|
||||
// 根据查看模式生成显示内容
|
||||
const getDisplayContent = () => {
|
||||
if (viewMode === 'hex') {
|
||||
return { displayValue: toHexDisplay(strValue), isBinary: true, encoding: 'HEX' };
|
||||
} else if (viewMode === 'text') {
|
||||
return { displayValue: strValue, isBinary: false, encoding: 'Text' };
|
||||
} else if (viewMode === 'utf8') {
|
||||
try {
|
||||
const bytes = new Uint8Array(strValue.length);
|
||||
for (let i = 0; i < strValue.length; i++) {
|
||||
bytes[i] = strValue.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
||||
return { displayValue: decoded, isBinary: false, encoding: 'UTF-8' };
|
||||
} catch (e) {
|
||||
return { displayValue: strValue, isBinary: false, encoding: 'UTF-8 (失败)' };
|
||||
}
|
||||
} else {
|
||||
// auto mode
|
||||
const { displayValue, isBinary, isJson, encoding } = formatStringValue(strValue);
|
||||
return { displayValue, isBinary, encoding };
|
||||
}
|
||||
};
|
||||
|
||||
const { displayValue, isBinary, encoding } = getDisplayContent();
|
||||
const isJson = viewMode === 'auto' && formatStringValue(strValue).isJson;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{isBinary && (
|
||||
<div style={{ padding: '4px 8px', background: '#fff7e6', borderBottom: '1px solid #ffd591', color: '#d46b08', fontSize: 12 }}>
|
||||
⚠️ 检测到二进制数据,以十六进制格式显示
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
padding: '4px 8px',
|
||||
background: '#f5f5f5',
|
||||
borderBottom: '1px solid #d9d9d9',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<span style={{ fontSize: 12, color: '#666' }}>
|
||||
{encoding && `编码: ${encoding}`}
|
||||
</span>
|
||||
<Radio.Group size="small" value={viewMode} onChange={(e) => setViewMode(e.target.value)}>
|
||||
<Radio.Button value="auto">自动</Radio.Button>
|
||||
<Radio.Button value="text">原始文本</Radio.Button>
|
||||
<Radio.Button value="utf8">UTF-8</Radio.Button>
|
||||
<Radio.Button value="hex">十六进制</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<Editor
|
||||
height={isBinary ? "calc(100% - 64px)" : "calc(100% - 40px)"}
|
||||
height="calc(100% - 72px)"
|
||||
language={isJson ? 'json' : 'plaintext'}
|
||||
value={displayValue}
|
||||
options={{
|
||||
@@ -547,14 +669,16 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
message.error('复制失败');
|
||||
});
|
||||
}}>复制</Button>
|
||||
{!isBinary && (
|
||||
{!isBinary && viewMode === 'auto' && (
|
||||
<Button icon={<EditOutlined />} onClick={() => {
|
||||
setEditValue(displayValue);
|
||||
setEditModalOpen(true);
|
||||
}}>编辑</Button>
|
||||
)}
|
||||
{isBinary && (
|
||||
<span style={{ color: '#999', fontSize: 12 }}>二进制数据不支持编辑</span>
|
||||
{(isBinary || viewMode !== 'auto') && (
|
||||
<span style={{ color: '#999', fontSize: 12 }}>
|
||||
{viewMode !== 'auto' ? '切换到"自动"模式以编辑' : '二进制数据不支持编辑'}
|
||||
</span>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
@@ -563,9 +687,32 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
};
|
||||
|
||||
const renderHashValue = () => {
|
||||
// 根据查看模式处理值
|
||||
const processValue = (value: string) => {
|
||||
if (viewMode === 'hex') {
|
||||
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
|
||||
} else if (viewMode === 'text') {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
|
||||
} else if (viewMode === 'utf8') {
|
||||
try {
|
||||
const bytes = new Uint8Array(value.length);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
bytes[i] = value.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
||||
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
|
||||
} catch (e) {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
|
||||
}
|
||||
} else {
|
||||
// auto mode
|
||||
return formatStringValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
const data = Object.entries(keyValue.value as Record<string, string>).map(([field, value]) => {
|
||||
const { displayValue, isBinary, isJson } = formatStringValue(value);
|
||||
return { field, value, displayValue, isBinary, isJson };
|
||||
const { displayValue, isBinary, isJson, encoding } = processValue(value);
|
||||
return { field, value, displayValue, isBinary, isJson, encoding };
|
||||
});
|
||||
|
||||
const handleEditHashField = async (field: string, newValue: string) => {
|
||||
@@ -602,7 +749,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Button size="small" icon={<PlusOutlined />} onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '添加字段',
|
||||
@@ -625,6 +772,12 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
}
|
||||
});
|
||||
}}>添加字段</Button>
|
||||
<Radio.Group size="small" value={viewMode} onChange={(e) => setViewMode(e.target.value)}>
|
||||
<Radio.Button value="auto">自动</Radio.Button>
|
||||
<Radio.Button value="text">原始文本</Radio.Button>
|
||||
<Radio.Button value="utf8">UTF-8</Radio.Button>
|
||||
<Radio.Button value="hex">十六进制</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<Table
|
||||
dataSource={data}
|
||||
@@ -635,17 +788,23 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
dataIndex: 'displayValue',
|
||||
key: 'value',
|
||||
ellipsis: true,
|
||||
render: (text: string, record: any) => (
|
||||
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{text}</pre>} overlayStyle={{ maxWidth: 600 }}>
|
||||
<span style={{
|
||||
color: record.isBinary ? '#999' : (record.isJson ? '#1890ff' : undefined),
|
||||
fontFamily: record.isBinary ? 'monospace' : undefined,
|
||||
fontSize: record.isBinary ? 11 : undefined
|
||||
}}>
|
||||
{record.isBinary ? '[二进制数据]' : text}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
render: (text: string, record: any) => {
|
||||
const tooltipContent = record.encoding && record.encoding !== 'UTF-8'
|
||||
? `[${record.encoding}]\n${text}`
|
||||
: text;
|
||||
|
||||
return (
|
||||
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 600 } }}>
|
||||
<span style={{
|
||||
color: record.isBinary ? '#d46b08' : (record.isJson ? '#1890ff' : undefined),
|
||||
fontFamily: record.isBinary ? 'monospace' : undefined,
|
||||
fontSize: record.isBinary ? 11 : undefined
|
||||
}}>
|
||||
{text}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -695,9 +854,32 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
};
|
||||
|
||||
const renderListValue = () => {
|
||||
// 根据查看模式处理值
|
||||
const processValue = (value: string) => {
|
||||
if (viewMode === 'hex') {
|
||||
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
|
||||
} else if (viewMode === 'text') {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
|
||||
} else if (viewMode === 'utf8') {
|
||||
try {
|
||||
const bytes = new Uint8Array(value.length);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
bytes[i] = value.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
||||
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
|
||||
} catch (e) {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
|
||||
}
|
||||
} else {
|
||||
// auto mode
|
||||
return formatStringValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
const data = (keyValue.value as string[]).map((value, index) => {
|
||||
const { displayValue, isBinary, isJson } = formatStringValue(value);
|
||||
return { index, value, displayValue, isBinary, isJson };
|
||||
const { displayValue, isBinary, isJson, encoding } = processValue(value);
|
||||
return { index, value, displayValue, isBinary, isJson, encoding };
|
||||
});
|
||||
|
||||
const handleEditListItem = async (index: number, newValue: string) => {
|
||||
@@ -734,7 +916,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Space>
|
||||
<Button size="small" icon={<PlusOutlined />} onClick={() => {
|
||||
Modal.confirm({
|
||||
@@ -769,6 +951,12 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
});
|
||||
}}>添加到头部</Button>
|
||||
</Space>
|
||||
<Radio.Group size="small" value={viewMode} onChange={(e) => setViewMode(e.target.value)}>
|
||||
<Radio.Button value="auto">自动</Radio.Button>
|
||||
<Radio.Button value="text">原始文本</Radio.Button>
|
||||
<Radio.Button value="utf8">UTF-8</Radio.Button>
|
||||
<Radio.Button value="hex">十六进制</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<Table
|
||||
dataSource={data}
|
||||
@@ -779,17 +967,23 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
dataIndex: 'displayValue',
|
||||
key: 'value',
|
||||
ellipsis: true,
|
||||
render: (text: string, record: any) => (
|
||||
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{text}</pre>} overlayStyle={{ maxWidth: 600 }}>
|
||||
<span style={{
|
||||
color: record.isBinary ? '#999' : (record.isJson ? '#1890ff' : undefined),
|
||||
fontFamily: record.isBinary ? 'monospace' : undefined,
|
||||
fontSize: record.isBinary ? 11 : undefined
|
||||
}}>
|
||||
{record.isBinary ? '[二进制数据]' : text}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
render: (text: string, record: any) => {
|
||||
const tooltipContent = record.encoding && record.encoding !== 'UTF-8'
|
||||
? `[${record.encoding}]\n${text}`
|
||||
: text;
|
||||
|
||||
return (
|
||||
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 600 } }}>
|
||||
<span style={{
|
||||
color: record.isBinary ? '#d46b08' : (record.isJson ? '#1890ff' : undefined),
|
||||
fontFamily: record.isBinary ? 'monospace' : undefined,
|
||||
fontSize: record.isBinary ? 11 : undefined
|
||||
}}>
|
||||
{text}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -836,9 +1030,32 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
};
|
||||
|
||||
const renderSetValue = () => {
|
||||
// 根据查看模式处理值
|
||||
const processValue = (value: string) => {
|
||||
if (viewMode === 'hex') {
|
||||
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
|
||||
} else if (viewMode === 'text') {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
|
||||
} else if (viewMode === 'utf8') {
|
||||
try {
|
||||
const bytes = new Uint8Array(value.length);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
bytes[i] = value.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
||||
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
|
||||
} catch (e) {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
|
||||
}
|
||||
} else {
|
||||
// auto mode
|
||||
return formatStringValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
const data = (keyValue.value as string[]).map((member, index) => {
|
||||
const { displayValue, isBinary, isJson } = formatStringValue(member);
|
||||
return { index, member, displayValue, isBinary, isJson };
|
||||
const { displayValue, isBinary, isJson, encoding } = processValue(member);
|
||||
return { index, member, displayValue, isBinary, isJson, encoding };
|
||||
});
|
||||
|
||||
const handleAddSetMember = async (member: string) => {
|
||||
@@ -875,7 +1092,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Button size="small" icon={<PlusOutlined />} onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '添加成员',
|
||||
@@ -890,6 +1107,12 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
}
|
||||
});
|
||||
}}>添加成员</Button>
|
||||
<Radio.Group size="small" value={viewMode} onChange={(e) => setViewMode(e.target.value)}>
|
||||
<Radio.Button value="auto">自动</Radio.Button>
|
||||
<Radio.Button value="text">原始文本</Radio.Button>
|
||||
<Radio.Button value="utf8">UTF-8</Radio.Button>
|
||||
<Radio.Button value="hex">十六进制</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<Table
|
||||
dataSource={data}
|
||||
@@ -899,17 +1122,23 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
dataIndex: 'displayValue',
|
||||
key: 'member',
|
||||
ellipsis: true,
|
||||
render: (text: string, record: any) => (
|
||||
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{text}</pre>} overlayStyle={{ maxWidth: 600 }}>
|
||||
<span style={{
|
||||
color: record.isBinary ? '#999' : (record.isJson ? '#1890ff' : undefined),
|
||||
fontFamily: record.isBinary ? 'monospace' : undefined,
|
||||
fontSize: record.isBinary ? 11 : undefined
|
||||
}}>
|
||||
{record.isBinary ? '[二进制数据]' : text}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
render: (text: string, record: any) => {
|
||||
const tooltipContent = record.encoding && record.encoding !== 'UTF-8'
|
||||
? `[${record.encoding}]\n${text}`
|
||||
: text;
|
||||
|
||||
return (
|
||||
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 600 } }}>
|
||||
<span style={{
|
||||
color: record.isBinary ? '#d46b08' : (record.isJson ? '#1890ff' : undefined),
|
||||
fontFamily: record.isBinary ? 'monospace' : undefined,
|
||||
fontSize: record.isBinary ? 11 : undefined
|
||||
}}>
|
||||
{text}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -944,9 +1173,32 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
};
|
||||
|
||||
const renderZSetValue = () => {
|
||||
// 根据查看模式处理值
|
||||
const processValue = (value: string) => {
|
||||
if (viewMode === 'hex') {
|
||||
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
|
||||
} else if (viewMode === 'text') {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
|
||||
} else if (viewMode === 'utf8') {
|
||||
try {
|
||||
const bytes = new Uint8Array(value.length);
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
bytes[i] = value.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
||||
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
|
||||
} catch (e) {
|
||||
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
|
||||
}
|
||||
} else {
|
||||
// auto mode
|
||||
return formatStringValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
const data = (keyValue.value as Array<{ member: string; score: number }>).map((item, index) => {
|
||||
const { displayValue, isBinary, isJson } = formatStringValue(item.member);
|
||||
return { ...item, index, displayMember: displayValue, isBinary, isJson };
|
||||
const { displayValue, isBinary, isJson, encoding } = processValue(item.member);
|
||||
return { ...item, index, displayMember: displayValue, isBinary, isJson, encoding };
|
||||
});
|
||||
|
||||
const handleAddZSetMember = async (member: string, score: number) => {
|
||||
@@ -983,7 +1235,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Button size="small" icon={<PlusOutlined />} onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '添加成员',
|
||||
@@ -1008,6 +1260,12 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
}
|
||||
});
|
||||
}}>添加成员</Button>
|
||||
<Radio.Group size="small" value={viewMode} onChange={(e) => setViewMode(e.target.value)}>
|
||||
<Radio.Button value="auto">自动</Radio.Button>
|
||||
<Radio.Button value="text">原始文本</Radio.Button>
|
||||
<Radio.Button value="utf8">UTF-8</Radio.Button>
|
||||
<Radio.Button value="hex">十六进制</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<Table
|
||||
dataSource={data}
|
||||
@@ -1018,17 +1276,23 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
dataIndex: 'displayMember',
|
||||
key: 'member',
|
||||
ellipsis: true,
|
||||
render: (text: string, record: any) => (
|
||||
record.isBinary ? (
|
||||
<Tooltip title="二进制数据,无法直接显示">
|
||||
<span style={{ color: '#999', fontStyle: 'italic' }}>[二进制数据]</span>
|
||||
render: (text: string, record: any) => {
|
||||
const tooltipContent = record.encoding && record.encoding !== 'UTF-8'
|
||||
? `[${record.encoding}]\n${text}`
|
||||
: text;
|
||||
|
||||
return (
|
||||
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 600 } }}>
|
||||
<span style={{
|
||||
color: record.isBinary ? '#d46b08' : (record.isJson ? '#1890ff' : undefined),
|
||||
fontFamily: record.isBinary ? 'monospace' : undefined,
|
||||
fontSize: record.isBinary ? 11 : undefined
|
||||
}}>
|
||||
{text}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0 }}>{text}</pre>} overlayStyle={{ maxWidth: 600 }}>
|
||||
<span style={{ color: record.isJson ? '#1890ff' : undefined }}>{text}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState, useMemo, useRef } from 'react';
|
||||
import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge } from 'antd';
|
||||
import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, Checkbox, Space, Select } from 'antd';
|
||||
import {
|
||||
DatabaseOutlined,
|
||||
TableOutlined,
|
||||
@@ -23,7 +23,8 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge }
|
||||
ReloadOutlined,
|
||||
DeleteOutlined,
|
||||
DisconnectOutlined,
|
||||
CloudOutlined
|
||||
CloudOutlined,
|
||||
CheckSquareOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { SavedConnection } from '../types';
|
||||
@@ -42,14 +43,19 @@ interface TreeNode {
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> = ({ onEditConnection }) => {
|
||||
const { connections, savedQueries, addTab, setActiveContext, removeConnection } = useStore();
|
||||
const connections = useStore(state => state.connections);
|
||||
const savedQueries = useStore(state => state.savedQueries);
|
||||
const addTab = useStore(state => state.addTab);
|
||||
const setActiveContext = useStore(state => state.setActiveContext);
|
||||
const removeConnection = useStore(state => state.removeConnection);
|
||||
const [treeData, setTreeData] = useState<TreeNode[]>([]);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
|
||||
const [autoExpandParent, setAutoExpandParent] = useState(true);
|
||||
const [loadedKeys, setLoadedKeys] = useState<React.Key[]>([]);
|
||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||
const [selectedNodes, setSelectedNodes] = useState<any[]>([]);
|
||||
const selectedNodesRef = useRef<any[]>([]);
|
||||
const loadingNodesRef = useRef<Set<string>>(new Set());
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number, y: number, items: MenuProps['items'] } | null>(null);
|
||||
|
||||
// Virtual Scroll State
|
||||
@@ -75,6 +81,22 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const [createDbForm] = Form.useForm();
|
||||
const [targetConnection, setTargetConnection] = useState<any>(null);
|
||||
|
||||
// Batch Operations Modal
|
||||
const [isBatchModalOpen, setIsBatchModalOpen] = useState(false);
|
||||
const [batchTables, setBatchTables] = useState<any[]>([]);
|
||||
const [checkedTableKeys, setCheckedTableKeys] = useState<string[]>([]);
|
||||
const [batchDbContext, setBatchDbContext] = useState<any>(null);
|
||||
const [selectedConnection, setSelectedConnection] = useState<string>('');
|
||||
const [selectedDatabase, setSelectedDatabase] = useState<string>('');
|
||||
const [availableDatabases, setAvailableDatabases] = useState<any[]>([]);
|
||||
|
||||
// Batch Database Operations Modal
|
||||
const [isBatchDbModalOpen, setIsBatchDbModalOpen] = useState(false);
|
||||
const [batchDatabases, setBatchDatabases] = useState<any[]>([]);
|
||||
const [checkedDbKeys, setCheckedDbKeys] = useState<string[]>([]);
|
||||
const [batchConnContext, setBatchConnContext] = useState<any>(null);
|
||||
const [selectedDbConnection, setSelectedDbConnection] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
// Refresh queries for expanded databases
|
||||
const findNode = (nodes: TreeNode[], k: React.Key): TreeNode | null => {
|
||||
@@ -121,6 +143,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
|
||||
const loadDatabases = async (node: any) => {
|
||||
const conn = node.dataRef as SavedConnection;
|
||||
const loadKey = `dbs-${conn.id}`;
|
||||
if (loadingNodesRef.current.has(loadKey)) return;
|
||||
loadingNodesRef.current.add(loadKey);
|
||||
const config = {
|
||||
...conn.config,
|
||||
port: Number(conn.config.port),
|
||||
@@ -152,43 +177,52 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
setTreeData(origin => updateTreeData(origin, node.key, dbs));
|
||||
} else {
|
||||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
|
||||
message.error(res.message);
|
||||
message.error({ content: res.message, key: `conn-${conn.id}-dbs` });
|
||||
}
|
||||
} catch (e: any) {
|
||||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
|
||||
message.error('连接失败: ' + (e?.message || String(e)));
|
||||
message.error({ content: '连接失败: ' + (e?.message || String(e)), key: `conn-${conn.id}-dbs` });
|
||||
} finally {
|
||||
loadingNodesRef.current.delete(loadKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await DBGetDatabases(config as any);
|
||||
if (res.success) {
|
||||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
|
||||
let dbs = (res.data as any[]).map((row: any) => ({
|
||||
title: row.Database || row.database,
|
||||
key: `${conn.id}-${row.Database || row.database}`,
|
||||
icon: <DatabaseOutlined />,
|
||||
type: 'database' as const,
|
||||
dataRef: { ...conn, dbName: row.Database || row.database },
|
||||
isLeaf: false,
|
||||
}));
|
||||
try {
|
||||
const res = await DBGetDatabases(config as any);
|
||||
if (res.success) {
|
||||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
|
||||
let dbs = (res.data as any[]).map((row: any) => ({
|
||||
title: row.Database || row.database,
|
||||
key: `${conn.id}-${row.Database || row.database}`,
|
||||
icon: <DatabaseOutlined />,
|
||||
type: 'database' as const,
|
||||
dataRef: { ...conn, dbName: row.Database || row.database },
|
||||
isLeaf: false,
|
||||
}));
|
||||
|
||||
// Filter databases if configured
|
||||
if (conn.includeDatabases && conn.includeDatabases.length > 0) {
|
||||
dbs = dbs.filter(db => conn.includeDatabases!.includes(db.title));
|
||||
}
|
||||
// Filter databases if configured
|
||||
if (conn.includeDatabases && conn.includeDatabases.length > 0) {
|
||||
dbs = dbs.filter(db => conn.includeDatabases!.includes(db.title));
|
||||
}
|
||||
|
||||
setTreeData(origin => updateTreeData(origin, node.key, dbs));
|
||||
} else {
|
||||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
|
||||
message.error(res.message);
|
||||
}
|
||||
setTreeData(origin => updateTreeData(origin, node.key, dbs));
|
||||
} else {
|
||||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
|
||||
message.error({ content: res.message, key: `conn-${conn.id}-dbs` });
|
||||
}
|
||||
} finally {
|
||||
loadingNodesRef.current.delete(loadKey);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTables = async (node: any) => {
|
||||
const conn = node.dataRef; // has dbName
|
||||
const dbName = conn.dbName;
|
||||
const key = node.key;
|
||||
const loadKey = `tables-${conn.id}-${dbName}`;
|
||||
if (loadingNodesRef.current.has(loadKey)) return;
|
||||
loadingNodesRef.current.add(loadKey);
|
||||
|
||||
const dbQueries = savedQueries.filter(q => q.connectionId === conn.id && q.dbName === dbName);
|
||||
|
||||
@@ -216,26 +250,30 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
useSSH: conn.config.useSSH || false,
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
const res = await DBGetTables(config as any, conn.dbName);
|
||||
if (res.success) {
|
||||
setConnectionStates(prev => ({ ...prev, [key as string]: 'success' }));
|
||||
const tables = (res.data as any[]).map((row: any) => {
|
||||
const tableName = Object.values(row)[0] as string;
|
||||
return {
|
||||
title: tableName,
|
||||
key: `${conn.id}-${conn.dbName}-${tableName}`,
|
||||
icon: <TableOutlined />,
|
||||
type: 'table' as const,
|
||||
dataRef: { ...conn, tableName },
|
||||
isLeaf: false,
|
||||
};
|
||||
});
|
||||
|
||||
setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...tables]));
|
||||
} else {
|
||||
setConnectionStates(prev => ({ ...prev, [key as string]: 'error' }));
|
||||
message.error(res.message);
|
||||
}
|
||||
try {
|
||||
const res = await DBGetTables(config as any, conn.dbName);
|
||||
if (res.success) {
|
||||
setConnectionStates(prev => ({ ...prev, [key as string]: 'success' }));
|
||||
const tables = (res.data as any[]).map((row: any) => {
|
||||
const tableName = Object.values(row)[0] as string;
|
||||
return {
|
||||
title: tableName,
|
||||
key: `${conn.id}-${conn.dbName}-${tableName}`,
|
||||
icon: <TableOutlined />,
|
||||
type: 'table' as const,
|
||||
dataRef: { ...conn, tableName },
|
||||
isLeaf: false,
|
||||
};
|
||||
});
|
||||
|
||||
setTreeData(origin => updateTreeData(origin, key, [queriesNode, ...tables]));
|
||||
} else {
|
||||
setConnectionStates(prev => ({ ...prev, [key as string]: 'error' }));
|
||||
message.error({ content: res.message, key: `db-${key}-tables` });
|
||||
}
|
||||
} finally {
|
||||
loadingNodesRef.current.delete(loadKey);
|
||||
}
|
||||
};
|
||||
|
||||
const onLoadData = async ({ key, children, dataRef, type }: any) => {
|
||||
@@ -319,7 +357,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
|
||||
const onSelect = (keys: React.Key[], info: any) => {
|
||||
setSelectedKeys(keys);
|
||||
setSelectedNodes(info.selectedNodes || []);
|
||||
selectedNodesRef.current = info.selectedNodes || [];
|
||||
|
||||
if (keys.length === 0) {
|
||||
setActiveContext(null);
|
||||
@@ -489,6 +527,282 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}
|
||||
};
|
||||
|
||||
const openBatchOperationModal = async () => {
|
||||
// Check if current selected node is database or table
|
||||
let connId = '';
|
||||
let dbName = '';
|
||||
|
||||
if (selectedNodesRef.current.length > 0) {
|
||||
const node = selectedNodesRef.current[0];
|
||||
if (node.type === 'database') {
|
||||
connId = node.dataRef.id;
|
||||
dbName = node.title;
|
||||
} else if (node.type === 'table') {
|
||||
connId = node.dataRef.id;
|
||||
dbName = node.dataRef.dbName;
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedConnection(connId);
|
||||
setSelectedDatabase(dbName);
|
||||
setBatchTables([]);
|
||||
setCheckedTableKeys([]);
|
||||
setAvailableDatabases([]);
|
||||
|
||||
if (connId) {
|
||||
const conn = connections.find(c => c.id === connId);
|
||||
if (conn) {
|
||||
await loadDatabasesForBatch(conn);
|
||||
if (dbName) {
|
||||
await loadTablesForBatch(conn, dbName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIsBatchModalOpen(true);
|
||||
};
|
||||
|
||||
const loadDatabasesForBatch = async (conn: SavedConnection) => {
|
||||
const config = {
|
||||
...conn.config,
|
||||
port: Number(conn.config.port),
|
||||
password: conn.config.password || "",
|
||||
database: conn.config.database || "",
|
||||
useSSH: conn.config.useSSH || false,
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
|
||||
const res = await DBGetDatabases(config as any);
|
||||
if (res.success) {
|
||||
let dbs = (res.data as any[]).map((row: any) => {
|
||||
const dbName = row.Database || row.database;
|
||||
return {
|
||||
title: dbName,
|
||||
key: `${conn.id}-${dbName}`,
|
||||
dbName: dbName
|
||||
};
|
||||
});
|
||||
|
||||
if (conn.includeDatabases && conn.includeDatabases.length > 0) {
|
||||
dbs = dbs.filter(db => conn.includeDatabases!.includes(db.dbName));
|
||||
}
|
||||
|
||||
setAvailableDatabases(dbs);
|
||||
} else {
|
||||
message.error('获取数据库列表失败: ' + res.message);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTablesForBatch = async (conn: SavedConnection, dbName: string) => {
|
||||
setBatchDbContext({ conn, dbName });
|
||||
|
||||
const config = {
|
||||
...conn.config,
|
||||
port: Number(conn.config.port),
|
||||
password: conn.config.password || "",
|
||||
database: conn.config.database || "",
|
||||
useSSH: conn.config.useSSH || false,
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
|
||||
const res = await DBGetTables(config as any, dbName);
|
||||
if (res.success) {
|
||||
const tables = (res.data as any[]).map((row: any) => {
|
||||
const tableName = Object.values(row)[0] as string;
|
||||
return {
|
||||
title: tableName,
|
||||
key: `${conn.id}-${dbName}-${tableName}`,
|
||||
tableName: tableName,
|
||||
dataRef: { ...conn, tableName, dbName }
|
||||
};
|
||||
});
|
||||
|
||||
setBatchTables(tables);
|
||||
setCheckedTableKeys([]);
|
||||
} else {
|
||||
message.error('获取表列表失败: ' + res.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnectionChange = async (connId: string) => {
|
||||
setSelectedConnection(connId);
|
||||
setSelectedDatabase('');
|
||||
setBatchTables([]);
|
||||
setCheckedTableKeys([]);
|
||||
|
||||
const conn = connections.find(c => c.id === connId);
|
||||
if (conn) {
|
||||
await loadDatabasesForBatch(conn);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDatabaseChange = async (dbName: string) => {
|
||||
setSelectedDatabase(dbName);
|
||||
|
||||
const conn = connections.find(c => c.id === selectedConnection);
|
||||
if (conn && dbName) {
|
||||
await loadTablesForBatch(conn, dbName);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchExport = async (includeData: boolean) => {
|
||||
const selectedTables = batchTables.filter(t => checkedTableKeys.includes(t.key));
|
||||
if (selectedTables.length === 0) {
|
||||
message.warning('请至少选择一张表');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBatchModalOpen(false);
|
||||
|
||||
const { conn, dbName } = batchDbContext;
|
||||
const tableNames = selectedTables.map(t => t.tableName);
|
||||
|
||||
const hide = message.loading(includeData ? `正在备份选中表 (${tableNames.length})...` : `正在导出选中表结构 (${tableNames.length})...`, 0);
|
||||
try {
|
||||
const res = await (window as any).go.app.App.ExportTablesSQL(normalizeConnConfig(conn.config), dbName, tableNames, includeData);
|
||||
hide();
|
||||
if (res.success) {
|
||||
message.success('导出成功');
|
||||
} else if (res.message !== 'Cancelled') {
|
||||
message.error('导出失败: ' + res.message);
|
||||
}
|
||||
} catch (e: any) {
|
||||
hide();
|
||||
message.error('导出失败: ' + (e?.message || String(e)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setCheckedTableKeys(batchTables.map(t => t.key));
|
||||
} else {
|
||||
setCheckedTableKeys([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInvertSelection = () => {
|
||||
const allKeys = batchTables.map(t => t.key);
|
||||
const newChecked = allKeys.filter(k => !checkedTableKeys.includes(k));
|
||||
setCheckedTableKeys(newChecked);
|
||||
};
|
||||
|
||||
const openBatchDatabaseModal = async () => {
|
||||
// Check if current selected node is connection or database
|
||||
let connId = '';
|
||||
|
||||
if (selectedNodesRef.current.length > 0) {
|
||||
const node = selectedNodesRef.current[0];
|
||||
if (node.type === 'connection' && node.dataRef?.config?.type !== 'redis') {
|
||||
connId = node.key as string;
|
||||
} else if (node.type === 'database') {
|
||||
connId = node.dataRef.id;
|
||||
} else if (node.type === 'table') {
|
||||
connId = node.dataRef.id;
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedDbConnection(connId);
|
||||
setBatchDatabases([]);
|
||||
setCheckedDbKeys([]);
|
||||
|
||||
if (connId) {
|
||||
const conn = connections.find(c => c.id === connId);
|
||||
if (conn) {
|
||||
await loadDatabasesForDbBatch(conn);
|
||||
}
|
||||
}
|
||||
|
||||
setIsBatchDbModalOpen(true);
|
||||
};
|
||||
|
||||
const loadDatabasesForDbBatch = async (conn: SavedConnection) => {
|
||||
setBatchConnContext(conn);
|
||||
|
||||
const config = {
|
||||
...conn.config,
|
||||
port: Number(conn.config.port),
|
||||
password: conn.config.password || "",
|
||||
database: conn.config.database || "",
|
||||
useSSH: conn.config.useSSH || false,
|
||||
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
|
||||
};
|
||||
|
||||
const res = await DBGetDatabases(config as any);
|
||||
if (res.success) {
|
||||
let dbs = (res.data as any[]).map((row: any) => {
|
||||
const dbName = row.Database || row.database;
|
||||
return {
|
||||
title: dbName,
|
||||
key: `${conn.id}-${dbName}`,
|
||||
dbName: dbName,
|
||||
dataRef: { ...conn, dbName }
|
||||
};
|
||||
});
|
||||
|
||||
if (conn.includeDatabases && conn.includeDatabases.length > 0) {
|
||||
dbs = dbs.filter(db => conn.includeDatabases!.includes(db.dbName));
|
||||
}
|
||||
|
||||
setBatchDatabases(dbs);
|
||||
setCheckedDbKeys([]);
|
||||
} else {
|
||||
message.error('获取数据库列表失败: ' + res.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDbConnectionChange = async (connId: string) => {
|
||||
setSelectedDbConnection(connId);
|
||||
|
||||
const conn = connections.find(c => c.id === connId);
|
||||
if (conn) {
|
||||
await loadDatabasesForDbBatch(conn);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDbExport = async (includeData: boolean) => {
|
||||
const selectedDbs = batchDatabases.filter(db => checkedDbKeys.includes(db.key));
|
||||
if (selectedDbs.length === 0) {
|
||||
message.warning('请至少选择一个数据库');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBatchDbModalOpen(false);
|
||||
|
||||
for (const db of selectedDbs) {
|
||||
const hide = message.loading(includeData ? `正在备份数据库 ${db.dbName} (结构+数据)...` : `正在导出数据库 ${db.dbName} 表结构...`, 0);
|
||||
try {
|
||||
const res = await (window as any).go.app.App.ExportDatabaseSQL(normalizeConnConfig(batchConnContext.config), db.dbName, includeData);
|
||||
hide();
|
||||
if (res.success) {
|
||||
message.success(`${db.dbName} 导出成功`);
|
||||
} else if (res.message !== 'Cancelled') {
|
||||
message.error(`${db.dbName} 导出失败: ` + res.message);
|
||||
break;
|
||||
} else {
|
||||
break; // User cancelled
|
||||
}
|
||||
} catch (e: any) {
|
||||
hide();
|
||||
message.error(`${db.dbName} 导出失败: ` + (e?.message || String(e)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckAllDb = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setCheckedDbKeys(batchDatabases.map(db => db.key));
|
||||
} else {
|
||||
setCheckedDbKeys([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInvertSelectionDb = () => {
|
||||
const allKeys = batchDatabases.map(db => db.key);
|
||||
const newChecked = allKeys.filter(k => !checkedDbKeys.includes(k));
|
||||
setCheckedDbKeys(newChecked);
|
||||
};
|
||||
|
||||
const handleRunSQLFile = async (node: any) => {
|
||||
const res = await (window as any).go.app.App.OpenSQLFile();
|
||||
if (res.success) {
|
||||
@@ -791,9 +1105,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
setTreeData(origin => updateTreeData(origin, node.key, undefined));
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'new-query',
|
||||
label: '新建查询',
|
||||
{
|
||||
key: 'new-query',
|
||||
label: '新建查询',
|
||||
icon: <ConsoleSqlOutlined />,
|
||||
onClick: () => {
|
||||
addTab({
|
||||
@@ -813,25 +1127,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
}
|
||||
];
|
||||
} else if (node.type === 'table') {
|
||||
const sameContextSelectedTables = (selectedNodes || []).filter((n: any) => n?.type === 'table' && n?.dataRef?.id === node?.dataRef?.id && n?.dataRef?.dbName === node?.dataRef?.dbName);
|
||||
const selectedForAction = sameContextSelectedTables.some((n: any) => n?.key === node.key) ? sameContextSelectedTables : [node];
|
||||
|
||||
return [
|
||||
...(selectedForAction.length > 1 ? ([
|
||||
{
|
||||
key: 'export-selected-schema',
|
||||
label: `导出选中表结构 (${selectedForAction.length}) (SQL)`,
|
||||
icon: <ExportOutlined />,
|
||||
onClick: () => handleExportTablesSQL(selectedForAction, false)
|
||||
},
|
||||
{
|
||||
key: 'backup-selected-sql',
|
||||
label: `备份选中表 (${selectedForAction.length}) (结构+数据 SQL)`,
|
||||
icon: <SaveOutlined />,
|
||||
onClick: () => handleExportTablesSQL(selectedForAction, true)
|
||||
},
|
||||
{ type: 'divider' as const }
|
||||
]) : []),
|
||||
{
|
||||
key: 'new-query',
|
||||
label: '新建查询',
|
||||
@@ -914,6 +1210,27 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
<div style={{ padding: '4px 8px' }}>
|
||||
<Search placeholder="搜索..." onChange={onSearch} size="small" />
|
||||
</div>
|
||||
|
||||
{/* Toolbar for batch operations - always visible */}
|
||||
<div style={{ padding: '4px 8px', borderBottom: '1px solid #f0f0f0', display: 'flex', gap: 4 }}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CheckSquareOutlined />}
|
||||
onClick={() => openBatchOperationModal()}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
批量操作表
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CheckSquareOutlined />}
|
||||
onClick={() => openBatchDatabaseModal()}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
批量操作库
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div ref={treeContainerRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||
<Tree
|
||||
showIcon
|
||||
@@ -927,7 +1244,6 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
loadedKeys={loadedKeys}
|
||||
onLoad={setLoadedKeys}
|
||||
autoExpandParent={autoExpandParent}
|
||||
multiple
|
||||
selectedKeys={selectedKeys}
|
||||
blockNode
|
||||
height={treeHeight}
|
||||
@@ -959,6 +1275,206 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
{/* Charset option could be added here */}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="批量操作表"
|
||||
open={isBatchModalOpen}
|
||||
onCancel={() => setIsBatchModalOpen(false)}
|
||||
width={600}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={() => setIsBatchModalOpen(false)}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="export-schema"
|
||||
icon={<ExportOutlined />}
|
||||
onClick={() => handleBatchExport(false)}
|
||||
disabled={checkedTableKeys.length === 0}
|
||||
>
|
||||
导出表结构 ({checkedTableKeys.length})
|
||||
</Button>,
|
||||
<Button
|
||||
key="backup"
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={() => handleBatchExport(true)}
|
||||
disabled={checkedTableKeys.length === 0}
|
||||
>
|
||||
备份表 ({checkedTableKeys.length})
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>选择连接:</label>
|
||||
<Select
|
||||
value={selectedConnection}
|
||||
onChange={handleConnectionChange}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="请选择连接"
|
||||
>
|
||||
{connections.filter(c => c.config.type !== 'redis').map(conn => (
|
||||
<Select.Option key={conn.id} value={conn.id}>
|
||||
{conn.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>选择数据库:</label>
|
||||
<Select
|
||||
value={selectedDatabase}
|
||||
onChange={handleDatabaseChange}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="请先选择连接"
|
||||
disabled={!selectedConnection}
|
||||
>
|
||||
{availableDatabases.map(db => (
|
||||
<Select.Option key={db.key} value={db.dbName}>
|
||||
{db.title}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{batchTables.length > 0 && (
|
||||
<>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleCheckAll(true)}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleCheckAll(false)}
|
||||
>
|
||||
取消全选
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleInvertSelection}
|
||||
>
|
||||
反选
|
||||
</Button>
|
||||
<span style={{ color: '#999' }}>
|
||||
已选择 {checkedTableKeys.length} / {batchTables.length} 张表
|
||||
</span>
|
||||
</Space>
|
||||
</div>
|
||||
<div style={{ maxHeight: 400, overflow: 'auto', border: '1px solid #f0f0f0', borderRadius: 4, padding: 8 }}>
|
||||
<Checkbox.Group
|
||||
value={checkedTableKeys}
|
||||
onChange={(values) => setCheckedTableKeys(values as string[])}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{batchTables.map(table => (
|
||||
<Checkbox key={table.key} value={table.key}>
|
||||
<TableOutlined style={{ marginRight: 8 }} />
|
||||
{table.title}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="批量操作库"
|
||||
open={isBatchDbModalOpen}
|
||||
onCancel={() => setIsBatchDbModalOpen(false)}
|
||||
width={600}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={() => setIsBatchDbModalOpen(false)}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="export-schema"
|
||||
icon={<ExportOutlined />}
|
||||
onClick={() => handleBatchDbExport(false)}
|
||||
disabled={checkedDbKeys.length === 0}
|
||||
>
|
||||
导出库结构 ({checkedDbKeys.length})
|
||||
</Button>,
|
||||
<Button
|
||||
key="backup"
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={() => handleBatchDbExport(true)}
|
||||
disabled={checkedDbKeys.length === 0}
|
||||
>
|
||||
备份库 ({checkedDbKeys.length})
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>选择连接:</label>
|
||||
<Select
|
||||
value={selectedDbConnection}
|
||||
onChange={handleDbConnectionChange}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="请选择连接"
|
||||
>
|
||||
{connections.filter(c => c.config.type !== 'redis').map(conn => (
|
||||
<Select.Option key={conn.id} value={conn.id}>
|
||||
{conn.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{batchDatabases.length > 0 && (
|
||||
<>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleCheckAllDb(true)}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleCheckAllDb(false)}
|
||||
>
|
||||
取消全选
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleInvertSelectionDb}
|
||||
>
|
||||
反选
|
||||
</Button>
|
||||
<span style={{ color: '#999' }}>
|
||||
已选择 {checkedDbKeys.length} / {batchDatabases.length} 个库
|
||||
</span>
|
||||
</Space>
|
||||
</div>
|
||||
<div style={{ maxHeight: 400, overflow: 'auto', border: '1px solid #f0f0f0', borderRadius: 4, padding: 8 }}>
|
||||
<Checkbox.Group
|
||||
value={checkedDbKeys}
|
||||
onChange={(values) => setCheckedDbKeys(values as string[])}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{batchDatabases.map(db => (
|
||||
<Checkbox key={db.key} value={db.key}>
|
||||
<DatabaseOutlined style={{ marginRight: 8 }} />
|
||||
{db.title}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,14 @@ import RedisViewer from './RedisViewer';
|
||||
import RedisCommandEditor from './RedisCommandEditor';
|
||||
|
||||
const TabManager: React.FC = () => {
|
||||
const { tabs, activeTabId, setActiveTab, closeTab, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs } = useStore();
|
||||
const tabs = useStore(state => state.tabs);
|
||||
const activeTabId = useStore(state => state.activeTabId);
|
||||
const setActiveTab = useStore(state => state.setActiveTab);
|
||||
const closeTab = useStore(state => state.closeTab);
|
||||
const closeOtherTabs = useStore(state => state.closeOtherTabs);
|
||||
const closeTabsToLeft = useStore(state => state.closeTabsToLeft);
|
||||
const closeTabsToRight = useStore(state => state.closeTabsToRight);
|
||||
const closeAllTabs = useStore(state => state.closeAllTabs);
|
||||
|
||||
const onChange = (newActiveKey: string) => {
|
||||
setActiveTab(newActiveKey);
|
||||
|
||||
Reference in New Issue
Block a user