Files
MyGoNavi/frontend/src/components/RedisCommandEditor.tsx
tianqijiuyun-latiao 9364c48ef0 feat(i18n): 完善多模块多语言适配与发版验证
扩展前后端多语言文案与共享词典。增加多模块 i18n 回归测试与 guard。收口外部 SQL 菜单和弹窗多语言文案。
2026-06-17 13:17:33 +08:00

522 lines
22 KiB
TypeScript

import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { Button, Space, message } from 'antd';
import { PlayCircleOutlined, ClearOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { t, type I18nParams } from '../i18n';
import { useOptionalI18n } from '../i18n/provider';
import Editor, { type OnMount } from './MonacoEditor';
import {
isMacLikePlatform,
normalizeBlurForPlatform,
normalizeOpacityForPlatform,
resolveAppearanceValues,
} from '../utils/appearance';
import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme';
interface RedisCommandEditorProps {
connectionId: string;
redisDB: number;
}
interface CommandResult {
command: string;
result: any;
error?: string;
timestamp: number;
durationMs: number;
}
export const REDIS_COMMAND_EDITOR_MIN_HEIGHT = 120;
export const REDIS_COMMAND_OUTPUT_MIN_HEIGHT = 240;
export const REDIS_COMMAND_RESIZER_HEIGHT = 8;
export const clampRedisCommandEditorHeight = (
requestedHeight: number,
containerHeight: number | undefined,
): number => {
const minHeight = REDIS_COMMAND_EDITOR_MIN_HEIGHT;
const fallbackMaxHeight = 800;
const maxHeight = containerHeight
? Math.max(
minHeight,
containerHeight - REDIS_COMMAND_OUTPUT_MIN_HEIGHT - REDIS_COMMAND_RESIZER_HEIGHT,
)
: fallbackMaxHeight;
return Math.min(Math.max(requestedHeight, minHeight), maxHeight);
};
// 智能解析 Redis 脚本块,保护多行引号内的换行符
function parseRedisScriptBlocks(script: string): string[] {
const blocks: string[] = [];
let currentBlock = "";
let inQuote: string | null = null;
let isEscaping = false;
const lines = script.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
if (!inQuote && (trimmed === '' || trimmed.startsWith('//') || trimmed.startsWith('#'))) {
continue;
}
for (let j = 0; j < line.length; j++) {
const char = line[j];
if (isEscaping) {
isEscaping = false;
currentBlock += char;
continue;
}
if (char === '\\') {
isEscaping = true;
currentBlock += char;
continue;
}
if (char === '"' || char === "'") {
if (inQuote === char) {
inQuote = null;
} else if (!inQuote) {
inQuote = char;
}
}
currentBlock += char;
}
if (inQuote || (i < lines.length - 1 && currentBlock.trim() !== '')) {
if (!inQuote) {
blocks.push(currentBlock.trim());
currentBlock = "";
} else {
currentBlock += '\n';
}
}
}
if (currentBlock.trim() !== '') {
blocks.push(currentBlock.trim());
}
return blocks.filter(b => b.trim() !== '');
}
const RedisCommandEditor: React.FC<RedisCommandEditorProps> = ({ connectionId, redisDB }) => {
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const appearance = useStore(state => state.appearance);
const i18n = useOptionalI18n();
const i18nLanguage = i18n?.language;
const tr = (key: string, params?: I18nParams) => t(key, params, i18nLanguage);
const connection = connections.find(c => c.id === connectionId);
const darkMode = theme === 'dark';
const isV2Ui = appearance.uiVersion === 'v2';
const resolvedAppearance = resolveAppearanceValues(appearance);
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const blur = normalizeBlurForPlatform(resolvedAppearance.blur);
const disableLocalBackdropFilter = isMacLikePlatform();
const workbenchTheme = useMemo(
() => buildRedisWorkbenchTheme({ darkMode, opacity, blur, disableBackdropFilter: disableLocalBackdropFilter }),
[blur, darkMode, disableLocalBackdropFilter, opacity, appearance.uiVersion],
);
const [command, setCommand] = useState('');
const [results, setResults] = useState<CommandResult[]>([]);
const [loading, setLoading] = useState(false);
// UI Layout state
const [editorHeight, setEditorHeight] = useState(250);
const dragRef = useRef<{ startY: number; startHeight: number } | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const resultsEndRef = useRef<HTMLDivElement>(null);
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, monaco) => {
editorRef.current = editor;
editor.addCommand(
monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter,
() => handleExecute()
);
if (!(window as any).__redisCompletionRegistered) {
(window as any).__redisCompletionRegistered = true;
const redisCommands = [
"APPEND", "AUTH", "BGREWRITEAOF", "BGSAVE", "BITCOUNT", "BITFIELD", "BITOP",
"BITPOS", "BLPOP", "BRPOP", "BRPOPLPUSH", "BZMPOP", "BZPOPMIN", "BZPOPMAX",
"CLIENT", "CLUSTER", "COMMAND", "CONFIG", "DBSIZE", "DEBUG", "DECR", "DECRBY",
"DEL", "DISCARD", "DUMP", "ECHO", "EVAL", "EVALSHA", "EXEC", "EXISTS", "EXPIRE",
"EXPIREAT", "EXPIRETIME", "FLUSHALL", "FLUSHDB", "GEOADD", "GEODIST", "GEOHASH",
"GEOPOS", "GEORADIUS", "GEORADIUSBYMEMBER", "GEOSEARCH", "GEOSEARCHSTORE",
"GET", "GETBIT", "GETDEL", "GETEX", "GETRANGE", "GETSET", "HDEL", "HELLO",
"HEXISTS", "HGET", "HGETALL", "HINCRBY", "HINCRBYFLOAT", "HKEYS", "HLEN",
"HMGET", "HMSET", "HSCAN", "HSET", "HSETNX", "HSTRLEN", "HVALS", "INCR",
"INCRBY", "INCRBYFLOAT", "INFO", "KEYS", "LASTSAVE", "LCS", "LINDEX", "LINSERT",
"LLEN", "LMOVE", "LMPOP", "LPOP", "LPOS", "LPUSH", "LPUSHX", "LRANGE", "LREM",
"LSET", "LTRIM", "MEMORY", "MGET", "MIGRATE", "MODULE", "MONITOR", "MOVE", "MSET",
"MSETNX", "MULTI", "OBJECT", "PERSIST", "PEXPIRE", "PEXPIREAT", "PEXPIRETIME",
"PFADD", "PFCOUNT", "PFMERGE", "PING", "PSETEX", "PSUBSCRIBE", "PTTL", "PUBLISH",
"PUBSUB", "PUNSUBSCRIBE", "QUIT", "RANDOMKEY", "READONLY", "READWRITE", "RENAME",
"RENAMENX", "RESET", "RESTORE", "ROLE", "RPOP", "RPOPLPUSH", "RPUSH", "RPUSHX",
"SADD", "SAVE", "SCAN", "SCARD", "SCRIPT", "SDIFF", "SDIFFSTORE", "SELECT",
"SET", "SETBIT", "SETEX", "SETNX", "SETRANGE", "SHUTDOWN", "SINTER", "SINTERCARD",
"SINTERSTORE", "SISMEMBER", "SLAVEOF", "SLOWLOG", "SMEMBERS", "SMISMEMBER",
"SMOVE", "SORT", "SORT_RO", "SPOP", "SRANDMEMBER", "SREM", "SSCAN", "STRLEN",
"SUBSCRIBE", "SUNION", "SUNIONSTORE", "SWAPDB", "SYNC", "TIME", "TOUCH", "TTL",
"TYPE", "UNLINK", "UNSUBSCRIBE", "UNWATCH", "WAIT", "WATCH", "XACK", "XADD",
"XAUTOCLAIM", "XCLAIM", "XDEL", "XGROUP", "XINFO", "XLEN", "XPENDING", "XRANGE",
"XREAD", "XREADGROUP", "XREVRANGE", "XTRIM", "ZADD", "ZCARD", "ZCOUNT", "ZDIFF",
"ZDIFFSTORE", "ZINCRBY", "ZINTER", "ZINTERCARD", "ZINTERSTORE", "ZLEXCOUNT",
"ZMPOP", "ZMSCORE", "ZPOPMAX", "ZPOPMIN", "ZRANDMEMBER", "ZRANGE", "ZRANGEBYLEX",
"ZRANGEBYSCORE", "ZRANK", "ZREM", "ZREMRANGEBYLEX", "ZREMRANGEBYRANK",
"ZREMRANGEBYSCORE", "ZREVRANGE", "ZREVRANGEBYLEX", "ZREVRANGEBYSCORE", "ZREVRANK",
"ZSCAN", "ZSCORE", "ZUNION", "ZUNIONSTORE"
];
monaco.languages.registerCompletionItemProvider('redis', {
provideCompletionItems: (model: any, position: any) => {
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn
};
return {
suggestions: redisCommands.map(cmd => ({
label: cmd,
kind: monaco.languages.CompletionItemKind.Keyword,
insertText: cmd,
range: range,
detail: tr('redis_command.completion.detail')
}))
};
}
});
}
};
const handleExecute = async () => {
const config = getConfig();
if (!config) return;
let cmdToExecute = '';
// 1. 获取用户是否有高亮选中的文本
const selection = editorRef.current?.getSelection();
if (selection && !selection.isEmpty()) {
cmdToExecute = editorRef.current?.getModel()?.getValueInRange(selection) || '';
} else {
// 没有选中则取全部文本
cmdToExecute = editorRef.current?.getValue() || '';
}
cmdToExecute = cmdToExecute.trim();
if (!cmdToExecute) {
message.warning(tr('redis_command.message.command_required'));
return;
}
// 2. 智能解析多行命令
const commands = parseRedisScriptBlocks(cmdToExecute);
if (commands.length === 0) return;
setLoading(true);
const newResults: CommandResult[] = [];
for (const cmd of commands) {
const start = Date.now();
try {
const res = await (window as any).go.app.App.RedisExecuteCommand(buildRpcConnectionConfig(config), cmd);
newResults.push({
command: cmd,
result: res.success ? res.data : null,
error: res.success ? undefined : res.message,
timestamp: Date.now(),
durationMs: Date.now() - start
});
} catch (e: any) {
newResults.push({
command: cmd,
result: null,
error: e?.message || String(e),
timestamp: Date.now(),
durationMs: Date.now() - start
});
}
}
setResults(prev => [...prev, ...newResults]);
setLoading(false);
};
// Auto scroll to bottom when new results arrive
useEffect(() => {
if (resultsEndRef.current) {
resultsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [results]);
const handleClear = () => {
setResults([]);
};
const formatResult = (result: any): React.ReactNode => {
if (result === null || result === undefined) {
return <span style={{ color: '#569cd6' }}>(nil)</span>;
}
if (typeof result === 'string') {
// 尝试美化 JSON 字符串
try {
const parsed = JSON.parse(result);
if (typeof parsed === 'object' && parsed !== null) {
return (
<div style={{ marginTop: 4, padding: 8, background: 'rgba(0,0,0,0.2)', borderRadius: 4 }}>
{JSON.stringify(parsed, null, 2)}
</div>
);
}
} catch (e) {
// not a valid json, just return string
}
return <span style={{ color: '#ce9178' }}>"{result}"</span>;
}
if (typeof result === 'number') {
return <span style={{ color: '#b5cea8' }}>(integer) {result}</span>;
}
if (Array.isArray(result)) {
if (result.length === 0) {
return '(empty array)';
}
return (
<div style={{ marginLeft: 8 }}>
{result.map((item, index) => (
<div key={index} style={{ display: 'flex' }}>
<span style={{ color: '#608b4e', marginRight: 8, userSelect: 'none' }}>{index + 1})</span>
<div>{formatResult(item)}</div>
</div>
))}
</div>
);
}
if (typeof result === 'object') {
return JSON.stringify(result, null, 2);
}
return String(result);
};
// Resizing logic
const handleDragStart = (e: React.MouseEvent) => {
e.preventDefault();
dragRef.current = { startY: e.clientY, startHeight: editorHeight };
document.addEventListener('mousemove', handleDragMove);
document.addEventListener('mouseup', handleDragEnd);
document.body.style.cursor = 'row-resize';
};
const handleDragMove = useCallback((e: MouseEvent) => {
if (!dragRef.current) return;
const delta = e.clientY - dragRef.current.startY;
let newHeight = dragRef.current.startHeight + delta;
// 限制输入区高度,避免拖拽后压缩掉底部输出区。
newHeight = clampRedisCommandEditorHeight(
newHeight,
containerRef.current?.clientHeight,
);
setEditorHeight(newHeight);
// 更新编辑器布局
if (editorRef.current) {
editorRef.current.layout();
}
}, []);
const handleDragEnd = useCallback(() => {
dragRef.current = null;
document.removeEventListener('mousemove', handleDragMove);
document.removeEventListener('mouseup', handleDragEnd);
document.body.style.cursor = 'default';
if (editorRef.current) {
editorRef.current.layout();
}
}, [handleDragMove]);
if (!connection) {
return <div style={{ padding: 20 }}>{tr('redis_command.state.connection_not_found')}</div>;
}
return (
<div
ref={containerRef}
data-redis-command-editor="true"
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden',
background: workbenchTheme.appBg,
color: workbenchTheme.textPrimary,
backdropFilter: workbenchTheme.backdropFilter,
WebkitBackdropFilter: workbenchTheme.backdropFilter,
}}
>
{/* Editor Top Pane */}
<div
data-redis-command-input-pane="true"
style={{
height: editorHeight,
minHeight: REDIS_COMMAND_EDITOR_MIN_HEIGHT,
display: 'flex',
flexDirection: 'column',
background: workbenchTheme.panelBg,
borderBottom: workbenchTheme.panelBorder,
}}
>
<div style={{ padding: '8px 12px', borderBottom: workbenchTheme.panelBorder, display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: workbenchTheme.panelBgStrong }}>
<Space>
<span style={{ fontWeight: 600, color: workbenchTheme.textPrimary }}>{tr('redis_command.title.console')}</span>
<span style={{ color: workbenchTheme.textSecondary, fontSize: 13, background: workbenchTheme.statusTagMutedBg, border: workbenchTheme.statusTagMutedBorder, padding: '2px 8px', borderRadius: 12 }}>db{redisDB}</span>
</Space>
<Space>
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={handleExecute}
loading={loading}
>
{tr('redis_command.action.execute')}
</Button>
</Space>
</div>
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<Editor
theme={darkMode ? 'transparent-dark' : 'transparent-light'}
gonaviTypography="code"
defaultLanguage="redis"
language="redis"
value={command}
onChange={(value) => setCommand(value || '')}
onMount={handleEditorMount}
options={{
minimap: { enabled: false },
lineNumbers: 'on',
wordWrap: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 4,
padding: { top: 10, bottom: 10 }
}}
/>
</div>
</div>
{/* Resizer Handle */}
<div
className="horizontal-resizer"
data-redis-command-resizer="true"
onMouseDown={handleDragStart}
style={{
height: REDIS_COMMAND_RESIZER_HEIGHT,
cursor: 'row-resize',
background: workbenchTheme.panelBgStrong,
borderTop: workbenchTheme.panelBorder,
borderBottom: workbenchTheme.panelBorder,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 10
}}
>
<div style={{ width: 40, height: 4, background: workbenchTheme.textMuted, borderRadius: 2, opacity: 0.6 }} />
</div>
{/* Results Terminal Bottom Pane */}
<div
data-redis-command-output-pane="true"
style={{
flex: 1,
minHeight: REDIS_COMMAND_OUTPUT_MIN_HEIGHT,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
background: darkMode ? '#111418' : workbenchTheme.panelBg,
}}
>
<div style={{ padding: '4px 12px', background: darkMode ? '#1b1f27' : workbenchTheme.panelBgStrong, display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: workbenchTheme.panelBorder }}>
<span style={{ color: workbenchTheme.textSecondary, fontSize: 12 }}>{tr('redis_command.output.title')}</span>
<Button type="text" size="small" icon={<ClearOutlined />} onClick={handleClear} style={{ color: workbenchTheme.textSecondary }}>{tr('redis_command.action.clear_console')}</Button>
</div>
<div
data-redis-command-output-terminal="true"
style={{
flex: 1,
minHeight: 0,
overflow: 'auto',
background: darkMode ? '#111418' : '#f8fafc',
color: darkMode ? '#d4d4d4' : '#0f172a',
fontFamily: 'var(--gn-font-mono)',
fontSize: 'var(--gn-font-size-mono, 13px)',
lineHeight: '1.62',
padding: 12,
}}
>
{results.length === 0 ? (
<div style={{ color: workbenchTheme.textMuted, textAlign: 'center', marginTop: 40 }}>
<div>{tr('redis_command.output.empty_hint')}</div>
<div style={{ fontSize: 12, marginTop: 12 }}>
{tr('redis_command.output.selection_tip')}
</div>
</div>
) : (
results.map((item, index) => (
<div key={item.timestamp + index} style={{ marginBottom: 16 }}>
<div style={{ color: '#569cd6', marginBottom: 6, fontWeight: 'bold' }}>
<span style={{ color: '#4CAF50', marginRight: 8 }}></span>
{item.command}
<span style={{ color: workbenchTheme.textMuted, fontSize: 11, marginLeft: 12, fontWeight: 'normal' }}>[{item.durationMs}ms]</span>
</div>
<div style={{ paddingLeft: 20 }}>
{item.error ? (
<div style={{ color: '#f14c4c', whiteSpace: 'pre-wrap' }}>
(error) {item.error}
</div>
) : (
<div style={{ whiteSpace: 'pre-wrap' }}>
{formatResult(item.result)}
</div>
)}
</div>
</div>
))
)}
<div ref={resultsEndRef} />
</div>
</div>
</div>
);
};
export default RedisCommandEditor;