Compare commits
10 Commits
eeff7271a1
...
586fe53f5f
| Author | SHA1 | Date | |
|---|---|---|---|
| 586fe53f5f | |||
| 05efb5d7f0 | |||
| 3e35258dd0 | |||
| 568ce93bf0 | |||
| 9aaa1eaf07 | |||
| 0bafc358aa | |||
| de265bb5a8 | |||
| 0e20b0df3a | |||
| 2387e6e5f4 | |||
| d7da96c0c5 |
7
package-lock.json
generated
7
package-lock.json
generated
@ -16,6 +16,7 @@
|
||||
"@capacitor/status-bar": "^5.0.6",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"electron-store": "^8.1.0",
|
||||
"framer-motion": "^10.16.16",
|
||||
@ -5866,6 +5867,12 @@
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-webgl": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmmirror.com/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
|
||||
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
|
||||
10
package.json
10
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "easyshell",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"description": "跨平台远程Shell管理终端 - 支持 Windows/Mac/Linux/Android",
|
||||
"author": "EasyShell Team",
|
||||
"main": "main.js",
|
||||
@ -11,7 +11,12 @@
|
||||
"build": "react-scripts build",
|
||||
"electron": "electron .",
|
||||
"pack": "electron-builder --dir",
|
||||
"dist": "npm run icons && npm run build && electron-builder",
|
||||
"dist": "npm run version:patch && npm run icons && npm run build && electron-builder",
|
||||
"dist:minor": "npm run version:minor && npm run icons && npm run build && electron-builder",
|
||||
"dist:major": "npm run version:major && npm run icons && npm run build && electron-builder",
|
||||
"version:patch": "node scripts/bump-version.js patch",
|
||||
"version:minor": "node scripts/bump-version.js minor",
|
||||
"version:major": "node scripts/bump-version.js major",
|
||||
"icons": "node scripts/generate-icons.js",
|
||||
"server": "cd server && npm start",
|
||||
"server:dev": "cd server && npm run dev",
|
||||
@ -76,6 +81,7 @@
|
||||
"@capacitor/status-bar": "^5.0.6",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"electron-store": "^8.1.0",
|
||||
"framer-motion": "^10.16.16",
|
||||
|
||||
63
scripts/bump-version.js
Normal file
63
scripts/bump-version.js
Normal file
@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 自动更新版本号脚本
|
||||
* 每次打包时自动递增补丁版本号 (1.0.0 -> 1.0.1)
|
||||
*
|
||||
* 使用方式:
|
||||
* node scripts/bump-version.js # 递增补丁版本 (patch)
|
||||
* node scripts/bump-version.js minor # 递增次版本 (minor)
|
||||
* node scripts/bump-version.js major # 递增主版本 (major)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 获取版本类型参数
|
||||
const versionType = process.argv[2] || 'patch';
|
||||
|
||||
// 读取 package.json
|
||||
const packagePath = path.join(__dirname, '..', 'package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
|
||||
// 解析当前版本
|
||||
const currentVersion = packageJson.version;
|
||||
const [major, minor, patch] = currentVersion.split('.').map(Number);
|
||||
|
||||
// 计算新版本
|
||||
let newVersion;
|
||||
switch (versionType) {
|
||||
case 'major':
|
||||
newVersion = `${major + 1}.0.0`;
|
||||
break;
|
||||
case 'minor':
|
||||
newVersion = `${major}.${minor + 1}.0`;
|
||||
break;
|
||||
case 'patch':
|
||||
default:
|
||||
newVersion = `${major}.${minor}.${patch + 1}`;
|
||||
break;
|
||||
}
|
||||
|
||||
// 更新 package.json
|
||||
packageJson.version = newVersion;
|
||||
fs.writeFileSync(packagePath, JSON.stringify(packageJson, null, 4) + '\n', 'utf8');
|
||||
|
||||
// 输出结果
|
||||
console.log(`📦 版本号已更新: ${currentVersion} -> ${newVersion}`);
|
||||
|
||||
// 可选:更新其他需要版本号的文件
|
||||
// 例如更新 App 中显示的版本号
|
||||
|
||||
// 更新 TitleBar 组件中的版本号(如果存在硬编码)
|
||||
const titleBarPath = path.join(__dirname, '..', 'src', 'components', 'TitleBar.js');
|
||||
if (fs.existsSync(titleBarPath)) {
|
||||
let titleBarContent = fs.readFileSync(titleBarPath, 'utf8');
|
||||
// 匹配多种版本号格式: v1.0, v1.0.0, V1.0, V1.0.0
|
||||
const versionRegex = /[vV]\d+\.\d+(\.\d+)?/g;
|
||||
if (versionRegex.test(titleBarContent)) {
|
||||
titleBarContent = titleBarContent.replace(versionRegex, `V${newVersion}`);
|
||||
fs.writeFileSync(titleBarPath, titleBarContent, 'utf8');
|
||||
console.log(`📝 TitleBar.js 版本号已更新`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ 版本更新完成!新版本: ${newVersion}`);
|
||||
41
src/App.js
41
src/App.js
@ -54,13 +54,37 @@ function App() {
|
||||
checkRemoteStatus();
|
||||
}, [loadHosts, checkRemoteStatus]);
|
||||
|
||||
// 关闭标签页 (需要在 useEffect 之前定义)
|
||||
const closeTab = useCallback((tabId) => {
|
||||
setActiveTabs((prev) => {
|
||||
const newTabs = prev.filter((t) => t.id !== tabId);
|
||||
return newTabs;
|
||||
});
|
||||
setActiveTabId((prevActiveId) => {
|
||||
if (prevActiveId === tabId) {
|
||||
// 找到要关闭的标签的索引
|
||||
const tabIndex = activeTabs.findIndex(t => t.id === tabId);
|
||||
const remainingTabs = activeTabs.filter((t) => t.id !== tabId);
|
||||
if (remainingTabs.length > 0) {
|
||||
// 优先切换到右边的标签,否则切换到左边的
|
||||
const newIndex = Math.min(tabIndex, remainingTabs.length - 1);
|
||||
return remainingTabs[newIndex].id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return prevActiveId;
|
||||
});
|
||||
}, [activeTabs]);
|
||||
|
||||
// 键盘快捷键
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
// Ctrl+K: 打开命令面板
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
setShowCommandPalette(true);
|
||||
}
|
||||
// Escape: 关闭弹窗
|
||||
if (e.key === 'Escape') {
|
||||
setShowCommandPalette(false);
|
||||
setShowHostManager(false);
|
||||
@ -90,21 +114,6 @@ function App() {
|
||||
setSelectedHost(null); // 关闭右侧编辑面板
|
||||
}, []);
|
||||
|
||||
// 关闭标签页
|
||||
const closeTab = useCallback((tabId) => {
|
||||
setActiveTabs((prev) => {
|
||||
const newTabs = prev.filter((t) => t.id !== tabId);
|
||||
return newTabs;
|
||||
});
|
||||
setActiveTabId((prevActiveId) => {
|
||||
if (prevActiveId === tabId) {
|
||||
const remainingTabs = activeTabs.filter((t) => t.id !== tabId);
|
||||
return remainingTabs.length > 0 ? remainingTabs[remainingTabs.length - 1].id : null;
|
||||
}
|
||||
return prevActiveId;
|
||||
});
|
||||
}, [activeTabs]);
|
||||
|
||||
// 更新连接状态
|
||||
const handleConnectionChange = useCallback((tabId, connected) => {
|
||||
setActiveTabs((prev) =>
|
||||
@ -350,11 +359,13 @@ function App() {
|
||||
<Terminal
|
||||
tabId={tab.id}
|
||||
hostId={tab.hostId}
|
||||
isActive={activeTabId === tab.id}
|
||||
onConnectionChange={(connected) => handleConnectionChange(tab.id, connected)}
|
||||
onShowCommandPalette={openCommandPalette}
|
||||
onToggleInfoPanel={() => setShowInfoPanel(!showInfoPanel)}
|
||||
onOpenSFTP={() => setShowSFTP(true)}
|
||||
showInfoPanel={showInfoPanel}
|
||||
onCloseTab={() => closeTab(tab.id)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
319
src/components/CommandSuggestions.js
Normal file
319
src/components/CommandSuggestions.js
Normal file
@ -0,0 +1,319 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { FiTerminal, FiFolder, FiFile, FiSettings, FiSearch, FiPackage, FiServer, FiDatabase } from 'react-icons/fi';
|
||||
|
||||
// 常用 Linux/Unix 命令库
|
||||
const COMMAND_DATABASE = [
|
||||
// 文件操作
|
||||
{ cmd: 'ls', desc: '列出目录内容', category: 'file', args: '-la' },
|
||||
{ cmd: 'cd', desc: '切换目录', category: 'file', args: '/path' },
|
||||
{ cmd: 'pwd', desc: '显示当前目录', category: 'file' },
|
||||
{ cmd: 'mkdir', desc: '创建目录', category: 'file', args: '-p dirname' },
|
||||
{ cmd: 'rmdir', desc: '删除空目录', category: 'file', args: 'dirname' },
|
||||
{ cmd: 'rm', desc: '删除文件/目录', category: 'file', args: '-rf path' },
|
||||
{ cmd: 'cp', desc: '复制文件/目录', category: 'file', args: '-r src dst' },
|
||||
{ cmd: 'mv', desc: '移动/重命名', category: 'file', args: 'src dst' },
|
||||
{ cmd: 'touch', desc: '创建空文件', category: 'file', args: 'filename' },
|
||||
{ cmd: 'cat', desc: '查看文件内容', category: 'file', args: 'filename' },
|
||||
{ cmd: 'head', desc: '查看文件头部', category: 'file', args: '-n 20 file' },
|
||||
{ cmd: 'tail', desc: '查看文件尾部', category: 'file', args: '-f logfile' },
|
||||
{ cmd: 'less', desc: '分页查看文件', category: 'file', args: 'filename' },
|
||||
{ cmd: 'more', desc: '分页显示文件', category: 'file', args: 'filename' },
|
||||
{ cmd: 'find', desc: '查找文件', category: 'file', args: '. -name "*.log"' },
|
||||
{ cmd: 'locate', desc: '快速定位文件', category: 'file', args: 'filename' },
|
||||
{ cmd: 'chmod', desc: '修改权限', category: 'file', args: '755 file' },
|
||||
{ cmd: 'chown', desc: '修改所有者', category: 'file', args: 'user:group file' },
|
||||
{ cmd: 'ln', desc: '创建链接', category: 'file', args: '-s target link' },
|
||||
{ cmd: 'tar', desc: '打包/解包', category: 'file', args: '-zxvf file.tar.gz' },
|
||||
{ cmd: 'zip', desc: '压缩文件', category: 'file', args: '-r archive.zip dir' },
|
||||
{ cmd: 'unzip', desc: '解压 zip', category: 'file', args: 'archive.zip' },
|
||||
{ cmd: 'gzip', desc: '压缩文件', category: 'file', args: 'filename' },
|
||||
{ cmd: 'gunzip', desc: '解压 gz', category: 'file', args: 'file.gz' },
|
||||
|
||||
// 文本处理
|
||||
{ cmd: 'grep', desc: '文本搜索', category: 'text', args: '-rn "pattern" .' },
|
||||
{ cmd: 'sed', desc: '流编辑器', category: 'text', args: "'s/old/new/g' file" },
|
||||
{ cmd: 'awk', desc: '文本处理', category: 'text', args: "'{print $1}' file" },
|
||||
{ cmd: 'sort', desc: '排序', category: 'text', args: '-n file' },
|
||||
{ cmd: 'uniq', desc: '去重', category: 'text', args: '-c file' },
|
||||
{ cmd: 'wc', desc: '统计行/字/字符', category: 'text', args: '-l file' },
|
||||
{ cmd: 'cut', desc: '切割文本', category: 'text', args: "-d':' -f1 file" },
|
||||
{ cmd: 'diff', desc: '比较文件', category: 'text', args: 'file1 file2' },
|
||||
{ cmd: 'echo', desc: '输出文本', category: 'text', args: '"Hello World"' },
|
||||
{ cmd: 'printf', desc: '格式化输出', category: 'text', args: '"%s\\n" text' },
|
||||
|
||||
// 系统信息
|
||||
{ cmd: 'top', desc: '系统监控', category: 'system' },
|
||||
{ cmd: 'htop', desc: '增强版 top', category: 'system' },
|
||||
{ cmd: 'ps', desc: '查看进程', category: 'system', args: 'aux' },
|
||||
{ cmd: 'kill', desc: '终止进程', category: 'system', args: '-9 PID' },
|
||||
{ cmd: 'killall', desc: '按名称终止', category: 'system', args: 'process_name' },
|
||||
{ cmd: 'df', desc: '磁盘使用', category: 'system', args: '-h' },
|
||||
{ cmd: 'du', desc: '目录大小', category: 'system', args: '-sh *' },
|
||||
{ cmd: 'free', desc: '内存使用', category: 'system', args: '-h' },
|
||||
{ cmd: 'uptime', desc: '运行时间', category: 'system' },
|
||||
{ cmd: 'uname', desc: '系统信息', category: 'system', args: '-a' },
|
||||
{ cmd: 'hostname', desc: '主机名', category: 'system' },
|
||||
{ cmd: 'whoami', desc: '当前用户', category: 'system' },
|
||||
{ cmd: 'id', desc: '用户信息', category: 'system' },
|
||||
{ cmd: 'w', desc: '登录用户', category: 'system' },
|
||||
{ cmd: 'last', desc: '登录历史', category: 'system' },
|
||||
{ cmd: 'dmesg', desc: '内核消息', category: 'system', args: '| tail' },
|
||||
{ cmd: 'lsof', desc: '打开的文件', category: 'system', args: '-i :80' },
|
||||
{ cmd: 'lscpu', desc: 'CPU 信息', category: 'system' },
|
||||
{ cmd: 'lsmem', desc: '内存信息', category: 'system' },
|
||||
{ cmd: 'lsblk', desc: '块设备', category: 'system' },
|
||||
{ cmd: 'fdisk', desc: '磁盘分区', category: 'system', args: '-l' },
|
||||
|
||||
// 网络
|
||||
{ cmd: 'ping', desc: '测试连通性', category: 'network', args: '-c 4 host' },
|
||||
{ cmd: 'curl', desc: 'HTTP 请求', category: 'network', args: '-I url' },
|
||||
{ cmd: 'wget', desc: '下载文件', category: 'network', args: 'url' },
|
||||
{ cmd: 'ssh', desc: 'SSH 连接', category: 'network', args: 'user@host' },
|
||||
{ cmd: 'scp', desc: '安全复制', category: 'network', args: 'file user@host:path' },
|
||||
{ cmd: 'rsync', desc: '同步文件', category: 'network', args: '-avz src dst' },
|
||||
{ cmd: 'netstat', desc: '网络统计', category: 'network', args: '-tunlp' },
|
||||
{ cmd: 'ss', desc: '套接字统计', category: 'network', args: '-tunlp' },
|
||||
{ cmd: 'ip', desc: 'IP 配置', category: 'network', args: 'addr show' },
|
||||
{ cmd: 'ifconfig', desc: '网络接口', category: 'network' },
|
||||
{ cmd: 'route', desc: '路由表', category: 'network', args: '-n' },
|
||||
{ cmd: 'iptables', desc: '防火墙', category: 'network', args: '-L -n' },
|
||||
{ cmd: 'nslookup', desc: 'DNS 查询', category: 'network', args: 'domain' },
|
||||
{ cmd: 'dig', desc: 'DNS 详细查询', category: 'network', args: 'domain' },
|
||||
{ cmd: 'traceroute', desc: '路由追踪', category: 'network', args: 'host' },
|
||||
{ cmd: 'tcpdump', desc: '抓包', category: 'network', args: '-i eth0' },
|
||||
|
||||
// 包管理
|
||||
{ cmd: 'apt', desc: 'Debian 包管理', category: 'package', args: 'update && apt upgrade' },
|
||||
{ cmd: 'apt-get', desc: 'APT 包管理', category: 'package', args: 'install package' },
|
||||
{ cmd: 'yum', desc: 'RHEL 包管理', category: 'package', args: 'install package' },
|
||||
{ cmd: 'dnf', desc: 'Fedora 包管理', category: 'package', args: 'install package' },
|
||||
{ cmd: 'pacman', desc: 'Arch 包管理', category: 'package', args: '-S package' },
|
||||
{ cmd: 'npm', desc: 'Node 包管理', category: 'package', args: 'install package' },
|
||||
{ cmd: 'pip', desc: 'Python 包管理', category: 'package', args: 'install package' },
|
||||
{ cmd: 'pip3', desc: 'Python3 包管理', category: 'package', args: 'install package' },
|
||||
|
||||
// 服务管理
|
||||
{ cmd: 'systemctl', desc: '服务管理', category: 'service', args: 'status service' },
|
||||
{ cmd: 'service', desc: '服务控制', category: 'service', args: 'nginx restart' },
|
||||
{ cmd: 'journalctl', desc: '查看日志', category: 'service', args: '-u service -f' },
|
||||
|
||||
// Docker
|
||||
{ cmd: 'docker', desc: 'Docker 命令', category: 'docker', args: 'ps -a' },
|
||||
{ cmd: 'docker-compose', desc: 'Docker Compose', category: 'docker', args: 'up -d' },
|
||||
|
||||
// Git
|
||||
{ cmd: 'git', desc: 'Git 版本控制', category: 'git', args: 'status' },
|
||||
|
||||
// 其他
|
||||
{ cmd: 'clear', desc: '清屏', category: 'other' },
|
||||
{ cmd: 'history', desc: '命令历史', category: 'other' },
|
||||
{ cmd: 'alias', desc: '别名列表', category: 'other' },
|
||||
{ cmd: 'which', desc: '命令路径', category: 'other', args: 'command' },
|
||||
{ cmd: 'whereis', desc: '二进制位置', category: 'other', args: 'command' },
|
||||
{ cmd: 'man', desc: '查看手册', category: 'other', args: 'command' },
|
||||
{ cmd: 'date', desc: '日期时间', category: 'other' },
|
||||
{ cmd: 'cal', desc: '日历', category: 'other' },
|
||||
{ cmd: 'env', desc: '环境变量', category: 'other' },
|
||||
{ cmd: 'export', desc: '设置变量', category: 'other', args: 'VAR=value' },
|
||||
{ cmd: 'source', desc: '执行脚本', category: 'other', args: 'script.sh' },
|
||||
{ cmd: 'crontab', desc: '定时任务', category: 'other', args: '-l' },
|
||||
{ cmd: 'nohup', desc: '后台运行', category: 'other', args: 'command &' },
|
||||
{ cmd: 'screen', desc: '终端复用', category: 'other', args: '-S session' },
|
||||
{ cmd: 'tmux', desc: '终端复用', category: 'other', args: 'new -s session' },
|
||||
{ cmd: 'vim', desc: '文本编辑器', category: 'other', args: 'filename' },
|
||||
{ cmd: 'nano', desc: '简易编辑器', category: 'other', args: 'filename' },
|
||||
{ cmd: 'vi', desc: 'Vi 编辑器', category: 'other', args: 'filename' },
|
||||
];
|
||||
|
||||
// 获取分类图标
|
||||
const getCategoryIcon = (category) => {
|
||||
switch (category) {
|
||||
case 'file': return FiFolder;
|
||||
case 'text': return FiFile;
|
||||
case 'system': return FiServer;
|
||||
case 'network': return FiDatabase;
|
||||
case 'package': return FiPackage;
|
||||
case 'service': return FiSettings;
|
||||
case 'docker': return FiPackage;
|
||||
case 'git': return FiSearch;
|
||||
default: return FiTerminal;
|
||||
}
|
||||
};
|
||||
|
||||
function CommandSuggestions({ input, position, onSelect, onClose, visible }) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const listRef = useRef(null);
|
||||
const selectedRef = useRef(null);
|
||||
|
||||
// 根据输入过滤命令
|
||||
const suggestions = React.useMemo(() => {
|
||||
if (!input || input.length < 1) return [];
|
||||
|
||||
const query = input.toLowerCase().trim();
|
||||
|
||||
// 分词,获取最后一个输入的词
|
||||
const parts = query.split(/\s+/);
|
||||
const lastWord = parts[parts.length - 1];
|
||||
const isFirstWord = parts.length === 1;
|
||||
|
||||
if (!lastWord) return [];
|
||||
|
||||
let results = [];
|
||||
|
||||
if (isFirstWord) {
|
||||
// 第一个词:搜索命令
|
||||
results = COMMAND_DATABASE.filter(item =>
|
||||
item.cmd.toLowerCase().startsWith(lastWord) ||
|
||||
item.desc.toLowerCase().includes(lastWord)
|
||||
);
|
||||
} else {
|
||||
// 后续词:搜索该命令的常用参数或相关命令
|
||||
const baseCmd = parts[0];
|
||||
const cmdInfo = COMMAND_DATABASE.find(c => c.cmd === baseCmd);
|
||||
|
||||
if (cmdInfo && cmdInfo.args) {
|
||||
// 如果有预设参数,显示参数提示
|
||||
results = [{
|
||||
cmd: `${baseCmd} ${cmdInfo.args}`,
|
||||
desc: `${cmdInfo.desc} (常用参数)`,
|
||||
category: cmdInfo.category,
|
||||
isFullCmd: true
|
||||
}];
|
||||
}
|
||||
|
||||
// 也搜索其他可能的命令组合
|
||||
const otherResults = COMMAND_DATABASE.filter(item =>
|
||||
item.cmd.toLowerCase().startsWith(lastWord)
|
||||
).slice(0, 3);
|
||||
|
||||
results = [...results, ...otherResults];
|
||||
}
|
||||
|
||||
return results.slice(0, 8);
|
||||
}, [input]);
|
||||
|
||||
// 重置选中索引
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [input]);
|
||||
|
||||
// 滚动到选中项
|
||||
useEffect(() => {
|
||||
if (selectedRef.current && listRef.current) {
|
||||
selectedRef.current.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
// 键盘导航
|
||||
useEffect(() => {
|
||||
if (!visible || suggestions.length === 0) return;
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSelectedIndex(prev => (prev + 1) % suggestions.length);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSelectedIndex(prev => (prev - 1 + suggestions.length) % suggestions.length);
|
||||
break;
|
||||
case 'Tab':
|
||||
case 'Enter':
|
||||
if (suggestions[selectedIndex]) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const item = suggestions[selectedIndex];
|
||||
onSelect(item.isFullCmd ? item.cmd : item.cmd);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [visible, suggestions, selectedIndex, onSelect, onClose]);
|
||||
|
||||
if (!visible || suggestions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute z-50 bg-shell-surface/95 backdrop-blur-xl border border-shell-accent/30
|
||||
rounded-lg shadow-2xl overflow-hidden min-w-[280px] max-w-[400px]"
|
||||
style={{
|
||||
bottom: position?.bottom || 60,
|
||||
left: position?.left || 16,
|
||||
}}
|
||||
>
|
||||
{/* 标题栏 */}
|
||||
<div className="px-3 py-1.5 border-b border-shell-border/50 flex items-center justify-between">
|
||||
<span className="text-[10px] text-shell-text-dim font-mono uppercase tracking-wider">
|
||||
命令提示
|
||||
</span>
|
||||
<span className="text-[10px] text-shell-text-dim">
|
||||
Tab 选择 · Esc 关闭
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 建议列表 */}
|
||||
<div ref={listRef} className="max-h-[240px] overflow-y-auto custom-scrollbar">
|
||||
{suggestions.map((item, index) => {
|
||||
const Icon = getCategoryIcon(item.category);
|
||||
const isSelected = index === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${item.cmd}-${index}`}
|
||||
ref={isSelected ? selectedRef : null}
|
||||
onClick={() => onSelect(item.isFullCmd ? item.cmd : item.cmd)}
|
||||
className={`
|
||||
px-3 py-2 cursor-pointer flex items-center gap-3 transition-all
|
||||
${isSelected
|
||||
? 'bg-shell-accent/20 border-l-2 border-shell-accent'
|
||||
: 'hover:bg-shell-card/50 border-l-2 border-transparent'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon
|
||||
size={14}
|
||||
className={isSelected ? 'text-shell-accent' : 'text-shell-text-dim'}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-mono text-sm ${isSelected ? 'text-shell-accent' : 'text-shell-text'}`}>
|
||||
{item.cmd}
|
||||
</span>
|
||||
{item.args && !item.isFullCmd && (
|
||||
<span className="text-xs text-shell-text-dim font-mono opacity-60">
|
||||
{item.args}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-shell-text-dim truncate">
|
||||
{item.desc}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommandSuggestions;
|
||||
@ -47,11 +47,11 @@ function HostInfoPanel({ hostId, connectionId, isConnected, onOpenSFTP, onClose
|
||||
echo "===CPU_CORES===$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null)"
|
||||
echo "===MEMORY===$(free -h 2>/dev/null | awk '/^Mem:/ {print $2}' || echo 'N/A')"
|
||||
echo "===MEMORY_USED===$(free -h 2>/dev/null | awk '/^Mem:/ {print $3}' || echo 'N/A')"
|
||||
echo "===DISK===$(df -h / | awk 'NR==2 {print $2}')"
|
||||
echo "===DISK_USED===$(df -h / | awk 'NR==2 {print $3}')"
|
||||
echo "===DISK_PERCENT===$(df -h / | awk 'NR==2 {print $5}')"
|
||||
echo "===LOAD===$(cat /proc/loadavg 2>/dev/null | awk '{print $1, $2, $3}' || uptime | awk -F'load average:' '{print $2}' | xargs)"
|
||||
echo "===IP===$(hostname -I 2>/dev/null | awk '{print $1}' || ifconfig 2>/dev/null | grep 'inet ' | grep -v 127.0.0.1 | head -1 | awk '{print $2}')"
|
||||
echo "===DISKS_START==="
|
||||
df -h -T 2>/dev/null | grep -E '^/dev/' | grep -v 'tmpfs\|devtmpfs\|squashfs\|overlay\|loop' | awk '{print $1"|"$2"|"$3"|"$4"|"$6"|"$7}' || df -h 2>/dev/null | grep -E '^/dev/' | awk '{print $1"|unknown|"$2"|"$3"|"$5"|"$6}'
|
||||
echo "===DISKS_END==="
|
||||
`
|
||||
);
|
||||
|
||||
@ -61,6 +61,26 @@ function HostInfoPanel({ hostId, connectionId, isConnected, onOpenSFTP, onClose
|
||||
return match ? match[1].trim() : 'N/A';
|
||||
};
|
||||
|
||||
// 解析多硬盘信息
|
||||
const disksMatch = result.stdout.match(/===DISKS_START===([\s\S]*?)===DISKS_END===/);
|
||||
const disks = [];
|
||||
if (disksMatch && disksMatch[1]) {
|
||||
const diskLines = disksMatch[1].trim().split('\n').filter(line => line.trim());
|
||||
for (const line of diskLines) {
|
||||
const parts = line.split('|');
|
||||
if (parts.length >= 6) {
|
||||
disks.push({
|
||||
device: parts[0],
|
||||
type: parts[1],
|
||||
total: parts[2],
|
||||
used: parts[3],
|
||||
percent: parts[4],
|
||||
mount: parts[5],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setSystemInfo({
|
||||
hostname: parseValue('HOSTNAME'),
|
||||
os: parseValue('OS'),
|
||||
@ -70,11 +90,9 @@ function HostInfoPanel({ hostId, connectionId, isConnected, onOpenSFTP, onClose
|
||||
cpuCores: parseValue('CPU_CORES'),
|
||||
memory: parseValue('MEMORY'),
|
||||
memoryUsed: parseValue('MEMORY_USED'),
|
||||
disk: parseValue('DISK'),
|
||||
diskUsed: parseValue('DISK_USED'),
|
||||
diskPercent: parseValue('DISK_PERCENT'),
|
||||
load: parseValue('LOAD'),
|
||||
ip: parseValue('IP'),
|
||||
disks: disks.length > 0 ? disks : [{ device: '/dev/sda1', type: 'unknown', total: 'N/A', used: 'N/A', percent: '0%', mount: '/' }],
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@ -340,19 +358,46 @@ function HostInfoPanel({ hostId, connectionId, isConnected, onOpenSFTP, onClose
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 磁盘使用 */}
|
||||
{/* 磁盘使用 - 支持多硬盘 */}
|
||||
<div className="bg-shell-surface/50 rounded-lg p-3 border border-shell-border/50">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FiHardDrive size={14} className="text-shell-orange" />
|
||||
<span className="text-shell-text-dim text-xs">磁盘 (/)</span>
|
||||
<span className="text-shell-text-dim text-xs">磁盘</span>
|
||||
<span className="text-shell-text-dim text-xs ml-auto">
|
||||
共 {systemInfo.disks?.length || 0} 个分区
|
||||
</span>
|
||||
</div>
|
||||
<UsageBar
|
||||
label="使用率"
|
||||
used={systemInfo.diskUsed}
|
||||
total={systemInfo.disk}
|
||||
percent={parseInt(systemInfo.diskPercent) || 0}
|
||||
<div className="space-y-3 max-h-48 overflow-y-auto custom-scrollbar pr-1">
|
||||
{systemInfo.disks?.map((disk, index) => (
|
||||
<div key={index} className="bg-shell-bg/30 rounded-lg p-2">
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="text-shell-accent font-mono truncate max-w-[120px]" title={disk.mount}>
|
||||
{disk.mount}
|
||||
</span>
|
||||
<span className="text-shell-text-dim text-[10px]" title={disk.device}>
|
||||
{disk.type !== 'unknown' ? disk.type : disk.device.split('/').pop()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-shell-text-dim">使用</span>
|
||||
<span className="text-shell-text">{disk.used} / {disk.total}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-shell-border/50 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${parseInt(disk.percent) || 0}%`,
|
||||
backgroundColor: parseInt(disk.percent) > 80 ? '#f85149' : parseInt(disk.percent) > 60 ? '#d29922' : '#58a6ff'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right text-[10px] text-shell-text-dim mt-0.5">
|
||||
{disk.percent}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-shell-text-dim text-sm">
|
||||
|
||||
@ -3,10 +3,12 @@ import { motion } from 'framer-motion';
|
||||
import { Terminal as XTerm } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import { WebglAddon } from '@xterm/addon-webgl';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { FiCommand, FiRefreshCw, FiInfo, FiFolder, FiActivity, FiZap } from 'react-icons/fi';
|
||||
import CommandSuggestions from './CommandSuggestions';
|
||||
|
||||
function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette, onToggleInfoPanel, onOpenSFTP, showInfoPanel }) {
|
||||
function Terminal({ tabId, hostId, isActive, onConnectionChange, onShowCommandPalette, onToggleInfoPanel, onOpenSFTP, showInfoPanel, onCloseTab }) {
|
||||
const containerRef = useRef(null);
|
||||
const terminalRef = useRef(null);
|
||||
const xtermRef = useRef(null);
|
||||
@ -19,12 +21,69 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette, onT
|
||||
const hasConnectedRef = useRef(false);
|
||||
const resizeObserverRef = useRef(null);
|
||||
const contextMenuHandlerRef = useRef(null);
|
||||
const resizeTimeoutRef = useRef(null);
|
||||
const webglAddonRef = useRef(null);
|
||||
const keydownHandlerRef = useRef(null);
|
||||
|
||||
const onConnectionChangeRef = useRef(onConnectionChange);
|
||||
onConnectionChangeRef.current = onConnectionChange;
|
||||
|
||||
const onCloseTabRef = useRef(onCloseTab);
|
||||
onCloseTabRef.current = onCloseTab;
|
||||
|
||||
const onShowCommandPaletteRef = useRef(onShowCommandPalette);
|
||||
onShowCommandPaletteRef.current = onShowCommandPalette;
|
||||
|
||||
const isActiveRef = useRef(isActive);
|
||||
isActiveRef.current = isActive;
|
||||
|
||||
// 处理命令建议选择
|
||||
const handleSuggestionSelect = useCallback((command) => {
|
||||
if (!connectionIdRef.current || !window.electronAPI) return;
|
||||
|
||||
// 清除当前输入的内容(发送对应数量的退格)
|
||||
const backspaces = '\b'.repeat(inputBufferRef.current.length);
|
||||
// 也发送删除字符来清除显示
|
||||
const deleteChars = ' '.repeat(inputBufferRef.current.length) + '\b'.repeat(inputBufferRef.current.length);
|
||||
|
||||
// 先清除当前输入
|
||||
if (inputBufferRef.current.length > 0) {
|
||||
window.electronAPI.ssh.write(connectionIdRef.current, backspaces);
|
||||
}
|
||||
|
||||
// 发送新命令
|
||||
window.electronAPI.ssh.write(connectionIdRef.current, command);
|
||||
|
||||
// 更新状态
|
||||
inputBufferRef.current = command;
|
||||
setCurrentInput(command);
|
||||
setShowSuggestions(false);
|
||||
|
||||
// 重新聚焦终端
|
||||
xtermRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// 关闭建议
|
||||
const handleCloseSuggestions = useCallback(() => {
|
||||
setShowSuggestions(false);
|
||||
xtermRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// 当标签页变为活动状态时,自动聚焦终端
|
||||
useEffect(() => {
|
||||
if (isActive && xtermRef.current) {
|
||||
// 短暂延迟确保 DOM 已更新
|
||||
setTimeout(() => {
|
||||
xtermRef.current?.focus();
|
||||
}, 50);
|
||||
}
|
||||
}, [isActive]);
|
||||
|
||||
const [connectionId, setConnectionId] = useState(null);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [currentInput, setCurrentInput] = useState('');
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const inputBufferRef = useRef('');
|
||||
const [error, setError] = useState(null);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
@ -154,8 +213,12 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette, onT
|
||||
cursorStyle: 'bar',
|
||||
fontSize: 14,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace",
|
||||
lineHeight: 1.5,
|
||||
scrollback: 2000,
|
||||
lineHeight: 1.4,
|
||||
scrollback: 1000, // 减少滚动缓冲区提升性能
|
||||
fastScrollModifier: 'alt', // 快速滚动
|
||||
fastScrollSensitivity: 5,
|
||||
smoothScrollDuration: 0, // 禁用平滑滚动提升性能
|
||||
scrollSensitivity: 1,
|
||||
theme: {
|
||||
// 赛博朋克主题配色
|
||||
background: '#050810',
|
||||
@ -195,6 +258,19 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette, onT
|
||||
xtermRef.current = term;
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
// 尝试加载 WebGL 渲染器提升性能
|
||||
try {
|
||||
const webglAddon = new WebglAddon();
|
||||
webglAddon.onContextLoss(() => {
|
||||
webglAddon.dispose();
|
||||
webglAddonRef.current = null;
|
||||
});
|
||||
term.loadAddon(webglAddon);
|
||||
webglAddonRef.current = webglAddon;
|
||||
} catch (e) {
|
||||
console.warn('WebGL 渲染器不可用,使用默认渲染器:', e);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
}, 0);
|
||||
@ -203,8 +279,68 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette, onT
|
||||
if (connectionIdRef.current && window.electronAPI) {
|
||||
window.electronAPI.ssh.write(connectionIdRef.current, data);
|
||||
}
|
||||
|
||||
// 追踪用户输入以显示命令建议
|
||||
if (data === '\r' || data === '\n') {
|
||||
// 回车:清空输入缓冲区
|
||||
inputBufferRef.current = '';
|
||||
setCurrentInput('');
|
||||
setShowSuggestions(false);
|
||||
} else if (data === '\x7f' || data === '\b') {
|
||||
// 退格:删除最后一个字符
|
||||
inputBufferRef.current = inputBufferRef.current.slice(0, -1);
|
||||
setCurrentInput(inputBufferRef.current);
|
||||
setShowSuggestions(inputBufferRef.current.length > 0);
|
||||
} else if (data === '\x1b' || data.startsWith('\x1b[')) {
|
||||
// ESC 或方向键:隐藏建议
|
||||
setShowSuggestions(false);
|
||||
} else if (data === '\t') {
|
||||
// Tab:如果有建议就不传递给终端
|
||||
// (Tab 补全由 CommandSuggestions 组件处理)
|
||||
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
// 普通字符输入
|
||||
inputBufferRef.current += data;
|
||||
setCurrentInput(inputBufferRef.current);
|
||||
setShowSuggestions(inputBufferRef.current.length > 0);
|
||||
} else if (data === '\x03') {
|
||||
// Ctrl+C:清空
|
||||
inputBufferRef.current = '';
|
||||
setCurrentInput('');
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
});
|
||||
|
||||
// 在容器上添加键盘事件监听器(捕获阶段),确保能拦截并阻止事件冒泡
|
||||
keydownHandlerRef.current = (e) => {
|
||||
// 只有活动标签页才响应快捷键
|
||||
if (!isActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+W: 关闭当前标签页
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
if (onCloseTabRef.current) {
|
||||
onCloseTabRef.current();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Ctrl+K: 打开命令面板
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
if (onShowCommandPaletteRef.current) {
|
||||
onShowCommandPaletteRef.current();
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
// 使用捕获阶段确保在 xterm 处理之前拦截
|
||||
container.addEventListener('keydown', keydownHandlerRef.current, true);
|
||||
|
||||
// 选中自动复制到剪贴板
|
||||
term.onSelectionChange(() => {
|
||||
const selection = term.getSelection();
|
||||
@ -229,8 +365,14 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette, onT
|
||||
};
|
||||
container.addEventListener('contextmenu', contextMenuHandlerRef.current);
|
||||
|
||||
// 使用防抖的 ResizeObserver 避免频繁调用
|
||||
resizeObserverRef.current = new ResizeObserver(() => {
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
resizeTimeoutRef.current = setTimeout(() => {
|
||||
fitTerminal();
|
||||
}, 100); // 100ms 防抖
|
||||
});
|
||||
resizeObserverRef.current.observe(container);
|
||||
|
||||
@ -268,6 +410,10 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette, onT
|
||||
clearTimeout(initTimerRef.current);
|
||||
}
|
||||
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
@ -279,6 +425,12 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette, onT
|
||||
contextMenuHandlerRef.current = null;
|
||||
}
|
||||
|
||||
// 清理键盘事件监听器
|
||||
if (keydownHandlerRef.current && terminalRef.current) {
|
||||
terminalRef.current.removeEventListener('keydown', keydownHandlerRef.current, true);
|
||||
keydownHandlerRef.current = null;
|
||||
}
|
||||
|
||||
if (cleanupListenersRef.current) {
|
||||
cleanupListenersRef.current();
|
||||
cleanupListenersRef.current = null;
|
||||
@ -289,10 +441,19 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette, onT
|
||||
connectionIdRef.current = null;
|
||||
}
|
||||
|
||||
// 安全清理终端及其插件
|
||||
// 注意:必须先 dispose terminal,它会自动清理所有加载的 addon
|
||||
try {
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.dispose();
|
||||
xtermRef.current = null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('终端清理时出错:', e);
|
||||
}
|
||||
|
||||
// 清理引用
|
||||
webglAddonRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
};
|
||||
}, [initTerminal, connect]);
|
||||
@ -329,15 +490,13 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette, onT
|
||||
setTimeout(() => connect(), 100);
|
||||
}, [connect]);
|
||||
|
||||
// 工具栏按钮组件
|
||||
// 工具栏按钮组件 - 简化动画提升性能
|
||||
const ToolButton = ({ onClick, disabled, active, title, children }) => (
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
p-2 rounded-lg transition-all duration-200
|
||||
p-2 rounded-lg transition-colors duration-100
|
||||
${active
|
||||
? 'bg-shell-accent/20 text-shell-accent border border-shell-accent/40'
|
||||
: 'bg-shell-card/50 border border-shell-border text-shell-text-dim hover:text-shell-text hover:border-shell-accent/30 hover:bg-shell-accent/10'
|
||||
@ -347,15 +506,13 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette, onT
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</motion.button>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="h-full flex flex-col bg-shell-bg relative overflow-hidden">
|
||||
{/* 背景装饰 */}
|
||||
<div className="absolute inset-0 cyber-grid opacity-20 pointer-events-none" />
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-shell-accent/5 rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute bottom-0 left-0 w-48 h-48 bg-shell-neon-purple/5 rounded-full blur-3xl pointer-events-none" />
|
||||
{/* 简化背景装饰以提升性能 */}
|
||||
<div className="absolute inset-0 cyber-grid opacity-10 pointer-events-none" />
|
||||
|
||||
{/* 终端工具栏 */}
|
||||
<div className="h-12 bg-shell-surface/80 backdrop-blur-xl border-b border-shell-border flex items-center px-4 justify-between flex-shrink-0 relative z-10">
|
||||
@ -383,10 +540,8 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette, onT
|
||||
</div>
|
||||
) : connectionId ? (
|
||||
<div className="flex items-center gap-2 text-shell-success text-sm">
|
||||
<motion.span
|
||||
<span
|
||||
className="w-2 h-2 rounded-full bg-shell-success"
|
||||
animate={{ scale: [1, 1.2, 1], opacity: [0.7, 1, 0.7] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
style={{ boxShadow: '0 0 8px rgba(0, 255, 136, 0.6)' }}
|
||||
/>
|
||||
<span className="font-display tracking-wide">CONNECTED</span>
|
||||
@ -403,16 +558,14 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette, onT
|
||||
{/* 右侧工具按钮 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 命令提示 */}
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
<button
|
||||
onClick={onShowCommandPalette}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg btn-cyber text-sm text-shell-accent"
|
||||
title="命令面板 (Ctrl+K)"
|
||||
>
|
||||
<FiCommand size={14} />
|
||||
<span className="hidden sm:inline font-display tracking-wide">COMMANDS</span>
|
||||
</motion.button>
|
||||
</button>
|
||||
|
||||
<div className="divider-vertical h-6 mx-1" />
|
||||
|
||||
@ -454,6 +607,15 @@ function Terminal({ tabId, hostId, onConnectionChange, onShowCommandPalette, onT
|
||||
style={{ minHeight: '300px', minWidth: '400px' }}
|
||||
/>
|
||||
|
||||
{/* 命令提示 */}
|
||||
<CommandSuggestions
|
||||
input={currentInput}
|
||||
visible={showSuggestions && isActive && connectionId}
|
||||
position={{ bottom: 60, left: 16 }}
|
||||
onSelect={handleSuggestionSelect}
|
||||
onClose={handleCloseSuggestions}
|
||||
/>
|
||||
|
||||
{/* 底部装饰线 */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-shell-accent/20 to-transparent pointer-events-none" />
|
||||
</div>
|
||||
|
||||
@ -61,7 +61,7 @@ function TitleBar() {
|
||||
</span>
|
||||
{/* 版本徽章 */}
|
||||
<span className="badge-cyber text-shell-accent">
|
||||
v1.0
|
||||
V1.0.2
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user