mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-07 06:13:03 +08:00
✨ feat(frontend/backend): 批量操作与表格编辑增强并完善事务支持
- 批量导出/备份:表与数据库支持全选/反选/智能上下文 - 右键菜单:单元格菜单支持设置 NULL - 编辑优化:大字段弹窗、仅值变化标记、提交只发送差异字段 - 事务支持:PostgreSQL/SQLite/Oracle/DaMeng/KingBase ApplyChanges - MySQL 修复:提交前归一化 datetime,避免写入失败 - 性能优化:移除 activeCell 重渲染、useRef 存储选中节点、防重加载 - Redis 优化:二进制智能解码与视图模式切换 - 资源更新:替换前端 favicon/logo
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GoNavi</title>
|
||||
</head>
|
||||
@@ -10,4 +10,4 @@
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
52
frontend/public/logo.svg
Normal file
52
frontend/public/logo.svg
Normal file
@@ -0,0 +1,52 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<!-- Background: Soft Light Grey -->
|
||||
<linearGradient id="bgSoft" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#f5f7fa;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#c3cfe2;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- Hexagon: Solid Tech Pink -->
|
||||
<linearGradient id="solidPink" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#FF5F6D;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#FFC371;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- N: Solid Tech Blue/Cyan -->
|
||||
<linearGradient id="solidCyan" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#00c6ff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0072ff;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<filter id="hardShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="4"/>
|
||||
<feOffset dx="4" dy="4" result="offsetblur"/>
|
||||
<feComponentTransfer>
|
||||
<feFuncA type="linear" slope="0.2"/>
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect x="32" y="32" width="448" height="448" rx="100" fill="url(#bgSoft)" />
|
||||
|
||||
<!-- Main Content Centered -->
|
||||
<g transform="translate(106, 106) scale(0.6)" filter="url(#hardShadow)">
|
||||
|
||||
<!-- Hex G -->
|
||||
<path d="M 250 0 L 466 125 L 466 375 L 250 500 L 34 375 L 34 125 Z"
|
||||
fill="none" stroke="url(#solidPink)" stroke-width="45" stroke-linejoin="round"/>
|
||||
|
||||
<!-- G Crossbar -->
|
||||
<path d="M 466 300 L 330 300" stroke="url(#solidPink)" stroke-width="45" stroke-linecap="round"/>
|
||||
|
||||
<!-- Inner N -->
|
||||
<path d="M 160 350 L 160 150 L 340 350 L 340 150"
|
||||
fill="none" stroke="url(#solidCyan)" stroke-width="50" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -17,7 +17,14 @@ function App() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isSyncModalOpen, setIsSyncModalOpen] = useState(false);
|
||||
const [editingConnection, setEditingConnection] = useState<SavedConnection | null>(null);
|
||||
const { darkMode, toggleDarkMode, addTab, activeContext, connections, addConnection, tabs, activeTabId } = useStore();
|
||||
const darkMode = useStore(state => state.darkMode);
|
||||
const toggleDarkMode = useStore(state => state.toggleDarkMode);
|
||||
const addTab = useStore(state => state.addTab);
|
||||
const activeContext = useStore(state => state.activeContext);
|
||||
const connections = useStore(state => state.connections);
|
||||
const addConnection = useStore(state => state.addConnection);
|
||||
const tabs = useStore(state => state.tabs);
|
||||
const activeTabId = useStore(state => state.activeTabId);
|
||||
|
||||
const handleNewQuery = () => {
|
||||
let connId = activeContext?.connectionId || '';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -10,23 +10,31 @@ import (
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
"GoNavi-Wails/internal/logger"
|
||||
)
|
||||
|
||||
const dbCachePingInterval = 30 * time.Second
|
||||
|
||||
type cachedDatabase struct {
|
||||
inst db.Database
|
||||
lastPing time.Time
|
||||
}
|
||||
|
||||
// App struct
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
dbCache map[string]db.Database // Cache for DB connections
|
||||
mu sync.Mutex // Mutex for cache access
|
||||
dbCache map[string]cachedDatabase // Cache for DB connections
|
||||
mu sync.RWMutex // Mutex for cache access
|
||||
}
|
||||
|
||||
// NewApp creates a new App application struct
|
||||
func NewApp() *App {
|
||||
return &App{
|
||||
dbCache: make(map[string]db.Database),
|
||||
dbCache: make(map[string]cachedDatabase),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +52,7 @@ func (a *App) Shutdown(ctx context.Context) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
for _, dbInst := range a.dbCache {
|
||||
if err := dbInst.Close(); err != nil {
|
||||
if err := dbInst.inst.Close(); err != nil {
|
||||
logger.Error(err, "关闭数据库连接失败")
|
||||
}
|
||||
}
|
||||
@@ -136,32 +144,63 @@ func formatConnSummary(config connection.ConnectionConfig) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (a *App) getDatabaseForcePing(config connection.ConnectionConfig) (db.Database, error) {
|
||||
return a.getDatabaseWithPing(config, true)
|
||||
}
|
||||
|
||||
// Helper: Get or create a database connection
|
||||
func (a *App) getDatabase(config connection.ConnectionConfig) (db.Database, error) {
|
||||
return a.getDatabaseWithPing(config, false)
|
||||
}
|
||||
|
||||
func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing bool) (db.Database, error) {
|
||||
key := getCacheKey(config)
|
||||
shortKey := key
|
||||
if len(shortKey) > 12 {
|
||||
shortKey = shortKey[:12]
|
||||
}
|
||||
logger.Infof("获取数据库连接:%s 缓存Key=%s", formatConnSummary(config), shortKey)
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.mu.RLock()
|
||||
entry, ok := a.dbCache[key]
|
||||
a.mu.RUnlock()
|
||||
if ok {
|
||||
needPing := forcePing
|
||||
if !needPing {
|
||||
lastPing := entry.lastPing
|
||||
if lastPing.IsZero() || time.Since(lastPing) >= dbCachePingInterval {
|
||||
needPing = true
|
||||
}
|
||||
}
|
||||
|
||||
if dbInst, ok := a.dbCache[key]; ok {
|
||||
logger.Infof("命中连接缓存,开始检测可用性:缓存Key=%s", shortKey)
|
||||
if err := dbInst.Ping(); err == nil {
|
||||
logger.Infof("缓存连接可用:缓存Key=%s", shortKey)
|
||||
return dbInst, nil
|
||||
if !needPing {
|
||||
return entry.inst, nil
|
||||
}
|
||||
|
||||
if err := entry.inst.Ping(); err == nil {
|
||||
// Update lastPing (best effort)
|
||||
a.mu.Lock()
|
||||
if cur, exists := a.dbCache[key]; exists && cur.inst == entry.inst {
|
||||
cur.lastPing = time.Now()
|
||||
a.dbCache[key] = cur
|
||||
}
|
||||
a.mu.Unlock()
|
||||
return entry.inst, nil
|
||||
} else {
|
||||
logger.Error(err, "缓存连接不可用,准备重建:缓存Key=%s", shortKey)
|
||||
logger.Error(err, "缓存连接不可用,准备重建:%s 缓存Key=%s", formatConnSummary(config), shortKey)
|
||||
}
|
||||
if err := dbInst.Close(); err != nil {
|
||||
logger.Error(err, "关闭失效缓存连接失败:缓存Key=%s", shortKey)
|
||||
|
||||
// Ping failed: remove cached instance (best effort)
|
||||
a.mu.Lock()
|
||||
if cur, exists := a.dbCache[key]; exists && cur.inst == entry.inst {
|
||||
if err := cur.inst.Close(); err != nil {
|
||||
logger.Error(err, "关闭失效缓存连接失败:缓存Key=%s", shortKey)
|
||||
}
|
||||
delete(a.dbCache, key)
|
||||
}
|
||||
delete(a.dbCache, key)
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
logger.Infof("获取数据库连接:%s 缓存Key=%s", formatConnSummary(config), shortKey)
|
||||
logger.Infof("创建数据库驱动实例:类型=%s 缓存Key=%s", config.Type, shortKey)
|
||||
dbInst, err := db.NewDatabase(config.Type)
|
||||
if err != nil {
|
||||
@@ -175,7 +214,18 @@ func (a *App) getDatabase(config connection.ConnectionConfig) (db.Database, erro
|
||||
return nil, wrapped
|
||||
}
|
||||
|
||||
a.dbCache[key] = dbInst
|
||||
now := time.Now()
|
||||
|
||||
a.mu.Lock()
|
||||
if existing, exists := a.dbCache[key]; exists && existing.inst != nil {
|
||||
a.mu.Unlock()
|
||||
// Prefer existing cached connection to avoid cache racing duplicates.
|
||||
_ = dbInst.Close()
|
||||
return existing.inst, nil
|
||||
}
|
||||
a.dbCache[key] = cachedDatabase{inst: dbInst, lastPing: now}
|
||||
a.mu.Unlock()
|
||||
|
||||
logger.Infof("数据库连接成功并写入缓存:%s 缓存Key=%s", formatConnSummary(config), shortKey)
|
||||
return dbInst, nil
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
// Generic DB Methods
|
||||
|
||||
func (a *App) DBConnect(config connection.ConnectionConfig) connection.QueryResult {
|
||||
// getDatabase checks cache and Pings. If valid, reuses. If not, connects.
|
||||
_, err := a.getDatabase(config)
|
||||
// 连接测试需要强制 ping,避免缓存命中但连接已失效时误判成功。
|
||||
_, err := a.getDatabaseForcePing(config)
|
||||
if err != nil {
|
||||
logger.Error(err, "DBConnect 连接失败:%s", formatConnSummary(config))
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
@@ -26,7 +26,7 @@ func (a *App) DBConnect(config connection.ConnectionConfig) connection.QueryResu
|
||||
}
|
||||
|
||||
func (a *App) TestConnection(config connection.ConnectionConfig) connection.QueryResult {
|
||||
_, err := a.getDatabase(config)
|
||||
_, err := a.getDatabaseForcePing(config)
|
||||
if err != nil {
|
||||
logger.Error(err, "TestConnection 连接测试失败:%s", formatConnSummary(config))
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
|
||||
@@ -201,10 +201,10 @@ func (a *App) ApplyChanges(config connection.ConnectionConfig, dbName, tableName
|
||||
if err != nil {
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
return connection.QueryResult{Success: true, Message: "Changes applied successfully"}
|
||||
return connection.QueryResult{Success: true, Message: "事务提交成功"}
|
||||
}
|
||||
|
||||
return connection.QueryResult{Success: false, Message: "Batch updates not supported for this database type"}
|
||||
|
||||
return connection.QueryResult{Success: false, Message: "当前数据库类型不支持批量提交"}
|
||||
}
|
||||
|
||||
func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tableName string, format string) connection.QueryResult {
|
||||
|
||||
@@ -248,7 +248,141 @@ func (c *CustomDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDe
|
||||
}
|
||||
|
||||
func (c *CustomDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
return fmt.Errorf("read-only mode for custom")
|
||||
if c.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
}
|
||||
|
||||
tx, err := c.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
driver := strings.ToLower(strings.TrimSpace(c.driver))
|
||||
isMySQL := strings.Contains(driver, "mysql")
|
||||
isPostgres := strings.Contains(driver, "postgres") || strings.Contains(driver, "kingbase") || strings.Contains(driver, "pg")
|
||||
isOracle := strings.Contains(driver, "oracle") || strings.Contains(driver, "ora") || strings.Contains(driver, "dm") || strings.Contains(driver, "dameng")
|
||||
|
||||
quoteIdent := func(name string) string {
|
||||
n := strings.TrimSpace(name)
|
||||
if isMySQL {
|
||||
n = strings.Trim(n, "`")
|
||||
n = strings.ReplaceAll(n, "`", "``")
|
||||
if n == "" {
|
||||
return "``"
|
||||
}
|
||||
return "`" + n + "`"
|
||||
}
|
||||
n = strings.Trim(n, "\"")
|
||||
n = strings.ReplaceAll(n, "\"", "\"\"")
|
||||
if n == "" {
|
||||
return "\"\""
|
||||
}
|
||||
return `"` + n + `"`
|
||||
}
|
||||
|
||||
placeholder := func(idx int) string {
|
||||
if isPostgres {
|
||||
return fmt.Sprintf("$%d", idx)
|
||||
}
|
||||
if isOracle {
|
||||
return fmt.Sprintf(":%d", idx)
|
||||
}
|
||||
// MySQL / SQLite / default
|
||||
return "?"
|
||||
}
|
||||
|
||||
schema := ""
|
||||
table := strings.TrimSpace(tableName)
|
||||
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
|
||||
schema = strings.TrimSpace(parts[0])
|
||||
table = strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
qualifiedTable := ""
|
||||
if schema != "" {
|
||||
qualifiedTable = fmt.Sprintf("%s.%s", quoteIdent(schema), quoteIdent(table))
|
||||
} else {
|
||||
qualifiedTable = quoteIdent(table)
|
||||
}
|
||||
|
||||
// 1. Deletes
|
||||
for _, pk := range changes.Deletes {
|
||||
var wheres []string
|
||||
var args []interface{}
|
||||
idx := 0
|
||||
for k, v := range pk {
|
||||
idx++
|
||||
wheres = append(wheres, fmt.Sprintf("%s = %s", quoteIdent(k), placeholder(idx)))
|
||||
args = append(args, v)
|
||||
}
|
||||
if len(wheres) == 0 {
|
||||
continue
|
||||
}
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("delete error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Updates
|
||||
for _, update := range changes.Updates {
|
||||
var sets []string
|
||||
var args []interface{}
|
||||
idx := 0
|
||||
|
||||
for k, v := range update.Values {
|
||||
idx++
|
||||
sets = append(sets, fmt.Sprintf("%s = %s", quoteIdent(k), placeholder(idx)))
|
||||
args = append(args, v)
|
||||
}
|
||||
|
||||
if len(sets) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var wheres []string
|
||||
for k, v := range update.Keys {
|
||||
idx++
|
||||
wheres = append(wheres, fmt.Sprintf("%s = %s", quoteIdent(k), placeholder(idx)))
|
||||
args = append(args, v)
|
||||
}
|
||||
|
||||
if len(wheres) == 0 {
|
||||
return fmt.Errorf("update requires keys")
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("update error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Inserts
|
||||
for _, row := range changes.Inserts {
|
||||
var cols []string
|
||||
var placeholders []string
|
||||
var args []interface{}
|
||||
idx := 0
|
||||
|
||||
for k, v := range row {
|
||||
idx++
|
||||
cols = append(cols, quoteIdent(k))
|
||||
placeholders = append(placeholders, placeholder(idx))
|
||||
args = append(args, v)
|
||||
}
|
||||
|
||||
if len(cols) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("insert error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (c *CustomDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
|
||||
@@ -373,7 +373,117 @@ func (d *DamengDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDe
|
||||
}
|
||||
|
||||
func (d *DamengDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
return fmt.Errorf("read-only mode implemented for Dameng so far")
|
||||
if d.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
}
|
||||
|
||||
tx, err := d.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
quoteIdent := func(name string) string {
|
||||
n := strings.TrimSpace(name)
|
||||
n = strings.Trim(n, "\"")
|
||||
n = strings.ReplaceAll(n, "\"", "\"\"")
|
||||
if n == "" {
|
||||
return "\"\""
|
||||
}
|
||||
return `"` + n + `"`
|
||||
}
|
||||
|
||||
schema := ""
|
||||
table := strings.TrimSpace(tableName)
|
||||
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
|
||||
schema = strings.TrimSpace(parts[0])
|
||||
table = strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
qualifiedTable := ""
|
||||
if schema != "" {
|
||||
qualifiedTable = fmt.Sprintf("%s.%s", quoteIdent(schema), quoteIdent(table))
|
||||
} else {
|
||||
qualifiedTable = quoteIdent(table)
|
||||
}
|
||||
|
||||
// 1. Deletes
|
||||
for _, pk := range changes.Deletes {
|
||||
var wheres []string
|
||||
var args []interface{}
|
||||
idx := 0
|
||||
for k, v := range pk {
|
||||
idx++
|
||||
wheres = append(wheres, fmt.Sprintf("%s = :%d", quoteIdent(k), idx))
|
||||
args = append(args, v)
|
||||
}
|
||||
if len(wheres) == 0 {
|
||||
continue
|
||||
}
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("delete error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Updates
|
||||
for _, update := range changes.Updates {
|
||||
var sets []string
|
||||
var args []interface{}
|
||||
idx := 0
|
||||
|
||||
for k, v := range update.Values {
|
||||
idx++
|
||||
sets = append(sets, fmt.Sprintf("%s = :%d", quoteIdent(k), idx))
|
||||
args = append(args, v)
|
||||
}
|
||||
|
||||
if len(sets) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var wheres []string
|
||||
for k, v := range update.Keys {
|
||||
idx++
|
||||
wheres = append(wheres, fmt.Sprintf("%s = :%d", quoteIdent(k), idx))
|
||||
args = append(args, v)
|
||||
}
|
||||
|
||||
if len(wheres) == 0 {
|
||||
return fmt.Errorf("update requires keys")
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("update error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Inserts
|
||||
for _, row := range changes.Inserts {
|
||||
var cols []string
|
||||
var placeholders []string
|
||||
var args []interface{}
|
||||
idx := 0
|
||||
|
||||
for k, v := range row {
|
||||
idx++
|
||||
cols = append(cols, quoteIdent(k))
|
||||
placeholders = append(placeholders, fmt.Sprintf(":%d", idx))
|
||||
args = append(args, v)
|
||||
}
|
||||
|
||||
if len(cols) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("insert error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (d *DamengDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
|
||||
@@ -597,7 +597,117 @@ func (k *KingbaseDB) GetTriggers(dbName, tableName string) ([]connection.Trigger
|
||||
}
|
||||
|
||||
func (k *KingbaseDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
return fmt.Errorf("read-only mode implemented for Kingbase so far")
|
||||
if k.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
}
|
||||
|
||||
tx, err := k.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
quoteIdent := func(name string) string {
|
||||
n := strings.TrimSpace(name)
|
||||
n = strings.Trim(n, "\"")
|
||||
n = strings.ReplaceAll(n, "\"", "\"\"")
|
||||
if n == "" {
|
||||
return "\"\""
|
||||
}
|
||||
return `"` + n + `"`
|
||||
}
|
||||
|
||||
schema := ""
|
||||
table := strings.TrimSpace(tableName)
|
||||
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
|
||||
schema = strings.TrimSpace(parts[0])
|
||||
table = strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
qualifiedTable := ""
|
||||
if schema != "" {
|
||||
qualifiedTable = fmt.Sprintf("%s.%s", quoteIdent(schema), quoteIdent(table))
|
||||
} else {
|
||||
qualifiedTable = quoteIdent(table)
|
||||
}
|
||||
|
||||
// 1. Deletes
|
||||
for _, pk := range changes.Deletes {
|
||||
var wheres []string
|
||||
var args []interface{}
|
||||
idx := 0
|
||||
for k, v := range pk {
|
||||
idx++
|
||||
wheres = append(wheres, fmt.Sprintf("%s = $%d", quoteIdent(k), idx))
|
||||
args = append(args, v)
|
||||
}
|
||||
if len(wheres) == 0 {
|
||||
continue
|
||||
}
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("delete error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Updates
|
||||
for _, update := range changes.Updates {
|
||||
var sets []string
|
||||
var args []interface{}
|
||||
idx := 0
|
||||
|
||||
for k, v := range update.Values {
|
||||
idx++
|
||||
sets = append(sets, fmt.Sprintf("%s = $%d", quoteIdent(k), idx))
|
||||
args = append(args, v)
|
||||
}
|
||||
|
||||
if len(sets) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var wheres []string
|
||||
for k, v := range update.Keys {
|
||||
idx++
|
||||
wheres = append(wheres, fmt.Sprintf("%s = $%d", quoteIdent(k), idx))
|
||||
args = append(args, v)
|
||||
}
|
||||
|
||||
if len(wheres) == 0 {
|
||||
return fmt.Errorf("update requires keys")
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("update error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Inserts
|
||||
for _, row := range changes.Inserts {
|
||||
var cols []string
|
||||
var placeholders []string
|
||||
var args []interface{}
|
||||
idx := 0
|
||||
|
||||
for k, v := range row {
|
||||
idx++
|
||||
cols = append(cols, quoteIdent(k))
|
||||
placeholders = append(placeholders, fmt.Sprintf("$%d", idx))
|
||||
args = append(args, v)
|
||||
}
|
||||
|
||||
if len(cols) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("insert error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (k *KingbaseDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
|
||||
@@ -318,15 +318,19 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
var args []interface{}
|
||||
for k, v := range pk {
|
||||
wheres = append(wheres, fmt.Sprintf("`%s` = ?", k))
|
||||
args = append(args, v)
|
||||
args = append(args, normalizeMySQLDateTimeValue(v))
|
||||
}
|
||||
if len(wheres) == 0 {
|
||||
continue
|
||||
}
|
||||
query := fmt.Sprintf("DELETE FROM `%s` WHERE %s", tableName, strings.Join(wheres, " AND "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
res, err := tx.Exec(query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete error: %v", err)
|
||||
}
|
||||
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
|
||||
return fmt.Errorf("删除未生效:未匹配到任何行")
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Updates
|
||||
@@ -336,7 +340,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
|
||||
for k, v := range update.Values {
|
||||
sets = append(sets, fmt.Sprintf("`%s` = ?", k))
|
||||
args = append(args, v)
|
||||
args = append(args, normalizeMySQLDateTimeValue(v))
|
||||
}
|
||||
|
||||
if len(sets) == 0 {
|
||||
@@ -346,7 +350,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
var wheres []string
|
||||
for k, v := range update.Keys {
|
||||
wheres = append(wheres, fmt.Sprintf("`%s` = ?", k))
|
||||
args = append(args, v)
|
||||
args = append(args, normalizeMySQLDateTimeValue(v))
|
||||
}
|
||||
|
||||
if len(wheres) == 0 {
|
||||
@@ -354,9 +358,13 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("UPDATE `%s` SET %s WHERE %s", tableName, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
res, err := tx.Exec(query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update error: %v", err)
|
||||
}
|
||||
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
|
||||
return fmt.Errorf("更新未生效:未匹配到任何行")
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Inserts
|
||||
@@ -368,7 +376,7 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
for k, v := range row {
|
||||
cols = append(cols, fmt.Sprintf("`%s`", k))
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, v)
|
||||
args = append(args, normalizeMySQLDateTimeValue(v))
|
||||
}
|
||||
|
||||
if len(cols) == 0 {
|
||||
@@ -376,14 +384,93 @@ func (m *MySQLDB) ApplyChanges(tableName string, changes connection.ChangeSet) e
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("INSERT INTO `%s` (%s) VALUES (%s)", tableName, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
res, err := tx.Exec(query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert error: %v", err)
|
||||
}
|
||||
if affected, err := res.RowsAffected(); err == nil && affected == 0 {
|
||||
return fmt.Errorf("插入未生效:未影响任何行")
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func normalizeMySQLDateTimeValue(value interface{}) interface{} {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
raw := strings.TrimSpace(text)
|
||||
if raw == "" {
|
||||
return value
|
||||
}
|
||||
|
||||
cleaned := strings.ReplaceAll(raw, "+ ", "+")
|
||||
cleaned = strings.ReplaceAll(cleaned, "- ", "-")
|
||||
|
||||
if len(cleaned) >= 19 && cleaned[10] == 'T' {
|
||||
if strings.HasSuffix(cleaned, "Z") || hasTimezoneOffset(cleaned) {
|
||||
if t, err := time.Parse(time.RFC3339Nano, cleaned); err == nil {
|
||||
return formatMySQLDateTime(t)
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, cleaned); err == nil {
|
||||
return formatMySQLDateTime(t)
|
||||
}
|
||||
}
|
||||
return strings.Replace(cleaned, "T", " ", 1)
|
||||
}
|
||||
|
||||
if strings.Contains(cleaned, " ") && (strings.HasSuffix(cleaned, "Z") || hasTimezoneOffset(cleaned)) {
|
||||
candidate := strings.Replace(cleaned, " ", "T", 1)
|
||||
if t, err := time.Parse(time.RFC3339Nano, candidate); err == nil {
|
||||
return formatMySQLDateTime(t)
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, candidate); err == nil {
|
||||
return formatMySQLDateTime(t)
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func hasTimezoneOffset(text string) bool {
|
||||
pos := strings.LastIndexAny(text, "+-")
|
||||
if pos < 0 || pos < 10 || pos+1 >= len(text) {
|
||||
return false
|
||||
}
|
||||
offset := text[pos+1:]
|
||||
if len(offset) == 5 && offset[2] == ':' {
|
||||
return isAllDigits(offset[:2]) && isAllDigits(offset[3:])
|
||||
}
|
||||
if len(offset) == 4 {
|
||||
return isAllDigits(offset)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isAllDigits(text string) bool {
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range text {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func formatMySQLDateTime(t time.Time) string {
|
||||
base := t.Format("2006-01-02 15:04:05")
|
||||
nanos := t.Nanosecond()
|
||||
if nanos == 0 {
|
||||
return base
|
||||
}
|
||||
micro := nanos / 1000
|
||||
return fmt.Sprintf("%s.%06d", base, micro)
|
||||
}
|
||||
|
||||
func (m *MySQLDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
query := fmt.Sprintf("SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '%s'", dbName)
|
||||
if dbName == "" {
|
||||
|
||||
@@ -363,8 +363,117 @@ func (o *OracleDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDe
|
||||
}
|
||||
|
||||
func (o *OracleDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
// TODO: Implement batch application for Oracle using correct syntax
|
||||
return fmt.Errorf("read-only mode implemented for Oracle so far")
|
||||
if o.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
}
|
||||
|
||||
tx, err := o.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
quoteIdent := func(name string) string {
|
||||
n := strings.TrimSpace(name)
|
||||
n = strings.Trim(n, "\"")
|
||||
n = strings.ReplaceAll(n, "\"", "\"\"")
|
||||
if n == "" {
|
||||
return "\"\""
|
||||
}
|
||||
return `"` + n + `"`
|
||||
}
|
||||
|
||||
schema := ""
|
||||
table := strings.TrimSpace(tableName)
|
||||
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
|
||||
schema = strings.TrimSpace(parts[0])
|
||||
table = strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
qualifiedTable := ""
|
||||
if schema != "" {
|
||||
qualifiedTable = fmt.Sprintf("%s.%s", quoteIdent(schema), quoteIdent(table))
|
||||
} else {
|
||||
qualifiedTable = quoteIdent(table)
|
||||
}
|
||||
|
||||
// 1. Deletes
|
||||
for _, pk := range changes.Deletes {
|
||||
var wheres []string
|
||||
var args []interface{}
|
||||
idx := 0
|
||||
for k, v := range pk {
|
||||
idx++
|
||||
wheres = append(wheres, fmt.Sprintf("%s = :%d", quoteIdent(k), idx))
|
||||
args = append(args, v)
|
||||
}
|
||||
if len(wheres) == 0 {
|
||||
continue
|
||||
}
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("delete error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Updates
|
||||
for _, update := range changes.Updates {
|
||||
var sets []string
|
||||
var args []interface{}
|
||||
idx := 0
|
||||
|
||||
for k, v := range update.Values {
|
||||
idx++
|
||||
sets = append(sets, fmt.Sprintf("%s = :%d", quoteIdent(k), idx))
|
||||
args = append(args, v)
|
||||
}
|
||||
|
||||
if len(sets) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var wheres []string
|
||||
for k, v := range update.Keys {
|
||||
idx++
|
||||
wheres = append(wheres, fmt.Sprintf("%s = :%d", quoteIdent(k), idx))
|
||||
args = append(args, v)
|
||||
}
|
||||
|
||||
if len(wheres) == 0 {
|
||||
return fmt.Errorf("update requires keys")
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("update error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Inserts
|
||||
for _, row := range changes.Inserts {
|
||||
var cols []string
|
||||
var placeholders []string
|
||||
var args []interface{}
|
||||
idx := 0
|
||||
|
||||
for k, v := range row {
|
||||
idx++
|
||||
cols = append(cols, quoteIdent(k))
|
||||
placeholders = append(placeholders, fmt.Sprintf(":%d", idx))
|
||||
args = append(args, v)
|
||||
}
|
||||
|
||||
if len(cols) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("insert error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (o *OracleDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
|
||||
@@ -521,3 +521,117 @@ ORDER BY table_schema, table_name, ordinal_position`
|
||||
}
|
||||
return cols, nil
|
||||
}
|
||||
|
||||
func (p *PostgresDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
if p.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
}
|
||||
|
||||
tx, err := p.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
quoteIdent := func(name string) string {
|
||||
n := strings.TrimSpace(name)
|
||||
n = strings.Trim(n, "\"")
|
||||
n = strings.ReplaceAll(n, "\"", "\"\"")
|
||||
if n == "" {
|
||||
return "\"\""
|
||||
}
|
||||
return `"` + n + `"`
|
||||
}
|
||||
|
||||
schema := ""
|
||||
table := strings.TrimSpace(tableName)
|
||||
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
|
||||
schema = strings.TrimSpace(parts[0])
|
||||
table = strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
qualifiedTable := ""
|
||||
if schema != "" {
|
||||
qualifiedTable = fmt.Sprintf("%s.%s", quoteIdent(schema), quoteIdent(table))
|
||||
} else {
|
||||
qualifiedTable = quoteIdent(table)
|
||||
}
|
||||
|
||||
// 1. Deletes
|
||||
for _, pk := range changes.Deletes {
|
||||
var wheres []string
|
||||
var args []interface{}
|
||||
idx := 0
|
||||
for k, v := range pk {
|
||||
idx++
|
||||
wheres = append(wheres, fmt.Sprintf("%s = $%d", quoteIdent(k), idx))
|
||||
args = append(args, v)
|
||||
}
|
||||
if len(wheres) == 0 {
|
||||
continue
|
||||
}
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("delete error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Updates
|
||||
for _, update := range changes.Updates {
|
||||
var sets []string
|
||||
var args []interface{}
|
||||
idx := 0
|
||||
|
||||
for k, v := range update.Values {
|
||||
idx++
|
||||
sets = append(sets, fmt.Sprintf("%s = $%d", quoteIdent(k), idx))
|
||||
args = append(args, v)
|
||||
}
|
||||
|
||||
if len(sets) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var wheres []string
|
||||
for k, v := range update.Keys {
|
||||
idx++
|
||||
wheres = append(wheres, fmt.Sprintf("%s = $%d", quoteIdent(k), idx))
|
||||
args = append(args, v)
|
||||
}
|
||||
|
||||
if len(wheres) == 0 {
|
||||
return fmt.Errorf("update requires keys")
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("update error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Inserts
|
||||
for _, row := range changes.Inserts {
|
||||
var cols []string
|
||||
var placeholders []string
|
||||
var args []interface{}
|
||||
idx := 0
|
||||
|
||||
for k, v := range row {
|
||||
idx++
|
||||
cols = append(cols, quoteIdent(k))
|
||||
placeholders = append(placeholders, fmt.Sprintf("$%d", idx))
|
||||
args = append(args, v)
|
||||
}
|
||||
|
||||
if len(cols) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("insert error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -445,6 +445,113 @@ func (s *SQLiteDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDe
|
||||
return triggers, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) ApplyChanges(tableName string, changes connection.ChangeSet) error {
|
||||
if s.conn == nil {
|
||||
return fmt.Errorf("connection not open")
|
||||
}
|
||||
|
||||
tx, err := s.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
quoteIdent := func(name string) string {
|
||||
n := strings.TrimSpace(name)
|
||||
n = strings.Trim(n, "\"")
|
||||
n = strings.ReplaceAll(n, "\"", "\"\"")
|
||||
if n == "" {
|
||||
return "\"\""
|
||||
}
|
||||
return `"` + n + `"`
|
||||
}
|
||||
|
||||
schema := ""
|
||||
table := strings.TrimSpace(tableName)
|
||||
if parts := strings.SplitN(table, ".", 2); len(parts) == 2 {
|
||||
schema = strings.TrimSpace(parts[0])
|
||||
table = strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
qualifiedTable := ""
|
||||
if schema != "" {
|
||||
qualifiedTable = fmt.Sprintf("%s.%s", quoteIdent(schema), quoteIdent(table))
|
||||
} else {
|
||||
qualifiedTable = quoteIdent(table)
|
||||
}
|
||||
|
||||
// 1. Deletes
|
||||
for _, pk := range changes.Deletes {
|
||||
var wheres []string
|
||||
var args []interface{}
|
||||
for k, v := range pk {
|
||||
wheres = append(wheres, fmt.Sprintf("%s = ?", quoteIdent(k)))
|
||||
args = append(args, v)
|
||||
}
|
||||
if len(wheres) == 0 {
|
||||
continue
|
||||
}
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE %s", qualifiedTable, strings.Join(wheres, " AND "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("delete error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Updates
|
||||
for _, update := range changes.Updates {
|
||||
var sets []string
|
||||
var args []interface{}
|
||||
|
||||
for k, v := range update.Values {
|
||||
sets = append(sets, fmt.Sprintf("%s = ?", quoteIdent(k)))
|
||||
args = append(args, v)
|
||||
}
|
||||
|
||||
if len(sets) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var wheres []string
|
||||
for k, v := range update.Keys {
|
||||
wheres = append(wheres, fmt.Sprintf("%s = ?", quoteIdent(k)))
|
||||
args = append(args, v)
|
||||
}
|
||||
|
||||
if len(wheres) == 0 {
|
||||
return fmt.Errorf("update requires keys")
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s", qualifiedTable, strings.Join(sets, ", "), strings.Join(wheres, " AND "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("update error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Inserts
|
||||
for _, row := range changes.Inserts {
|
||||
var cols []string
|
||||
var placeholders []string
|
||||
var args []interface{}
|
||||
|
||||
for k, v := range row {
|
||||
cols = append(cols, quoteIdent(k))
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, v)
|
||||
}
|
||||
|
||||
if len(cols) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", qualifiedTable, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return fmt.Errorf("insert error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
|
||||
tables, err := s.GetTables(dbName)
|
||||
if err != nil {
|
||||
|
||||
52
logo.svg
Normal file
52
logo.svg
Normal file
@@ -0,0 +1,52 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<!-- Background: Soft Light Grey -->
|
||||
<linearGradient id="bgSoft" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#f5f7fa;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#c3cfe2;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- Hexagon: Solid Tech Pink -->
|
||||
<linearGradient id="solidPink" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#FF5F6D;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#FFC371;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- N: Solid Tech Blue/Cyan -->
|
||||
<linearGradient id="solidCyan" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#00c6ff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0072ff;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
|
||||
<filter id="hardShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="4"/>
|
||||
<feOffset dx="4" dy="4" result="offsetblur"/>
|
||||
<feComponentTransfer>
|
||||
<feFuncA type="linear" slope="0.2"/>
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect x="32" y="32" width="448" height="448" rx="100" fill="url(#bgSoft)" />
|
||||
|
||||
<!-- Main Content Centered -->
|
||||
<g transform="translate(106, 106) scale(0.6)" filter="url(#hardShadow)">
|
||||
|
||||
<!-- Hex G -->
|
||||
<path d="M 250 0 L 466 125 L 466 375 L 250 500 L 34 375 L 34 125 Z"
|
||||
fill="none" stroke="url(#solidPink)" stroke-width="45" stroke-linejoin="round"/>
|
||||
|
||||
<!-- G Crossbar -->
|
||||
<path d="M 466 300 L 330 300" stroke="url(#solidPink)" stroke-width="45" stroke-linecap="round"/>
|
||||
|
||||
<!-- Inner N -->
|
||||
<path d="M 160 350 L 160 150 L 340 350 L 340 150"
|
||||
fill="none" stroke="url(#solidCyan)" stroke-width="50" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
Reference in New Issue
Block a user