feat(redis): 新增Redis数据源完整支持

- 后端实现:新增Redis客户端接口与go-redis实现,支持SSH隧道连接
  - API方法:新增21个Redis操作API(连接/Key/Value/命令执行等)
  - 连接配置:ConnectionModal支持Redis类型,自动识别端口与认证方式
  - 数据浏览:RedisViewer组件支持Key列表展示、类型识别与分页加载
  - 值编辑器:支持String/Hash/List/Set/ZSet五种数据类型的查看与编辑
  - 二进制处理:自动检测二进制数据并以十六进制格式展示
  - 命令终端:RedisCommandEditor支持多行命令执行与结果展示
  - 交互优化:JSON语法高亮编辑、一键复制值、面板宽度可调整
This commit is contained in:
Syngnat
2026-02-04 16:45:51 +08:00
parent 68770a42e2
commit 80fa7a1acd
19 changed files with 3201 additions and 38 deletions

View File

@@ -1,8 +1,8 @@
import React, { useState, useEffect } 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 } from '@ant-design/icons';
import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { DBConnect, DBGetDatabases, TestConnection } from '../../wailsjs/go/app/App';
import { DBConnect, DBGetDatabases, TestConnection, RedisConnect } from '../../wailsjs/go/app/App';
import { SavedConnection } from '../types';
const { Meta } = Card;
@@ -43,7 +43,8 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
sshKeyPath: initialValues.config.ssh?.keyPath,
driver: (initialValues.config as any).driver,
dsn: (initialValues.config as any).dsn,
timeout: (initialValues.config as any).timeout || 30
timeout: (initialValues.config as any).timeout || 30,
redisDB: (initialValues.config as any).redisDB || 0
});
setUseSSH(initialValues.config.useSSH || false);
setDbType(initialValues.config.type);
@@ -61,16 +62,21 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
try {
const values = await form.validateFields();
setLoading(true);
const config = await buildConfig(values);
const res = await DBConnect(config as any);
// Use different API for Redis
const isRedisType = values.type === 'redis';
const res = isRedisType
? await RedisConnect(config as any)
: await DBConnect(config as any);
setLoading(false);
if (res.success) {
const newConn = {
id: initialValues ? initialValues.id : Date.now().toString(),
name: values.name || (values.type === 'sqlite' ? 'SQLite DB' : values.host),
name: values.name || (values.type === 'sqlite' ? 'SQLite DB' : (values.type === 'redis' ? `Redis ${values.host}` : values.host)),
config: config,
includeDatabases: values.includeDatabases
};
@@ -82,7 +88,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
addConnection(newConn);
message.success('连接已保存!');
}
form.resetFields();
setUseSSH(false);
setDbType('mysql');
@@ -102,14 +108,23 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
setLoading(true);
setTestResult(null);
const config = await buildConfig(values);
const res = await TestConnection(config as any);
// Use different API for Redis
const isRedisType = values.type === 'redis';
const res = isRedisType
? await RedisConnect(config as any)
: await TestConnection(config as any);
setLoading(false);
if (res.success) {
setTestResult({ type: 'success', message: res.message });
const dbRes = await DBGetDatabases(config as any);
if (dbRes.success) {
const dbs = (dbRes.data as any[]).map((row: any) => row.Database || row.database);
setDbList(dbs);
// Only fetch database list for non-Redis connections
if (!isRedisType) {
const dbRes = await DBGetDatabases(config as any);
if (dbRes.success) {
const dbs = (dbRes.data as any[]).map((row: any) => row.Database || row.database);
setDbList(dbs);
}
}
} else {
setTestResult({ type: 'error', message: "测试失败: " + res.message });
@@ -128,7 +143,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
keyPath: values.sshKeyPath || ""
} : { host: "", port: 22, user: "", password: "", keyPath: "" };
return {
return {
type: values.type,
host: values.host || "",
port: Number(values.port || 0),
@@ -139,19 +154,21 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
ssh: sshConfig,
driver: values.driver,
dsn: values.dsn,
timeout: Number(values.timeout || 30)
timeout: Number(values.timeout || 30),
redisDB: Number(values.redisDB || 0)
};
};
const handleTypeSelect = (type: string) => {
setDbType(type);
form.setFieldsValue({ type: type });
// Auto-fill default port
let defaultPort = 3306;
switch (type) {
case 'mysql': defaultPort = 3306; break;
case 'postgres': defaultPort = 5432; break;
case 'redis': defaultPort = 6379; break;
case 'oracle': defaultPort = 1521; break;
case 'dameng': defaultPort = 5236; break;
case 'kingbase': defaultPort = 54321; break;
@@ -160,16 +177,22 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
if (type !== 'sqlite' && type !== 'custom') {
form.setFieldsValue({ port: defaultPort });
}
// Set default redisDB for Redis
if (type === 'redis') {
form.setFieldsValue({ redisDB: 0 });
}
setStep(2);
};
const isSqlite = dbType === 'sqlite';
const isCustom = dbType === 'custom';
const isRedis = dbType === 'redis';
const dbTypes = [
{ key: 'mysql', name: 'MySQL', icon: <ConsoleSqlOutlined style={{ fontSize: 24, color: '#00758F' }} /> },
{ key: 'postgres', name: 'PostgreSQL', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#336791' }} /> },
{ key: 'redis', name: 'Redis', icon: <CloudOutlined style={{ fontSize: 24, color: '#DC382D' }} /> },
{ key: 'sqlite', name: 'SQLite', icon: <FileTextOutlined style={{ fontSize: 24, color: '#003B57' }} /> },
{ key: 'oracle', name: 'Oracle', icon: <DatabaseOutlined style={{ fontSize: 24, color: '#F80000' }} /> },
{ key: 'dameng', name: 'Dameng (达梦)', icon: <CloudServerOutlined style={{ fontSize: 24, color: '#1890ff' }} /> },
@@ -235,7 +258,20 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
)}
</div>
{!isSqlite && (
{/* Redis specific: password only, no username */}
{isRedis && (
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="password" label="密码 (可选)" style={{ flex: 1 }}>
<Input.Password placeholder="Redis 密码(如果设置了 requirepass" />
</Form.Item>
<Form.Item name="redisDB" label="数据库 (0-15)" style={{ width: 120 }}>
<InputNumber style={{ width: '100%' }} min={0} max={15} />
</Form.Item>
</div>
)}
{/* Non-Redis, non-SQLite: username and password */}
{!isSqlite && !isRedis && (
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="user" label="用户名" rules={[{ required: true, message: '请输入用户名' }]} style={{ flex: 1 }}>
<Input />
@@ -245,8 +281,8 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
</Form.Item>
</div>
)}
{!isSqlite && (
{!isSqlite && !isRedis && (
<Form.Item name="includeDatabases" label="显示数据库 (留空显示全部)" help="连接测试成功后可选择">
<Select mode="multiple" placeholder="选择显示的数据库" allowClear>
{dbList.map(db => <Select.Option key={db} value={db}>{db}</Select.Option>)}

View File

@@ -0,0 +1,205 @@
import React, { useState, useCallback, useRef } from 'react';
import { Button, Space, message } from 'antd';
import { PlayCircleOutlined, ClearOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import Editor, { OnMount } from '@monaco-editor/react';
interface RedisCommandEditorProps {
connectionId: string;
redisDB: number;
}
interface CommandResult {
command: string;
result: any;
error?: string;
timestamp: number;
}
const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, redisDB }) => {
const { connections } = useStore();
const connection = connections.find(c => c.id === connectionId);
const [command, setCommand] = useState('');
const [results, setResults] = useState<CommandResult[]>([]);
const [loading, setLoading] = useState(false);
const editorRef = useRef<any>(null);
const getConfig = useCallback(() => {
if (!connection) return null;
return {
...connection.config,
port: Number(connection.config.port),
password: connection.config.password || "",
useSSH: connection.config.useSSH || false,
ssh: connection.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" },
redisDB: redisDB
};
}, [connection, redisDB]);
const handleEditorMount: OnMount = (editor) => {
editorRef.current = editor;
// Add keyboard shortcut for execute
editor.addCommand(
// Ctrl/Cmd + Enter
2048 | 3, // KeyMod.CtrlCmd | KeyCode.Enter
() => handleExecute()
);
};
const handleExecute = async () => {
const config = getConfig();
if (!config) return;
const cmdToExecute = command.trim();
if (!cmdToExecute) {
message.warning('请输入命令');
return;
}
// Support multiple commands separated by newlines
const commands = cmdToExecute.split('\n').filter(c => c.trim() && !c.trim().startsWith('//') && !c.trim().startsWith('#'));
setLoading(true);
const newResults: CommandResult[] = [];
for (const cmd of commands) {
const trimmedCmd = cmd.trim();
if (!trimmedCmd) continue;
try {
const res = await (window as any).go.app.App.RedisExecuteCommand(config, trimmedCmd);
newResults.push({
command: trimmedCmd,
result: res.success ? res.data : null,
error: res.success ? undefined : res.message,
timestamp: Date.now()
});
} catch (e: any) {
newResults.push({
command: trimmedCmd,
result: null,
error: e?.message || String(e),
timestamp: Date.now()
});
}
}
setResults(prev => [...newResults, ...prev]);
setLoading(false);
};
const handleClear = () => {
setResults([]);
};
const formatResult = (result: any): string => {
if (result === null || result === undefined) {
return '(nil)';
}
if (typeof result === 'string') {
return `"${result}"`;
}
if (typeof result === 'number') {
return `(integer) ${result}`;
}
if (Array.isArray(result)) {
if (result.length === 0) {
return '(empty array)';
}
return result.map((item, index) => `${index + 1}) ${formatResult(item)}`).join('\n');
}
if (typeof result === 'object') {
return JSON.stringify(result, null, 2);
}
return String(result);
};
if (!connection) {
return <div style={{ padding: 20 }}></div>;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Command Input */}
<div style={{ borderBottom: '1px solid #f0f0f0' }}>
<div style={{ padding: '8px 12px', borderBottom: '1px solid #f0f0f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<span style={{ fontWeight: 500 }}>Redis </span>
<span style={{ color: '#999', fontSize: 12 }}>db{redisDB}</span>
</Space>
<Space>
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={handleExecute}
loading={loading}
>
(Ctrl+Enter)
</Button>
<Button icon={<ClearOutlined />} onClick={handleClear}></Button>
</Space>
</div>
<Editor
height="150px"
defaultLanguage="plaintext"
value={command}
onChange={(value) => setCommand(value || '')}
onMount={handleEditorMount}
options={{
minimap: { enabled: false },
lineNumbers: 'on',
fontSize: 14,
wordWrap: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2
}}
/>
</div>
{/* Results */}
<div style={{ flex: 1, overflow: 'auto', background: '#1e1e1e', color: '#d4d4d4', fontFamily: 'monospace' }}>
{results.length === 0 ? (
<div style={{ padding: 20, color: '#666', textAlign: 'center' }}>
Redis Ctrl+Enter
<br />
<span style={{ fontSize: 12 }}></span>
</div>
) : (
results.map((item, index) => (
<div key={item.timestamp + index} style={{ padding: '8px 12px', borderBottom: '1px solid #333' }}>
<div style={{ color: '#569cd6', marginBottom: 4 }}>
&gt; {item.command}
</div>
{item.error ? (
<div style={{ color: '#f14c4c', whiteSpace: 'pre-wrap' }}>
(error) {item.error}
</div>
) : (
<div style={{ color: '#ce9178', whiteSpace: 'pre-wrap' }}>
{formatResult(item.result)}
</div>
)}
</div>
))
)}
</div>
{/* Common Commands Help */}
<div style={{ padding: '8px 12px', borderTop: '1px solid #f0f0f0', background: '#fafafa', fontSize: 12, color: '#666' }}>
:
<span style={{ marginLeft: 8 }}>
<code>KEYS *</code> |
<code style={{ marginLeft: 8 }}>GET key</code> |
<code style={{ marginLeft: 8 }}>SET key value</code> |
<code style={{ marginLeft: 8 }}>HGETALL key</code> |
<code style={{ marginLeft: 8 }}>INFO</code> |
<code style={{ marginLeft: 8 }}>DBSIZE</code>
</span>
</div>
</div>
);
};
export default RedisCommandEditor;

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
import React, { useEffect, useState, useMemo, useRef } from 'react';
import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge } from 'antd';
import {
DatabaseOutlined,
TableOutlined,
ConsoleSqlOutlined,
HddOutlined,
FolderOpenOutlined,
DatabaseOutlined,
TableOutlined,
ConsoleSqlOutlined,
HddOutlined,
FolderOpenOutlined,
FileTextOutlined,
CopyOutlined,
ExportOutlined,
@@ -22,7 +22,8 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge }
PlusOutlined,
ReloadOutlined,
DeleteOutlined,
DisconnectOutlined
DisconnectOutlined,
CloudOutlined
} from '@ant-design/icons';
import { useStore } from '../store';
import { SavedConnection } from '../types';
@@ -37,7 +38,7 @@ interface TreeNode {
children?: TreeNode[];
icon?: React.ReactNode;
dataRef?: any;
type?: 'connection' | 'database' | 'table' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers';
type?: 'connection' | 'database' | 'table' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db';
}
const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> = ({ onEditConnection }) => {
@@ -99,7 +100,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
setTreeData(connections.map(conn => ({
title: conn.name,
key: conn.id,
icon: <HddOutlined />,
icon: conn.config.type === 'redis' ? <CloudOutlined style={{ color: '#DC382D' }} /> : <HddOutlined />,
type: 'connection',
dataRef: conn,
isLeaf: false,
@@ -120,14 +121,41 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const loadDatabases = async (node: any) => {
const conn = node.dataRef as SavedConnection;
const config = {
...conn.config,
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: "" }
};
// Handle Redis connections differently
if (conn.config.type === 'redis') {
try {
const res = await (window as any).go.app.App.RedisGetDatabases(config);
if (res.success) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
const dbs = (res.data as any[]).map((db: any) => ({
title: `db${db.index}${db.keys > 0 ? ` (${db.keys})` : ''}`,
key: `${conn.id}-db${db.index}`,
icon: <DatabaseOutlined style={{ color: '#DC382D' }} />,
type: 'redis-db' as const,
dataRef: { ...conn, redisDB: db.index },
isLeaf: true,
}));
setTreeData(origin => updateTreeData(origin, node.key, dbs));
} else {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
message.error(res.message);
}
} catch (e: any) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
message.error('连接失败: ' + (e?.message || String(e)));
}
return;
}
const res = await DBGetDatabases(config as any);
if (res.success) {
setConnectionStates(prev => ({ ...prev, [conn.id]: 'success' }));
@@ -293,9 +321,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
return;
}
if (!info.selected) return;
const { type, dataRef, key, title } = info.node;
// Update active context
if (type === 'connection') {
setActiveContext({ connectionId: key, dbName: '' });
@@ -305,6 +333,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
} else if (type === 'saved-query') {
setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
} else if (type === 'redis-db') {
setActiveContext({ connectionId: dataRef.id, dbName: `db${dataRef.redisDB}` });
}
if (type === 'folder-columns') openDesign(info.node, 'columns', true);
@@ -341,6 +371,16 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
query: q.sql
});
return;
} else if (node.type === 'redis-db') {
const { id, redisDB } = node.dataRef;
addTab({
id: `redis-keys-${id}-db${redisDB}`,
title: `db${redisDB}`,
type: 'redis-keys',
connectionId: id,
redisDB: redisDB
});
return;
}
const key = node.key;
@@ -519,7 +559,80 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}, [searchValue, treeData]);
const getNodeMenuItems = (node: any): MenuProps['items'] => {
const conn = node.dataRef as SavedConnection;
const isRedis = conn?.config?.type === 'redis';
if (node.type === 'connection') {
// Redis connection menu
if (isRedis) {
return [
{
key: 'refresh',
label: '刷新',
icon: <ReloadOutlined />,
onClick: () => loadDatabases(node)
},
{ type: 'divider' },
{
key: 'new-command',
label: '新建命令窗口',
icon: <ConsoleSqlOutlined />,
onClick: () => {
addTab({
id: `redis-cmd-${node.key}-${Date.now()}`,
title: `命令 - ${node.title}`,
type: 'redis-command',
connectionId: node.key,
redisDB: 0
});
}
},
{ type: 'divider' },
{
key: 'edit',
label: '编辑连接',
icon: <EditOutlined />,
onClick: () => {
if (onEditConnection) onEditConnection(node.dataRef);
}
},
{
key: 'disconnect',
label: '断开连接',
icon: <DisconnectOutlined />,
onClick: () => {
setConnectionStates(prev => {
const next = { ...prev };
Object.keys(next).forEach(k => {
if (k === node.key || k.startsWith(`${node.key}-`)) {
delete next[k];
}
});
return next;
});
setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
setTreeData(origin => updateTreeData(origin, node.key, undefined));
message.success("已断开连接");
}
},
{
key: 'delete',
label: '删除连接',
icon: <DeleteOutlined />,
danger: true,
onClick: () => {
Modal.confirm({
title: '确认删除',
content: `确定要删除连接 "${node.title}" 吗?`,
onOk: () => removeConnection(node.key)
});
}
}
];
}
// Regular database connection menu
return [
{
key: 'new-db',
@@ -537,9 +650,9 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
onClick: () => loadDatabases(node)
},
{ type: 'divider' },
{
key: 'new-query',
label: '新建查询',
{
key: 'new-query',
label: '新建查询',
icon: <ConsoleSqlOutlined />,
onClick: () => {
addTab({
@@ -598,6 +711,39 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
}
}
];
} else if (node.type === 'redis-db') {
// Redis database menu
const { id, redisDB } = node.dataRef;
return [
{
key: 'open-keys',
label: '浏览 Key',
icon: <KeyOutlined />,
onClick: () => {
addTab({
id: `redis-keys-${id}-db${redisDB}`,
title: `db${redisDB}`,
type: 'redis-keys',
connectionId: id,
redisDB: redisDB
});
}
},
{
key: 'new-command',
label: '新建命令窗口',
icon: <ConsoleSqlOutlined />,
onClick: () => {
addTab({
id: `redis-cmd-${id}-db${redisDB}-${Date.now()}`,
title: `命令 - db${redisDB}`,
type: 'redis-command',
connectionId: id,
redisDB: redisDB
});
}
}
];
} else if (node.type === 'database') {
return [
{

View File

@@ -5,6 +5,8 @@ import { useStore } from '../store';
import DataViewer from './DataViewer';
import QueryEditor from './QueryEditor';
import TableDesigner from './TableDesigner';
import RedisViewer from './RedisViewer';
import RedisCommandEditor from './RedisCommandEditor';
const TabManager: React.FC = () => {
const { tabs, activeTabId, setActiveTab, closeTab, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs } = useStore();
@@ -27,6 +29,10 @@ const TabManager: React.FC = () => {
content = <DataViewer tab={tab} />;
} else if (tab.type === 'design') {
content = <TableDesigner tab={tab} />;
} else if (tab.type === 'redis-keys') {
content = <RedisViewer connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
} else if (tab.type === 'redis-command') {
content = <RedisCommandEditor connectionId={tab.connectionId} redisDB={tab.redisDB ?? 0} />;
}
const menuItems: MenuProps['items'] = [