765 lines
30 KiB
Python
765 lines
30 KiB
Python
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from collections.abc import Callable
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
from PySide6.QtCore import QModelIndex, QObject, QRunnable, QSize, Qt, QThreadPool, QTimer, Signal, Slot
|
|||
|
|
from PySide6.QtGui import QCloseEvent, QColor, QFont, QGuiApplication, QIcon, QPainter, QPixmap
|
|||
|
|
from PySide6.QtWidgets import (
|
|||
|
|
QAbstractItemView,
|
|||
|
|
QApplication,
|
|||
|
|
QCheckBox,
|
|||
|
|
QFrame,
|
|||
|
|
QHBoxLayout,
|
|||
|
|
QHeaderView,
|
|||
|
|
QLabel,
|
|||
|
|
QLineEdit,
|
|||
|
|
QMainWindow,
|
|||
|
|
QMessageBox,
|
|||
|
|
QProgressBar,
|
|||
|
|
QPushButton,
|
|||
|
|
QScrollArea,
|
|||
|
|
QSizePolicy,
|
|||
|
|
QSplitter,
|
|||
|
|
QStackedWidget,
|
|||
|
|
QStyle,
|
|||
|
|
QTableView,
|
|||
|
|
QTextEdit,
|
|||
|
|
QVBoxLayout,
|
|||
|
|
QWidget,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
from processdock.models import (
|
|||
|
|
PROCESS_ROLE,
|
|||
|
|
ActionButtonDelegate,
|
|||
|
|
ProcessFilterProxyModel,
|
|||
|
|
ProcessTableModel,
|
|||
|
|
)
|
|||
|
|
from processdock.services import ActionResult, ProcessDockError, ProcessService
|
|||
|
|
from processdock.styles import APP_STYLE
|
|||
|
|
|
|||
|
|
|
|||
|
|
class WorkerSignals(QObject):
|
|||
|
|
result = Signal(object)
|
|||
|
|
error = Signal(str)
|
|||
|
|
finished = Signal()
|
|||
|
|
|
|||
|
|
|
|||
|
|
class FunctionWorker(QRunnable):
|
|||
|
|
def __init__(self, function: Callable[[], Any]) -> None:
|
|||
|
|
super().__init__()
|
|||
|
|
self.function = function
|
|||
|
|
self.signals = WorkerSignals()
|
|||
|
|
|
|||
|
|
@Slot()
|
|||
|
|
def run(self) -> None:
|
|||
|
|
try:
|
|||
|
|
result = self.function()
|
|||
|
|
except Exception as error: # delivered to the GUI thread
|
|||
|
|
self.signals.error.emit(str(error) or error.__class__.__name__)
|
|||
|
|
else:
|
|||
|
|
self.signals.result.emit(result)
|
|||
|
|
finally:
|
|||
|
|
self.signals.finished.emit()
|
|||
|
|
|
|||
|
|
|
|||
|
|
class SummaryCard(QFrame):
|
|||
|
|
def __init__(self, icon: str, title: str, value: str, subtitle: str, parent=None) -> None:
|
|||
|
|
super().__init__(parent)
|
|||
|
|
self.setObjectName("SummaryCard")
|
|||
|
|
self.setMinimumHeight(92)
|
|||
|
|
layout = QHBoxLayout(self)
|
|||
|
|
layout.setContentsMargins(16, 13, 16, 13)
|
|||
|
|
layout.setSpacing(12)
|
|||
|
|
|
|||
|
|
icon_label = QLabel(icon)
|
|||
|
|
icon_label.setObjectName("CardIcon")
|
|||
|
|
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|||
|
|
layout.addWidget(icon_label)
|
|||
|
|
|
|||
|
|
text_layout = QVBoxLayout()
|
|||
|
|
text_layout.setSpacing(1)
|
|||
|
|
title_label = QLabel(title)
|
|||
|
|
title_label.setObjectName("CardTitle")
|
|||
|
|
self.value_label = QLabel(value)
|
|||
|
|
self.value_label.setObjectName("CardValue")
|
|||
|
|
self.subtitle_label = QLabel(subtitle)
|
|||
|
|
self.subtitle_label.setObjectName("CardSubtitle")
|
|||
|
|
text_layout.addWidget(title_label)
|
|||
|
|
text_layout.addWidget(self.value_label)
|
|||
|
|
text_layout.addWidget(self.subtitle_label)
|
|||
|
|
layout.addLayout(text_layout, 1)
|
|||
|
|
|
|||
|
|
def update_value(self, value: str, subtitle: str | None = None) -> None:
|
|||
|
|
self.value_label.setText(value)
|
|||
|
|
if subtitle is not None:
|
|||
|
|
self.subtitle_label.setText(subtitle)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class DetailRow(QWidget):
|
|||
|
|
def __init__(self, key: str, parent=None) -> None:
|
|||
|
|
super().__init__(parent)
|
|||
|
|
layout = QHBoxLayout(self)
|
|||
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|||
|
|
layout.setSpacing(12)
|
|||
|
|
key_label = QLabel(key)
|
|||
|
|
key_label.setObjectName("DetailKey")
|
|||
|
|
key_label.setFixedWidth(76)
|
|||
|
|
key_label.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
|
|||
|
|
self.value_label = QLabel("—")
|
|||
|
|
self.value_label.setObjectName("DetailValue")
|
|||
|
|
self.value_label.setTextFormat(Qt.TextFormat.PlainText)
|
|||
|
|
self.value_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
|||
|
|
self.value_label.setWordWrap(True)
|
|||
|
|
layout.addWidget(key_label)
|
|||
|
|
layout.addWidget(self.value_label, 1)
|
|||
|
|
|
|||
|
|
def set_value(self, value: str) -> None:
|
|||
|
|
self.value_label.setText(value or "—")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class DetailPanel(QFrame):
|
|||
|
|
open_requested = Signal(object)
|
|||
|
|
copy_requested = Signal(str)
|
|||
|
|
terminate_requested = Signal(object, bool)
|
|||
|
|
|
|||
|
|
def __init__(self, parent=None) -> None:
|
|||
|
|
super().__init__(parent)
|
|||
|
|
self.setObjectName("DetailCard")
|
|||
|
|
self.setMinimumWidth(350)
|
|||
|
|
self.setMaximumWidth(430)
|
|||
|
|
self._process: dict[str, Any] | None = None
|
|||
|
|
|
|||
|
|
outer = QVBoxLayout(self)
|
|||
|
|
outer.setContentsMargins(0, 0, 0, 0)
|
|||
|
|
outer.setSpacing(0)
|
|||
|
|
|
|||
|
|
heading = QWidget()
|
|||
|
|
heading_layout = QHBoxLayout(heading)
|
|||
|
|
heading_layout.setContentsMargins(18, 16, 18, 12)
|
|||
|
|
title = QLabel("进程详情")
|
|||
|
|
title.setObjectName("SectionTitle")
|
|||
|
|
hint = QLabel("实时信息")
|
|||
|
|
hint.setObjectName("MutedLabel")
|
|||
|
|
heading_layout.addWidget(title)
|
|||
|
|
heading_layout.addStretch()
|
|||
|
|
heading_layout.addWidget(hint)
|
|||
|
|
outer.addWidget(heading)
|
|||
|
|
|
|||
|
|
self.stack = QStackedWidget()
|
|||
|
|
outer.addWidget(self.stack, 1)
|
|||
|
|
self._build_empty_page()
|
|||
|
|
self._build_detail_page()
|
|||
|
|
self.stack.setCurrentWidget(self.empty_page)
|
|||
|
|
|
|||
|
|
def _build_empty_page(self) -> None:
|
|||
|
|
self.empty_page = QWidget()
|
|||
|
|
layout = QVBoxLayout(self.empty_page)
|
|||
|
|
layout.setContentsMargins(34, 34, 34, 70)
|
|||
|
|
layout.addStretch()
|
|||
|
|
icon = QLabel("⌁")
|
|||
|
|
icon.setObjectName("EmptyIcon")
|
|||
|
|
icon.setFixedSize(54, 54)
|
|||
|
|
icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|||
|
|
self.empty_title = QLabel("选择一个进程")
|
|||
|
|
self.empty_title.setObjectName("EmptyTitle")
|
|||
|
|
self.empty_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|||
|
|
self.empty_hint = QLabel("点击左侧任意一行,查看路径、命令行和资源占用。")
|
|||
|
|
self.empty_hint.setObjectName("MutedLabel")
|
|||
|
|
self.empty_hint.setWordWrap(True)
|
|||
|
|
self.empty_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|||
|
|
layout.addWidget(icon, alignment=Qt.AlignmentFlag.AlignCenter)
|
|||
|
|
layout.addSpacing(12)
|
|||
|
|
layout.addWidget(self.empty_title)
|
|||
|
|
layout.addSpacing(4)
|
|||
|
|
layout.addWidget(self.empty_hint)
|
|||
|
|
layout.addStretch()
|
|||
|
|
self.stack.addWidget(self.empty_page)
|
|||
|
|
|
|||
|
|
def _build_detail_page(self) -> None:
|
|||
|
|
self.detail_page = QWidget()
|
|||
|
|
page_layout = QVBoxLayout(self.detail_page)
|
|||
|
|
page_layout.setContentsMargins(0, 0, 0, 0)
|
|||
|
|
page_layout.setSpacing(0)
|
|||
|
|
scroll = QScrollArea()
|
|||
|
|
scroll.setWidgetResizable(True)
|
|||
|
|
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
|||
|
|
detail_content = QWidget()
|
|||
|
|
layout = QVBoxLayout(detail_content)
|
|||
|
|
layout.setContentsMargins(18, 6, 18, 18)
|
|||
|
|
layout.setSpacing(12)
|
|||
|
|
|
|||
|
|
identity = QWidget()
|
|||
|
|
identity_layout = QHBoxLayout(identity)
|
|||
|
|
identity_layout.setContentsMargins(0, 0, 0, 6)
|
|||
|
|
identity_layout.setSpacing(12)
|
|||
|
|
self.avatar = QLabel("P")
|
|||
|
|
self.avatar.setObjectName("ProcessAvatar")
|
|||
|
|
self.avatar.setFixedSize(50, 50)
|
|||
|
|
self.avatar.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|||
|
|
name_layout = QVBoxLayout()
|
|||
|
|
name_layout.setSpacing(4)
|
|||
|
|
self.process_name = QLabel("—")
|
|||
|
|
self.process_name.setObjectName("ProcessName")
|
|||
|
|
self.process_name.setTextFormat(Qt.TextFormat.PlainText)
|
|||
|
|
self.pid_badge = QLabel("PID —")
|
|||
|
|
self.pid_badge.setObjectName("PidBadge")
|
|||
|
|
self.pid_badge.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
|||
|
|
name_layout.addWidget(self.process_name)
|
|||
|
|
name_layout.addWidget(self.pid_badge)
|
|||
|
|
identity_layout.addWidget(self.avatar)
|
|||
|
|
identity_layout.addLayout(name_layout, 1)
|
|||
|
|
layout.addWidget(identity)
|
|||
|
|
|
|||
|
|
self.limited_banner = QLabel("部分信息受系统权限限制")
|
|||
|
|
self.limited_banner.setObjectName("LimitedBanner")
|
|||
|
|
self.limited_banner.setWordWrap(True)
|
|||
|
|
self.limited_banner.hide()
|
|||
|
|
layout.addWidget(self.limited_banner)
|
|||
|
|
|
|||
|
|
self.rows: dict[str, DetailRow] = {}
|
|||
|
|
for key, label in (
|
|||
|
|
("ports", "监听端口"),
|
|||
|
|
("exe", "程序路径"),
|
|||
|
|
("create_time", "启动时间"),
|
|||
|
|
("cpu", "CPU 使用率"),
|
|||
|
|
("memory", "内存占用"),
|
|||
|
|
("username", "运行用户"),
|
|||
|
|
("status", "进程状态"),
|
|||
|
|
("ppid", "父进程 PID"),
|
|||
|
|
):
|
|||
|
|
row = DetailRow(label)
|
|||
|
|
if key == "ports":
|
|||
|
|
row.value_label.setObjectName("PortValue")
|
|||
|
|
self.rows[key] = row
|
|||
|
|
layout.addWidget(row)
|
|||
|
|
|
|||
|
|
command_title = QLabel("启动命令")
|
|||
|
|
command_title.setObjectName("DetailKey")
|
|||
|
|
layout.addWidget(command_title)
|
|||
|
|
self.command_box = QTextEdit()
|
|||
|
|
self.command_box.setObjectName("CommandBox")
|
|||
|
|
self.command_box.setReadOnly(True)
|
|||
|
|
self.command_box.setMinimumHeight(76)
|
|||
|
|
self.command_box.setMaximumHeight(108)
|
|||
|
|
layout.addWidget(self.command_box)
|
|||
|
|
layout.addStretch()
|
|||
|
|
|
|||
|
|
actions = QWidget()
|
|||
|
|
actions_layout = QVBoxLayout(actions)
|
|||
|
|
actions_layout.setContentsMargins(18, 10, 18, 18)
|
|||
|
|
actions_layout.setSpacing(8)
|
|||
|
|
utility_layout = QHBoxLayout()
|
|||
|
|
utility_layout.setSpacing(8)
|
|||
|
|
self.open_button = QPushButton("打开文件位置")
|
|||
|
|
self.open_button.setObjectName("SecondaryButton")
|
|||
|
|
self.copy_button = QPushButton("复制启动命令")
|
|||
|
|
self.copy_button.setObjectName("SecondaryButton")
|
|||
|
|
utility_layout.addWidget(self.open_button)
|
|||
|
|
utility_layout.addWidget(self.copy_button)
|
|||
|
|
actions_layout.addLayout(utility_layout)
|
|||
|
|
|
|||
|
|
danger_layout = QHBoxLayout()
|
|||
|
|
danger_layout.setSpacing(8)
|
|||
|
|
self.normal_button = QPushButton("正常结束")
|
|||
|
|
self.normal_button.setObjectName("SoftDangerButton")
|
|||
|
|
self.force_button = QPushButton("强制结束")
|
|||
|
|
self.force_button.setObjectName("DangerButton")
|
|||
|
|
danger_layout.addWidget(self.normal_button)
|
|||
|
|
danger_layout.addWidget(self.force_button)
|
|||
|
|
actions_layout.addLayout(danger_layout)
|
|||
|
|
|
|||
|
|
self.open_button.clicked.connect(self._request_open)
|
|||
|
|
self.copy_button.clicked.connect(self._request_copy)
|
|||
|
|
self.normal_button.clicked.connect(lambda: self._request_terminate(False))
|
|||
|
|
self.force_button.clicked.connect(lambda: self._request_terminate(True))
|
|||
|
|
|
|||
|
|
scroll.setWidget(detail_content)
|
|||
|
|
page_layout.addWidget(scroll, 1)
|
|||
|
|
page_layout.addWidget(actions)
|
|||
|
|
self.stack.addWidget(self.detail_page)
|
|||
|
|
self.detail_scroll = scroll
|
|||
|
|
|
|||
|
|
def set_process(self, process: dict[str, Any]) -> None:
|
|||
|
|
self._process = process
|
|||
|
|
name = process["name"]
|
|||
|
|
self.avatar.setText((name[:1] or "P").upper())
|
|||
|
|
self.process_name.setText(name)
|
|||
|
|
self.pid_badge.setText(f'PID {process["pid"]}')
|
|||
|
|
self.limited_banner.setVisible(bool(process["access_limited"]))
|
|||
|
|
|
|||
|
|
ports = "、".join(str(port) for port in process["ports"]) or "未监听端口"
|
|||
|
|
port_tooltip = "\n".join(
|
|||
|
|
f'{item["protocol"]} {item["address"]}:{item["port"]}'
|
|||
|
|
for item in process["port_details"]
|
|||
|
|
)
|
|||
|
|
self.rows["ports"].set_value(ports)
|
|||
|
|
self.rows["ports"].value_label.setToolTip(port_tooltip)
|
|||
|
|
self.rows["exe"].set_value(process["exe"])
|
|||
|
|
self.rows["create_time"].set_value(process["create_time_text"])
|
|||
|
|
self.rows["cpu"].set_value(f'{process["cpu_percent"]:.1f}%')
|
|||
|
|
self.rows["memory"].set_value(
|
|||
|
|
f'{process["memory_text"]} ({process["memory_percent"]:.1f}%)'
|
|||
|
|
)
|
|||
|
|
self.rows["username"].set_value(process["username"])
|
|||
|
|
self.rows["status"].set_value(process["status_label"])
|
|||
|
|
self.rows["ppid"].set_value(str(process["ppid"]) if process["ppid"] else "—")
|
|||
|
|
self.command_box.setPlainText(process["command"] or "—")
|
|||
|
|
self.open_button.setEnabled(bool(process["can_open_location"]))
|
|||
|
|
self.copy_button.setEnabled(bool(process["command"]))
|
|||
|
|
self.normal_button.setEnabled(bool(process["can_terminate"]))
|
|||
|
|
self.force_button.setEnabled(bool(process["can_terminate"]))
|
|||
|
|
self.normal_button.setToolTip(
|
|||
|
|
"向进程发送常规结束请求" if process["can_terminate"] else "不能结束 ProcessDock 自身"
|
|||
|
|
)
|
|||
|
|
self.force_button.setToolTip("立即终止进程,未保存的数据可能丢失")
|
|||
|
|
self.stack.setCurrentIndex(1)
|
|||
|
|
|
|||
|
|
def clear(self, message: str = "点击左侧任意一行,查看路径、命令行和资源占用。") -> None:
|
|||
|
|
self._process = None
|
|||
|
|
self.empty_hint.setText(message)
|
|||
|
|
self.stack.setCurrentWidget(self.empty_page)
|
|||
|
|
|
|||
|
|
def set_action_busy(self, busy: bool) -> None:
|
|||
|
|
if not self._process:
|
|||
|
|
return
|
|||
|
|
self.open_button.setEnabled(not busy and bool(self._process["can_open_location"]))
|
|||
|
|
self.copy_button.setEnabled(not busy and bool(self._process["command"]))
|
|||
|
|
self.normal_button.setEnabled(not busy and bool(self._process["can_terminate"]))
|
|||
|
|
self.force_button.setEnabled(not busy and bool(self._process["can_terminate"]))
|
|||
|
|
|
|||
|
|
def _request_open(self) -> None:
|
|||
|
|
if self._process:
|
|||
|
|
self.open_requested.emit(self._process)
|
|||
|
|
|
|||
|
|
def _request_copy(self) -> None:
|
|||
|
|
if self._process and self._process["command"]:
|
|||
|
|
self.copy_requested.emit(self._process["command"])
|
|||
|
|
|
|||
|
|
def _request_terminate(self, force: bool) -> None:
|
|||
|
|
if self._process:
|
|||
|
|
self.terminate_requested.emit(self._process, force)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class MainWindow(QMainWindow):
|
|||
|
|
def __init__(
|
|||
|
|
self, service: ProcessService | None = None, auto_refresh: bool = True, parent=None
|
|||
|
|
) -> None:
|
|||
|
|
super().__init__(parent)
|
|||
|
|
self.service = service or ProcessService()
|
|||
|
|
self.thread_pool = QThreadPool.globalInstance()
|
|||
|
|
self._workers: set[FunctionWorker] = set()
|
|||
|
|
self._refreshing = False
|
|||
|
|
self._refresh_pending = False
|
|||
|
|
self._closing = False
|
|||
|
|
self._selected_pid: int | None = None
|
|||
|
|
self._action_busy = False
|
|||
|
|
|
|||
|
|
self.setWindowTitle("ProcessDock · 进程与端口管理器")
|
|||
|
|
self.setWindowIcon(make_app_icon())
|
|||
|
|
self.setMinimumSize(1080, 700)
|
|||
|
|
self.resize(1360, 820)
|
|||
|
|
self.setStyleSheet(APP_STYLE)
|
|||
|
|
self._build_ui(auto_refresh)
|
|||
|
|
|
|||
|
|
self.auto_timer = QTimer(self)
|
|||
|
|
self.auto_timer.setInterval(3000)
|
|||
|
|
self.auto_timer.timeout.connect(self.refresh)
|
|||
|
|
self.auto_checkbox.toggled.connect(self._toggle_auto_refresh)
|
|||
|
|
if auto_refresh:
|
|||
|
|
self.auto_timer.start()
|
|||
|
|
QTimer.singleShot(0, self.refresh)
|
|||
|
|
|
|||
|
|
def _build_ui(self, auto_refresh: bool) -> None:
|
|||
|
|
root = QWidget()
|
|||
|
|
root.setObjectName("AppRoot")
|
|||
|
|
self.setCentralWidget(root)
|
|||
|
|
outer = QVBoxLayout(root)
|
|||
|
|
outer.setContentsMargins(20, 18, 20, 16)
|
|||
|
|
outer.setSpacing(12)
|
|||
|
|
|
|||
|
|
header = QFrame()
|
|||
|
|
header.setObjectName("HeaderCard")
|
|||
|
|
header_layout = QHBoxLayout(header)
|
|||
|
|
header_layout.setContentsMargins(16, 11, 16, 11)
|
|||
|
|
header_layout.setSpacing(12)
|
|||
|
|
mark = QLabel("P")
|
|||
|
|
mark.setObjectName("BrandMark")
|
|||
|
|
mark.setFixedSize(42, 42)
|
|||
|
|
mark.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|||
|
|
header_layout.addWidget(mark)
|
|||
|
|
brand = QVBoxLayout()
|
|||
|
|
brand.setSpacing(0)
|
|||
|
|
title = QLabel("ProcessDock")
|
|||
|
|
title.setObjectName("BrandTitle")
|
|||
|
|
subtitle = QLabel("进程与端口管理器")
|
|||
|
|
subtitle.setObjectName("BrandSubtitle")
|
|||
|
|
brand.addWidget(title)
|
|||
|
|
brand.addWidget(subtitle)
|
|||
|
|
header_layout.addLayout(brand)
|
|||
|
|
header_layout.addStretch()
|
|||
|
|
|
|||
|
|
self.search_box = QLineEdit()
|
|||
|
|
self.search_box.setObjectName("SearchBox")
|
|||
|
|
self.search_box.setPlaceholderText("搜索进程、PID、端口或路径")
|
|||
|
|
self.search_box.setClearButtonEnabled(True)
|
|||
|
|
self.search_box.textChanged.connect(self._filter_changed)
|
|||
|
|
header_layout.addWidget(self.search_box)
|
|||
|
|
|
|||
|
|
self.refresh_button = QPushButton("刷新")
|
|||
|
|
self.refresh_button.setObjectName("PrimaryButton")
|
|||
|
|
self.refresh_button.setIcon(
|
|||
|
|
self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload)
|
|||
|
|
)
|
|||
|
|
self.refresh_button.clicked.connect(self.refresh)
|
|||
|
|
header_layout.addWidget(self.refresh_button)
|
|||
|
|
self.auto_checkbox = QCheckBox("自动刷新 3 秒")
|
|||
|
|
self.auto_checkbox.setChecked(auto_refresh)
|
|||
|
|
header_layout.addWidget(self.auto_checkbox)
|
|||
|
|
outer.addWidget(header)
|
|||
|
|
|
|||
|
|
cards_layout = QHBoxLayout()
|
|||
|
|
cards_layout.setSpacing(10)
|
|||
|
|
self.process_card = SummaryCard("进", "运行进程", "—", "正在读取")
|
|||
|
|
self.port_card = SummaryCard("端", "监听端口", "—", "TCP 与 UDP")
|
|||
|
|
self.cpu_card = SummaryCard("CPU", "系统 CPU", "—", "所有逻辑核心")
|
|||
|
|
self.memory_card = SummaryCard("存", "系统内存", "—", "正在读取")
|
|||
|
|
for card in (self.process_card, self.port_card, self.cpu_card, self.memory_card):
|
|||
|
|
cards_layout.addWidget(card, 1)
|
|||
|
|
outer.addLayout(cards_layout)
|
|||
|
|
|
|||
|
|
self.warning_banner = QLabel()
|
|||
|
|
self.warning_banner.setObjectName("WarningBanner")
|
|||
|
|
self.warning_banner.setWordWrap(True)
|
|||
|
|
self.warning_banner.hide()
|
|||
|
|
outer.addWidget(self.warning_banner)
|
|||
|
|
|
|||
|
|
splitter = QSplitter(Qt.Orientation.Horizontal)
|
|||
|
|
splitter.setChildrenCollapsible(False)
|
|||
|
|
outer.addWidget(splitter, 1)
|
|||
|
|
|
|||
|
|
table_card = QFrame()
|
|||
|
|
table_card.setObjectName("TableCard")
|
|||
|
|
table_layout = QVBoxLayout(table_card)
|
|||
|
|
table_layout.setContentsMargins(0, 0, 0, 0)
|
|||
|
|
table_layout.setSpacing(0)
|
|||
|
|
table_heading = QWidget()
|
|||
|
|
table_heading_layout = QHBoxLayout(table_heading)
|
|||
|
|
table_heading_layout.setContentsMargins(18, 14, 18, 11)
|
|||
|
|
table_title = QLabel("正在运行的进程")
|
|||
|
|
table_title.setObjectName("SectionTitle")
|
|||
|
|
self.count_label = QLabel("准备读取…")
|
|||
|
|
self.count_label.setObjectName("MutedLabel")
|
|||
|
|
table_heading_layout.addWidget(table_title)
|
|||
|
|
table_heading_layout.addStretch()
|
|||
|
|
table_heading_layout.addWidget(self.count_label)
|
|||
|
|
table_layout.addWidget(table_heading)
|
|||
|
|
|
|||
|
|
self.loading_bar = QProgressBar()
|
|||
|
|
self.loading_bar.setObjectName("LoadingBar")
|
|||
|
|
self.loading_bar.setRange(0, 0)
|
|||
|
|
self.loading_bar.setTextVisible(False)
|
|||
|
|
self.loading_bar.hide()
|
|||
|
|
table_layout.addWidget(self.loading_bar)
|
|||
|
|
|
|||
|
|
self.model = ProcessTableModel(self)
|
|||
|
|
self.proxy = ProcessFilterProxyModel(self)
|
|||
|
|
self.proxy.setSourceModel(self.model)
|
|||
|
|
self.table = QTableView()
|
|||
|
|
self.table.setModel(self.proxy)
|
|||
|
|
self.table.setAlternatingRowColors(True)
|
|||
|
|
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
|||
|
|
self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
|||
|
|
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
|||
|
|
self.table.setShowGrid(False)
|
|||
|
|
self.table.setWordWrap(False)
|
|||
|
|
self.table.setSortingEnabled(True)
|
|||
|
|
self.table.sortByColumn(1, Qt.SortOrder.AscendingOrder)
|
|||
|
|
self.table.verticalHeader().hide()
|
|||
|
|
self.table.verticalHeader().setDefaultSectionSize(48)
|
|||
|
|
header_view = self.table.horizontalHeader()
|
|||
|
|
header_view.setDefaultAlignment(Qt.AlignmentFlag.AlignCenter)
|
|||
|
|
header_view.setMinimumSectionSize(62)
|
|||
|
|
header_view.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
|
|||
|
|
header_view.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
|||
|
|
widths = (78, 190, 135, 82, 105, 130, 88, 82)
|
|||
|
|
for column, width in enumerate(widths):
|
|||
|
|
self.table.setColumnWidth(column, width)
|
|||
|
|
self.table.selectionModel().currentRowChanged.connect(self._selection_changed)
|
|||
|
|
self.action_delegate = ActionButtonDelegate(self.table)
|
|||
|
|
self.action_delegate.terminate_requested.connect(
|
|||
|
|
lambda process: self._confirm_terminate(process, False)
|
|||
|
|
)
|
|||
|
|
self.table.setItemDelegateForColumn(7, self.action_delegate)
|
|||
|
|
|
|||
|
|
self.table_stack = QStackedWidget()
|
|||
|
|
self.table_stack.addWidget(self.table)
|
|||
|
|
self.no_results_page = self._build_no_results_page()
|
|||
|
|
self.table_stack.addWidget(self.no_results_page)
|
|||
|
|
table_layout.addWidget(self.table_stack, 1)
|
|||
|
|
splitter.addWidget(table_card)
|
|||
|
|
|
|||
|
|
self.detail_panel = DetailPanel()
|
|||
|
|
self.detail_panel.open_requested.connect(self._open_location)
|
|||
|
|
self.detail_panel.copy_requested.connect(self._copy_command)
|
|||
|
|
self.detail_panel.terminate_requested.connect(self._confirm_terminate)
|
|||
|
|
splitter.addWidget(self.detail_panel)
|
|||
|
|
splitter.setStretchFactor(0, 1)
|
|||
|
|
splitter.setStretchFactor(1, 0)
|
|||
|
|
splitter.setSizes([910, 390])
|
|||
|
|
|
|||
|
|
footer = QHBoxLayout()
|
|||
|
|
self.footer_status = QLabel("就绪")
|
|||
|
|
self.footer_status.setObjectName("FooterStatus")
|
|||
|
|
self.footer_time = QLabel("尚未刷新")
|
|||
|
|
self.footer_time.setObjectName("FooterStatus")
|
|||
|
|
footer.addWidget(self.footer_status)
|
|||
|
|
footer.addStretch()
|
|||
|
|
footer.addWidget(self.footer_time)
|
|||
|
|
outer.addLayout(footer)
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def _build_no_results_page() -> QWidget:
|
|||
|
|
page = QWidget()
|
|||
|
|
layout = QVBoxLayout(page)
|
|||
|
|
layout.addStretch()
|
|||
|
|
icon = QLabel("⌕")
|
|||
|
|
icon.setObjectName("EmptyIcon")
|
|||
|
|
icon.setFixedSize(54, 54)
|
|||
|
|
icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|||
|
|
title = QLabel("没有匹配的进程")
|
|||
|
|
title.setObjectName("EmptyTitle")
|
|||
|
|
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|||
|
|
hint = QLabel("换一个进程名称、PID 或端口试试。")
|
|||
|
|
hint.setObjectName("MutedLabel")
|
|||
|
|
hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|||
|
|
layout.addWidget(icon, alignment=Qt.AlignmentFlag.AlignCenter)
|
|||
|
|
layout.addSpacing(10)
|
|||
|
|
layout.addWidget(title)
|
|||
|
|
layout.addWidget(hint)
|
|||
|
|
layout.addStretch()
|
|||
|
|
return page
|
|||
|
|
|
|||
|
|
@Slot()
|
|||
|
|
def refresh(self) -> None:
|
|||
|
|
if self._refreshing:
|
|||
|
|
self._refresh_pending = True
|
|||
|
|
return
|
|||
|
|
self._refreshing = True
|
|||
|
|
self.refresh_button.setEnabled(False)
|
|||
|
|
self.loading_bar.show()
|
|||
|
|
self._set_status("正在读取进程和端口…")
|
|||
|
|
|
|||
|
|
worker = FunctionWorker(self.service.snapshot)
|
|||
|
|
worker.signals.result.connect(self._apply_snapshot)
|
|||
|
|
worker.signals.error.connect(self._refresh_failed)
|
|||
|
|
worker.signals.finished.connect(self._refresh_finished)
|
|||
|
|
self._track_worker(worker)
|
|||
|
|
self.thread_pool.start(worker)
|
|||
|
|
|
|||
|
|
@Slot(object)
|
|||
|
|
def _apply_snapshot(self, snapshot: dict[str, Any]) -> None:
|
|||
|
|
if self._closing:
|
|||
|
|
return
|
|||
|
|
processes = snapshot["processes"]
|
|||
|
|
self.model.set_processes(processes)
|
|||
|
|
summary = snapshot["summary"]
|
|||
|
|
self.process_card.update_value(str(summary["process_count"]), "本机可见进程")
|
|||
|
|
self.port_card.update_value(str(summary["port_count"]), "TCP 监听 / UDP 绑定")
|
|||
|
|
self.cpu_card.update_value(f'{summary["cpu_percent"]:.1f}%', "所有逻辑核心")
|
|||
|
|
self.memory_card.update_value(
|
|||
|
|
f'{summary["memory_percent"]:.1f}%',
|
|||
|
|
f'{summary["memory_used"]} / {summary["memory_total"]}',
|
|||
|
|
)
|
|||
|
|
warnings = snapshot.get("warnings") or []
|
|||
|
|
self.warning_banner.setText(" ".join(warnings))
|
|||
|
|
self.warning_banner.setVisible(bool(warnings))
|
|||
|
|
self.footer_time.setText(f'上次刷新 {snapshot["refreshed_at"]}')
|
|||
|
|
self._set_status("数据已更新")
|
|||
|
|
self._update_visible_count()
|
|||
|
|
|
|||
|
|
if self._selected_pid is not None:
|
|||
|
|
selected = next(
|
|||
|
|
(process for process in processes if process["pid"] == self._selected_pid),
|
|||
|
|
None,
|
|||
|
|
)
|
|||
|
|
if selected is None:
|
|||
|
|
self._selected_pid = None
|
|||
|
|
self.detail_panel.clear("该进程已经结束,请重新选择。")
|
|||
|
|
else:
|
|||
|
|
self.detail_panel.set_process(selected)
|
|||
|
|
self._reselect_process(self._selected_pid)
|
|||
|
|
|
|||
|
|
@Slot(str)
|
|||
|
|
def _refresh_failed(self, message: str) -> None:
|
|||
|
|
self.warning_banner.setText(f"刷新失败:{message}")
|
|||
|
|
self.warning_banner.show()
|
|||
|
|
self._set_status("刷新失败")
|
|||
|
|
|
|||
|
|
@Slot()
|
|||
|
|
def _refresh_finished(self) -> None:
|
|||
|
|
self._refreshing = False
|
|||
|
|
self.refresh_button.setEnabled(True)
|
|||
|
|
self.loading_bar.hide()
|
|||
|
|
if self._refresh_pending and not self._closing:
|
|||
|
|
self._refresh_pending = False
|
|||
|
|
QTimer.singleShot(0, self.refresh)
|
|||
|
|
|
|||
|
|
def _track_worker(self, worker: FunctionWorker) -> None:
|
|||
|
|
self._workers.add(worker)
|
|||
|
|
worker.signals.finished.connect(lambda current=worker: self._workers.discard(current))
|
|||
|
|
|
|||
|
|
@Slot(str)
|
|||
|
|
def _filter_changed(self, query: str) -> None:
|
|||
|
|
self.proxy.set_query(query)
|
|||
|
|
self._update_visible_count()
|
|||
|
|
|
|||
|
|
def _update_visible_count(self) -> None:
|
|||
|
|
visible = self.proxy.rowCount()
|
|||
|
|
total = self.model.rowCount()
|
|||
|
|
self.count_label.setText(
|
|||
|
|
f"{visible} / {total} 个进程" if self.search_box.text().strip() else f"{total} 个进程"
|
|||
|
|
)
|
|||
|
|
self.table_stack.setCurrentWidget(self.table if visible else self.no_results_page)
|
|||
|
|
|
|||
|
|
@Slot(QModelIndex, QModelIndex)
|
|||
|
|
def _selection_changed(self, current: QModelIndex, previous: QModelIndex) -> None:
|
|||
|
|
del previous
|
|||
|
|
if not current.isValid():
|
|||
|
|
return
|
|||
|
|
process = current.data(PROCESS_ROLE)
|
|||
|
|
if process:
|
|||
|
|
self._selected_pid = process["pid"]
|
|||
|
|
self.detail_panel.set_process(process)
|
|||
|
|
|
|||
|
|
def _reselect_process(self, pid: int) -> None:
|
|||
|
|
for source_row, process in enumerate(self.model.processes):
|
|||
|
|
if process["pid"] != pid:
|
|||
|
|
continue
|
|||
|
|
source_index = self.model.index(source_row, 0)
|
|||
|
|
proxy_index = self.proxy.mapFromSource(source_index)
|
|||
|
|
if proxy_index.isValid():
|
|||
|
|
self.table.selectRow(proxy_index.row())
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
@Slot(bool)
|
|||
|
|
def _toggle_auto_refresh(self, enabled: bool) -> None:
|
|||
|
|
if enabled:
|
|||
|
|
self.auto_timer.start()
|
|||
|
|
self._set_status("已开启自动刷新")
|
|||
|
|
else:
|
|||
|
|
self.auto_timer.stop()
|
|||
|
|
self._set_status("已暂停自动刷新")
|
|||
|
|
|
|||
|
|
@Slot(object, bool)
|
|||
|
|
def _confirm_terminate(self, process: dict[str, Any], force: bool) -> None:
|
|||
|
|
if self._action_busy or not process.get("can_terminate"):
|
|||
|
|
return
|
|||
|
|
name = process["name"]
|
|||
|
|
pid = process["pid"]
|
|||
|
|
box = QMessageBox(self)
|
|||
|
|
box.setIcon(QMessageBox.Icon.Warning)
|
|||
|
|
box.setWindowTitle("确认强制结束" if force else "确认结束进程")
|
|||
|
|
box.setText(f"确定要结束 {name}(PID {pid})吗?")
|
|||
|
|
box.setInformativeText(
|
|||
|
|
"强制结束可能导致未保存的数据丢失。"
|
|||
|
|
if force
|
|||
|
|
else "将先发送常规结束请求;如果进程没有响应,可再强制结束。"
|
|||
|
|
)
|
|||
|
|
confirm = box.addButton(
|
|||
|
|
"强制结束" if force else "正常结束", QMessageBox.ButtonRole.AcceptRole
|
|||
|
|
)
|
|||
|
|
box.addButton("取消", QMessageBox.ButtonRole.RejectRole)
|
|||
|
|
box.setDefaultButton(confirm)
|
|||
|
|
box.exec()
|
|||
|
|
if box.clickedButton() is not confirm:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
self._action_busy = True
|
|||
|
|
if self._selected_pid == pid:
|
|||
|
|
self.detail_panel.set_action_busy(True)
|
|||
|
|
self._set_status(f"正在结束 {name}…")
|
|||
|
|
worker = FunctionWorker(
|
|||
|
|
lambda: self.service.terminate(
|
|||
|
|
pid,
|
|||
|
|
force=force,
|
|||
|
|
expected_create_time=process.get("create_time"),
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
worker.signals.result.connect(self._termination_finished)
|
|||
|
|
worker.signals.error.connect(self._action_failed)
|
|||
|
|
worker.signals.finished.connect(self._action_worker_finished)
|
|||
|
|
self._track_worker(worker)
|
|||
|
|
self.thread_pool.start(worker)
|
|||
|
|
|
|||
|
|
@Slot(object)
|
|||
|
|
def _termination_finished(self, result: ActionResult) -> None:
|
|||
|
|
self._set_status(result.message)
|
|||
|
|
if result.still_running:
|
|||
|
|
QMessageBox.warning(self, "进程仍在运行", result.message)
|
|||
|
|
self.refresh()
|
|||
|
|
|
|||
|
|
@Slot(str)
|
|||
|
|
def _action_failed(self, message: str) -> None:
|
|||
|
|
self._set_status("操作失败")
|
|||
|
|
QMessageBox.critical(self, "操作失败", message)
|
|||
|
|
|
|||
|
|
@Slot()
|
|||
|
|
def _action_worker_finished(self) -> None:
|
|||
|
|
self._action_busy = False
|
|||
|
|
self.detail_panel.set_action_busy(False)
|
|||
|
|
|
|||
|
|
@Slot(object)
|
|||
|
|
def _open_location(self, process: dict[str, Any]) -> None:
|
|||
|
|
if self._action_busy:
|
|||
|
|
return
|
|||
|
|
self._action_busy = True
|
|||
|
|
self.detail_panel.set_action_busy(True)
|
|||
|
|
worker = FunctionWorker(
|
|||
|
|
lambda: self.service.open_file_location(
|
|||
|
|
process["pid"], expected_create_time=process.get("create_time")
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
worker.signals.result.connect(lambda result: self._set_status(result.message))
|
|||
|
|
worker.signals.error.connect(self._action_failed)
|
|||
|
|
worker.signals.finished.connect(self._action_worker_finished)
|
|||
|
|
self._track_worker(worker)
|
|||
|
|
self.thread_pool.start(worker)
|
|||
|
|
|
|||
|
|
@Slot(str)
|
|||
|
|
def _copy_command(self, command: str) -> None:
|
|||
|
|
QGuiApplication.clipboard().setText(command)
|
|||
|
|
self._set_status("启动命令已复制到剪贴板")
|
|||
|
|
|
|||
|
|
def _set_status(self, text: str) -> None:
|
|||
|
|
self.footer_status.setText(text)
|
|||
|
|
|
|||
|
|
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
|
|||
|
|
self._closing = True
|
|||
|
|
self.auto_timer.stop()
|
|||
|
|
self.thread_pool.clear()
|
|||
|
|
super().closeEvent(event)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def make_app_icon() -> QIcon:
|
|||
|
|
pixmap = QPixmap(64, 64)
|
|||
|
|
pixmap.fill(Qt.GlobalColor.transparent)
|
|||
|
|
painter = QPainter(pixmap)
|
|||
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|||
|
|
painter.setPen(Qt.PenStyle.NoPen)
|
|||
|
|
painter.setBrush(QColor("#5B5BD6"))
|
|||
|
|
painter.drawRoundedRect(4, 4, 56, 56, 15, 15)
|
|||
|
|
painter.setPen(QColor("#FFFFFF"))
|
|||
|
|
font = QFont("Arial", 29, QFont.Weight.Bold)
|
|||
|
|
painter.setFont(font)
|
|||
|
|
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "P")
|
|||
|
|
painter.end()
|
|||
|
|
return QIcon(pixmap)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def create_window_for_testing(service: ProcessService | None = None) -> MainWindow:
|
|||
|
|
"""Small test hook used by the off-screen smoke check."""
|
|||
|
|
if QApplication.instance() is None:
|
|||
|
|
raise RuntimeError("A QApplication must exist before creating the window.")
|
|||
|
|
return MainWindow(service=service, auto_refresh=False)
|