feat: command snippets + UI test coverage for new features
- core/snippets.py: SnippetManager with 20 prebuilt sysadmin commands, CRUD + reorder, persisted to ~/.sshclient/snippets.json - ui/snippet_dialog.py: SnippetDialog (add/edit) + SnippetManagerDialog (table view with add/edit/delete/move up/down) - ui/terminal_panel.py: snippet combo box in toolbar, send_command() method to inject commands into interactive shell, snippet manager button - README.md: updated feature table, usage section, project structure - test_ui.py: added [2/4] checks for sparkline/snippets/theme, added [4/4] theme toggle verification
This commit is contained in:
@@ -9,10 +9,14 @@
|
|||||||
| 模块 | 说明 |
|
| 模块 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 多主机管理 | 增删改查、密码/私钥双认证、配置导入导出、配置持久化到 `~/.sshclient/` |
|
| 多主机管理 | 增删改查、密码/私钥双认证、配置导入导出、配置持久化到 `~/.sshclient/` |
|
||||||
| 远程终端 | 命令执行 + 输出捕获 + 退出码 + 超时控制 + 常用命令快捷栏 |
|
| 远程终端 | 真实交互式 shell(Xshell 风格)、ANSI 颜色、本地历史、Ctrl+A/E/U/K/W 快捷键 |
|
||||||
|
| 命令片段 | 20+ 预置常用命令(ss/df/ps/docker/systemctl…),一键发送到终端,自定义增删改 |
|
||||||
| SFTP 文件浏览 | 目录树浏览、上传/下载(带进度条)、新建目录、删除、重命名、双击进入 |
|
| SFTP 文件浏览 | 目录树浏览、上传/下载(带进度条)、新建目录、删除、重命名、双击进入 |
|
||||||
| 实时监控 | CPU/内存/负载/启动时间/磁盘/网络速率,1-10 秒可调刷新间隔 |
|
| 实时监控 | CPU/内存/负载/启动时间/磁盘/网络速率/进程列表,1-10 秒可调刷新间隔 |
|
||||||
| AI Agent | OpenAI 兼容 API(OpenAI / DeepSeek / Moonshot / 通义千问 / Ollama 等),5 个工具自动调用:执行命令、查指标、列文件、读文件、上传 |
|
| 趋势迷你图 | CPU 和内存使用率实时折线图,最近 60 个采样点,一目了然 |
|
||||||
|
| 进程管理 | 进程表(500 条)、搜索过滤、CPU/内存排序、树形视图、右键杀进程、批量操作 |
|
||||||
|
| AI Agent | OpenAI 兼容 API(OpenAI / DeepSeek / Moonshot / 通义千问 / Ollama 等),5 个工具自动调用 |
|
||||||
|
| 暗色主题 | 视图菜单一键切换暗/亮主题,偏好自动持久化 |
|
||||||
| 跨平台 | 代码兼容 Windows / macOS / Linux(PyQt5 + paramiko) |
|
| 跨平台 | 代码兼容 Windows / macOS / Linux(PyQt5 + paramiko) |
|
||||||
|
|
||||||
## 📦 在 Windows 上构建 exe
|
## 📦 在 Windows 上构建 exe
|
||||||
@@ -58,13 +62,15 @@ python test_e2e.py # 端到端测试(需本机或可访问的 sshd)
|
|||||||
选中主机 → **🔌 连接**。状态指示器变绿后即可使用。
|
选中主机 → **🔌 连接**。状态指示器变绿后即可使用。
|
||||||
|
|
||||||
### 3. 终端
|
### 3. 终端
|
||||||
**⌨ 终端** Tab 直接输入命令回车执行。常用命令(pwd / df / free / top / netstat)有快捷按钮。
|
**⌨ 终端** Tab 直接输入命令回车执行。支持 ANSI 颜色、↑↓ 历史回放、Ctrl+A/E/U/K/W 行编辑快捷键。
|
||||||
|
|
||||||
|
**命令片段**:工具栏下拉框选择预置命令(ss/df/ps/docker/systemctl 等),一键发送到终端。点击 ⚙ 管理自定义片段。
|
||||||
|
|
||||||
### 4. 文件浏览
|
### 4. 文件浏览
|
||||||
**📁 文件** Tab 双击目录进入,双击文件直接下载。可拖入 / 上传任意文件。
|
**📁 文件** Tab 双击目录进入,双击文件直接下载。可拖入 / 上传任意文件。
|
||||||
|
|
||||||
### 5. 监控
|
### 5. 监控
|
||||||
**📊 监控** Tab 点击 **开始监控**,指标会按设定间隔刷新。CPU/内存用大字突出,磁盘用进度条,网络显示当前速率。
|
**📊 监控** Tab 点击 **开始监控**,指标会按设定间隔刷新。CPU/内存用大字突出,下方有实时趋势折线图,磁盘用进度条,网络显示当前速率。下半区是进程管理表,支持搜索过滤、CPU/内存排序、树形视图、右键杀进程。
|
||||||
|
|
||||||
### 6. AI Agent
|
### 6. AI Agent
|
||||||
1. 先点 **⚙ AI 设置**,选择预设(OpenAI/DeepSeek/Kimi/通义千问/Ollama)或自定义填 API Key
|
1. 先点 **⚙ AI 设置**,选择预设(OpenAI/DeepSeek/Kimi/通义千问/Ollama)或自定义填 API Key
|
||||||
@@ -101,12 +107,16 @@ sshclient/
|
|||||||
│ ├── ssh_client.py # SSH 连接 + SFTP
|
│ ├── ssh_client.py # SSH 连接 + SFTP
|
||||||
│ ├── monitor.py # 远程系统监控
|
│ ├── monitor.py # 远程系统监控
|
||||||
│ ├── ai_agent.py # AI Agent (OpenAI 兼容)
|
│ ├── ai_agent.py # AI Agent (OpenAI 兼容)
|
||||||
│ └── manager.py # 多主机管理 + 配置持久化
|
│ ├── manager.py # 多主机管理 + 配置持久化
|
||||||
|
│ └── snippets.py # 命令片段管理 + 持久化
|
||||||
├── ui/ # PyQt5 界面
|
├── ui/ # PyQt5 界面
|
||||||
│ ├── main_window.py # 主窗口
|
│ ├── main_window.py # 主窗口
|
||||||
│ ├── widgets.py # FileBrowser / MonitorPanel / AIChatPanel
|
│ ├── widgets.py # FileBrowser / MonitorPanel / SparklineChart / AIChatPanel
|
||||||
|
│ ├── terminal_panel.py # 交互式终端面板
|
||||||
│ ├── host_dialog.py # 主机编辑对话框
|
│ ├── host_dialog.py # 主机编辑对话框
|
||||||
│ ├── config_dialog.py # AI 设置对话框
|
│ ├── config_dialog.py # AI 设置对话框
|
||||||
|
│ ├── snippet_dialog.py # 命令片段管理对话框
|
||||||
|
│ ├── theme.py # 亮色/暗色主题 QSS
|
||||||
│ └── workers.py # 后台线程
|
│ └── workers.py # 后台线程
|
||||||
└── test_*.py # 测试脚本
|
└── test_*.py # 测试脚本
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""
|
||||||
|
命令片段管理:保存常用命令,快速发送到终端。
|
||||||
|
持久化到 ~/.sshclient/snippets.json
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
CONFIG_DIR = Path.home() / ".sshclient"
|
||||||
|
SNIPPETS_FILE = CONFIG_DIR / "snippets.json"
|
||||||
|
|
||||||
|
# 预置片段(首次运行时自动添加)
|
||||||
|
DEFAULT_SNIPPETS = [
|
||||||
|
{"name": "系统信息", "cmd": "uname -a && cat /etc/os-release"},
|
||||||
|
{"name": "磁盘使用", "cmd": "df -h"},
|
||||||
|
{"name": "内存使用", "cmd": "free -h"},
|
||||||
|
{"name": "CPU 信息", "cmd": "lscpu | head -20"},
|
||||||
|
{"name": "监听端口", "cmd": "ss -tlnp"},
|
||||||
|
{"name": "所有连接", "cmd": "ss -tnp"},
|
||||||
|
{"name": "进程 TOP10", "cmd": "ps aux --sort=-%cpu | head -11"},
|
||||||
|
{"name": "内存 TOP10", "cmd": "ps aux --sort=-%mem | head -11"},
|
||||||
|
{"name": "最近登录", "cmd": "last -10"},
|
||||||
|
{"name": "系统日志", "cmd": "journalctl -n 50 --no-pager"},
|
||||||
|
{"name": "DNS 解析", "cmd": "dig +short"},
|
||||||
|
{"name": "网络路由", "cmd": "ip route show"},
|
||||||
|
{"name": "网卡信息", "cmd": "ip addr show"},
|
||||||
|
{"name": "防火墙状态", "cmd": "iptables -L -n --line-numbers"},
|
||||||
|
{"name": "Docker 容器", "cmd": "docker ps -a"},
|
||||||
|
{"name": "Docker 日志", "cmd": "docker logs --tail 50"},
|
||||||
|
{"name": "systemctl 状态", "cmd": "systemctl status"},
|
||||||
|
{"name": "重启服务", "cmd": "systemctl restart"},
|
||||||
|
{"name": "定时任务", "cmd": "crontab -l"},
|
||||||
|
{"name": "大文件 TOP10", "cmd": "find / -type f -size +100M 2>/dev/null | head -10"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SnippetManager:
|
||||||
|
"""命令片段的增删改查 + 持久化"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._snippets: List[dict] = []
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def _load(self):
|
||||||
|
if SNIPPETS_FILE.exists():
|
||||||
|
try:
|
||||||
|
with open(SNIPPETS_FILE, "r", encoding="utf-8") as f:
|
||||||
|
self._snippets = json.load(f)
|
||||||
|
except Exception:
|
||||||
|
self._snippets = []
|
||||||
|
if not self._snippets:
|
||||||
|
self._snippets = [dict(s) for s in DEFAULT_SNIPPETS]
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def _save(self):
|
||||||
|
with open(SNIPPETS_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(self._snippets, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
def list_all(self) -> List[dict]:
|
||||||
|
return list(self._snippets)
|
||||||
|
|
||||||
|
def add(self, name: str, cmd: str) -> int:
|
||||||
|
"""添加片段,返回新索引"""
|
||||||
|
snippet = {"name": name.strip(), "cmd": cmd}
|
||||||
|
self._snippets.append(snippet)
|
||||||
|
self._save()
|
||||||
|
return len(self._snippets) - 1
|
||||||
|
|
||||||
|
def update(self, index: int, name: str, cmd: str):
|
||||||
|
if 0 <= index < len(self._snippets):
|
||||||
|
self._snippets[index] = {"name": name.strip(), "cmd": cmd}
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def remove(self, index: int):
|
||||||
|
if 0 <= index < len(self._snippets):
|
||||||
|
del self._snippets[index]
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def move(self, index: int, direction: int):
|
||||||
|
"""direction: -1=上移, +1=下移"""
|
||||||
|
new_idx = index + direction
|
||||||
|
if 0 <= new_idx < len(self._snippets):
|
||||||
|
self._snippets[index], self._snippets[new_idx] = \
|
||||||
|
self._snippets[new_idx], self._snippets[index]
|
||||||
|
self._save()
|
||||||
+25
-4
@@ -33,13 +33,21 @@ def main():
|
|||||||
assert w.tabs.count() == 4, f"应有 4 个 Tab,实际 {w.tabs.count()}"
|
assert w.tabs.count() == 4, f"应有 4 个 Tab,实际 {w.tabs.count()}"
|
||||||
print(f" ✓ 主窗口创建,Tab 数量 = {w.tabs.count()}")
|
print(f" ✓ 主窗口创建,Tab 数量 = {w.tabs.count()}")
|
||||||
|
|
||||||
print("[2/3] 验证子组件")
|
print("[2/4] 验证子组件")
|
||||||
assert isinstance(w.file_browser, FileBrowser)
|
assert isinstance(w.file_browser, FileBrowser)
|
||||||
assert isinstance(w.monitor, MonitorPanel)
|
assert isinstance(w.monitor, MonitorPanel)
|
||||||
assert isinstance(w.ai_panel, AIChatPanel)
|
assert isinstance(w.ai_panel, AIChatPanel)
|
||||||
|
# 验证新增功能
|
||||||
|
assert hasattr(w.monitor, "_spark_cpu"), "MonitorPanel 应有 CPU 迷你图"
|
||||||
|
assert hasattr(w.monitor, "_spark_mem"), "MonitorPanel 应有内存迷你图"
|
||||||
|
assert hasattr(w.terminal_panel, "snippet_combo"), "TerminalPanel 应有片段下拉框"
|
||||||
|
assert hasattr(w, "act_dark"), "MainWindow 应有暗色主题菜单项"
|
||||||
|
menus = [a.text() for a in w.menuBar().actions()]
|
||||||
|
assert "视图(&V)" in menus, f"应有视图菜单: {menus}"
|
||||||
print(f" ✓ FileBrowser / MonitorPanel / AIChatPanel 全部就位")
|
print(f" ✓ FileBrowser / MonitorPanel / AIChatPanel 全部就位")
|
||||||
|
print(f" ✓ 暗色主题切换 / CPU·内存迷你图 / 命令片段下拉框")
|
||||||
|
|
||||||
print("[3/3] 验证对话框")
|
print("[3/4] 验证对话框")
|
||||||
# 主机对话框
|
# 主机对话框
|
||||||
hd = HostDialog(current={"name": "test", "host": "1.2.3.4", "port": 22,
|
hd = HostDialog(current={"name": "test", "host": "1.2.3.4", "port": 22,
|
||||||
"username": "u", "password": "p", "key_path": ""})
|
"username": "u", "password": "p", "key_path": ""})
|
||||||
@@ -56,10 +64,23 @@ def main():
|
|||||||
cd.close()
|
cd.close()
|
||||||
print(f" ✓ AIConfigDialog + {len(PRESETS)} 个预设")
|
print(f" ✓ AIConfigDialog + {len(PRESETS)} 个预设")
|
||||||
|
|
||||||
# 不真正 show,只确保不崩
|
print("[4/4] 验证主题切换")
|
||||||
|
from ui.theme import get_theme, set_theme, get_qss
|
||||||
|
# 切换到暗色
|
||||||
|
w.act_dark.setChecked(True)
|
||||||
|
w._toggle_theme()
|
||||||
|
assert get_theme() == "dark"
|
||||||
|
qss = app.styleSheet()
|
||||||
|
assert len(qss) > 100, "QSS 应该有内容"
|
||||||
|
# 切回亮色
|
||||||
|
w.act_dark.setChecked(False)
|
||||||
|
w._toggle_theme()
|
||||||
|
assert get_theme() == "light"
|
||||||
|
print(f" ✓ 暗/亮主题切换正常 (dark QSS={len(get_qss('dark'))} chars)")
|
||||||
|
|
||||||
|
# 清理
|
||||||
w.close()
|
w.close()
|
||||||
print("\n所有 UI 组件加载正常 ✓")
|
print("\n所有 UI 组件加载正常 ✓")
|
||||||
# 不进入事件循环,强制退出
|
|
||||||
QTimer.singleShot(0, app.quit)
|
QTimer.singleShot(0, app.quit)
|
||||||
app.exec_()
|
app.exec_()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""
|
||||||
|
命令片段管理对话框
|
||||||
|
"""
|
||||||
|
from PyQt5.QtCore import Qt
|
||||||
|
from PyQt5.QtWidgets import (
|
||||||
|
QDialog, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem,
|
||||||
|
QPushButton, QLabel, QLineEdit, QTextEdit, QMessageBox, QHeaderView,
|
||||||
|
QAbstractItemView, QSplitter, QWidget, QFormLayout,
|
||||||
|
)
|
||||||
|
|
||||||
|
from core.snippets import SnippetManager
|
||||||
|
|
||||||
|
|
||||||
|
class SnippetDialog(QDialog):
|
||||||
|
"""单个片段的编辑/新增对话框"""
|
||||||
|
|
||||||
|
def __init__(self, parent=None, name: str = "", cmd: str = ""):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("编辑片段" if name else "新增片段")
|
||||||
|
self.setMinimumWidth(460)
|
||||||
|
v = QVBoxLayout(self)
|
||||||
|
form = QFormLayout()
|
||||||
|
self.name_edit = QLineEdit(name)
|
||||||
|
self.name_edit.setPlaceholderText("如:查看端口占用")
|
||||||
|
form.addRow("名称:", self.name_edit)
|
||||||
|
self.cmd_edit = QTextEdit(cmd)
|
||||||
|
self.cmd_edit.setPlaceholderText("如:ss -tlnp | grep :80")
|
||||||
|
self.cmd_edit.setMaximumHeight(80)
|
||||||
|
form.addRow("命令:", self.cmd_edit)
|
||||||
|
v.addLayout(form)
|
||||||
|
btns = QHBoxLayout()
|
||||||
|
btns.addStretch(1)
|
||||||
|
ok = QPushButton("保存")
|
||||||
|
ok.clicked.connect(self._on_ok)
|
||||||
|
cancel = QPushButton("取消")
|
||||||
|
cancel.clicked.connect(self.reject)
|
||||||
|
btns.addWidget(ok)
|
||||||
|
btns.addWidget(cancel)
|
||||||
|
v.addLayout(btns)
|
||||||
|
|
||||||
|
def _on_ok(self):
|
||||||
|
if not self.name_edit.text().strip():
|
||||||
|
QMessageBox.warning(self, "提示", "请输入名称")
|
||||||
|
return
|
||||||
|
if not self.cmd_edit.toPlainText().strip():
|
||||||
|
QMessageBox.warning(self, "提示", "请输入命令")
|
||||||
|
return
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
def get_value(self) -> dict:
|
||||||
|
return {
|
||||||
|
"name": self.name_edit.text().strip(),
|
||||||
|
"cmd": self.cmd_edit.toPlainText().strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SnippetManagerDialog(QDialog):
|
||||||
|
"""片段管理主对话框:表格 + 增删改 + 上下移"""
|
||||||
|
|
||||||
|
def __init__(self, mgr: SnippetManager, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.mgr = mgr
|
||||||
|
self.setWindowTitle("命令片段管理")
|
||||||
|
self.setMinimumSize(520, 420)
|
||||||
|
self._build_ui()
|
||||||
|
self._refresh_table()
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
v = QVBoxLayout(self)
|
||||||
|
# 工具栏
|
||||||
|
toolbar = QHBoxLayout()
|
||||||
|
toolbar.addWidget(QLabel("📋 命令片段列表"))
|
||||||
|
toolbar.addStretch(1)
|
||||||
|
self.btn_add = QPushButton("➕ 新增")
|
||||||
|
self.btn_add.clicked.connect(self._add)
|
||||||
|
self.btn_edit = QPushButton("✏ 编辑")
|
||||||
|
self.btn_edit.clicked.connect(self._edit)
|
||||||
|
self.btn_del = QPushButton("🗑 删除")
|
||||||
|
self.btn_del.clicked.connect(self._delete)
|
||||||
|
self.btn_up = QPushButton("⬆ 上移")
|
||||||
|
self.btn_up.clicked.connect(lambda: self._move(-1))
|
||||||
|
self.btn_down = QPushButton("⬇ 下移")
|
||||||
|
self.btn_down.clicked.connect(lambda: self._move(1))
|
||||||
|
for b in (self.btn_add, self.btn_edit, self.btn_del, self.btn_up, self.btn_down):
|
||||||
|
toolbar.addWidget(b)
|
||||||
|
v.addLayout(toolbar)
|
||||||
|
# 表格
|
||||||
|
self.table = QTableWidget(0, 2)
|
||||||
|
self.table.setHorizontalHeaderLabels(["名称", "命令"])
|
||||||
|
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||||
|
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
|
||||||
|
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||||
|
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||||
|
self.table.doubleClicked.connect(self._edit)
|
||||||
|
v.addWidget(self.table)
|
||||||
|
# 关闭按钮
|
||||||
|
btns = QHBoxLayout()
|
||||||
|
btns.addStretch(1)
|
||||||
|
close = QPushButton("关闭")
|
||||||
|
close.clicked.connect(self.accept)
|
||||||
|
btns.addWidget(close)
|
||||||
|
v.addLayout(btns)
|
||||||
|
|
||||||
|
def _refresh_table(self):
|
||||||
|
snippets = self.mgr.list_all()
|
||||||
|
self.table.setRowCount(len(snippets))
|
||||||
|
for i, s in enumerate(snippets):
|
||||||
|
self.table.setItem(i, 0, QTableWidgetItem(s["name"]))
|
||||||
|
cmd_display = s["cmd"][:60] + ("..." if len(s["cmd"]) > 60 else "")
|
||||||
|
self.table.setItem(i, 1, QTableWidgetItem(cmd_display))
|
||||||
|
self.table.item(i, 0).setToolTip(s["cmd"])
|
||||||
|
|
||||||
|
def _add(self):
|
||||||
|
dlg = SnippetDialog(self)
|
||||||
|
if dlg.exec_() == dlg.Accepted:
|
||||||
|
v = dlg.get_value()
|
||||||
|
self.mgr.add(v["name"], v["cmd"])
|
||||||
|
self._refresh_table()
|
||||||
|
|
||||||
|
def _edit(self):
|
||||||
|
row = self.table.currentRow()
|
||||||
|
if row < 0:
|
||||||
|
return
|
||||||
|
s = self.mgr.list_all()[row]
|
||||||
|
dlg = SnippetDialog(self, s["name"], s["cmd"])
|
||||||
|
if dlg.exec_() == dlg.Accepted:
|
||||||
|
v = dlg.get_value()
|
||||||
|
self.mgr.update(row, v["name"], v["cmd"])
|
||||||
|
self._refresh_table()
|
||||||
|
|
||||||
|
def _delete(self):
|
||||||
|
row = self.table.currentRow()
|
||||||
|
if row < 0:
|
||||||
|
return
|
||||||
|
s = self.mgr.list_all()[row]
|
||||||
|
if QMessageBox.question(
|
||||||
|
self, "确认删除", f"删除片段「{s['name']}」?",
|
||||||
|
QMessageBox.Yes | QMessageBox.No
|
||||||
|
) == QMessageBox.Yes:
|
||||||
|
self.mgr.remove(row)
|
||||||
|
self._refresh_table()
|
||||||
|
|
||||||
|
def _move(self, direction):
|
||||||
|
row = self.table.currentRow()
|
||||||
|
if row < 0:
|
||||||
|
return
|
||||||
|
self.mgr.move(row, direction)
|
||||||
|
new_row = row + direction
|
||||||
|
self._refresh_table()
|
||||||
|
if 0 <= new_row < self.table.rowCount():
|
||||||
|
self.table.selectRow(new_row)
|
||||||
@@ -21,6 +21,7 @@ from PyQt5.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from core.ssh_client import SSHConnection
|
from core.ssh_client import SSHConnection
|
||||||
|
from core.snippets import SnippetManager
|
||||||
|
|
||||||
|
|
||||||
# ANSI 颜色映射(16 色)
|
# ANSI 颜色映射(16 色)
|
||||||
@@ -133,6 +134,8 @@ class TerminalPanel(QWidget):
|
|||||||
self._ansi_invert = False
|
self._ansi_invert = False
|
||||||
# OSC 0/2 设置的窗口标题
|
# OSC 0/2 设置的窗口标题
|
||||||
self._window_title = ""
|
self._window_title = ""
|
||||||
|
# 命令片段
|
||||||
|
self.snippet_mgr = SnippetManager()
|
||||||
|
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._apply_style()
|
self._apply_style()
|
||||||
@@ -152,6 +155,20 @@ class TerminalPanel(QWidget):
|
|||||||
self.status_label.setStyleSheet("color: #888;")
|
self.status_label.setStyleSheet("color: #888;")
|
||||||
toolbar.addWidget(self.status_label)
|
toolbar.addWidget(self.status_label)
|
||||||
toolbar.addStretch(1)
|
toolbar.addStretch(1)
|
||||||
|
# 命令片段
|
||||||
|
toolbar.addWidget(QLabel("📋"))
|
||||||
|
self.snippet_combo = QComboBox()
|
||||||
|
self.snippet_combo.setMinimumWidth(180)
|
||||||
|
self.snippet_combo.setToolTip("选择命令片段快速发送到终端")
|
||||||
|
self.snippet_combo.currentIndexChanged.connect(self._on_snippet_selected)
|
||||||
|
toolbar.addWidget(self.snippet_combo)
|
||||||
|
self._refresh_snippets()
|
||||||
|
self.btn_snippet_mgr = QPushButton("⚙")
|
||||||
|
self.btn_snippet_mgr.setFixedWidth(28)
|
||||||
|
self.btn_snippet_mgr.setToolTip("管理命令片段")
|
||||||
|
self.btn_snippet_mgr.clicked.connect(self._open_snippet_manager)
|
||||||
|
toolbar.addWidget(self.btn_snippet_mgr)
|
||||||
|
toolbar.addSpacing(8)
|
||||||
self.btn_clear = QPushButton("清屏")
|
self.btn_clear = QPushButton("清屏")
|
||||||
self.btn_clear.clicked.connect(self._clear_screen)
|
self.btn_clear.clicked.connect(self._clear_screen)
|
||||||
self.btn_reset = QPushButton("重连 shell")
|
self.btn_reset = QPushButton("重连 shell")
|
||||||
@@ -190,6 +207,51 @@ class TerminalPanel(QWidget):
|
|||||||
}
|
}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 命令片段
|
||||||
|
# ============================================================
|
||||||
|
def _refresh_snippets(self):
|
||||||
|
"""刷新下拉框"""
|
||||||
|
self.snippet_combo.blockSignals(True)
|
||||||
|
self.snippet_combo.clear()
|
||||||
|
self.snippet_combo.addItem("-- 选择命令片段 --", "")
|
||||||
|
for s in self.snippet_mgr.list_all():
|
||||||
|
self.snippet_combo.addItem(f"{s['name']} ({s['cmd'][:30]})", s["cmd"])
|
||||||
|
self.snippet_combo.blockSignals(False)
|
||||||
|
|
||||||
|
def _on_snippet_selected(self, index: int):
|
||||||
|
"""选择片段后发送命令到终端"""
|
||||||
|
if index <= 0:
|
||||||
|
return
|
||||||
|
cmd = self.snippet_combo.itemData(index)
|
||||||
|
if not cmd:
|
||||||
|
return
|
||||||
|
# 重置下拉框选中项(让用户能重复选同一个)
|
||||||
|
self.snippet_combo.blockSignals(True)
|
||||||
|
self.snippet_combo.setCurrentIndex(0)
|
||||||
|
self.snippet_combo.blockSignals(False)
|
||||||
|
# 发送命令
|
||||||
|
self.send_command(cmd)
|
||||||
|
|
||||||
|
def send_command(self, cmd: str):
|
||||||
|
"""发送一条命令到远程 shell(自动加换行)"""
|
||||||
|
if not self.chan or not self._connected:
|
||||||
|
self._set_status("未连接,无法发送命令", "#c62828")
|
||||||
|
return
|
||||||
|
data = cmd.encode("utf-8", errors="replace")
|
||||||
|
if not cmd.endswith("\n"):
|
||||||
|
data += b"\n"
|
||||||
|
try:
|
||||||
|
self.chan.send(data)
|
||||||
|
except Exception as e:
|
||||||
|
self._set_status(f"发送失败: {e}", "#c62828")
|
||||||
|
|
||||||
|
def _open_snippet_manager(self):
|
||||||
|
from .snippet_dialog import SnippetManagerDialog
|
||||||
|
dlg = SnippetManagerDialog(self.snippet_mgr, self)
|
||||||
|
dlg.exec_()
|
||||||
|
self._refresh_snippets()
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 生命周期
|
# 生命周期
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user